Files
vikunja/pkg/utils/httpclient_test.go
kolaenteandTink dd52c36dd1 fix(utils): exempt the proxy dial from the SSRF guard
Outgoing proxies usually live on a private network, so the SSRF guard
refused to connect to them unless allownonroutableips was enabled,
which disabled the guard for direct connections too. Only the dial to
the configured proxy skips the guard now; proxied targets are resolved
by the proxy.
2026-09-24 18:06:12 +02:00

177 lines
6.3 KiB
Go

// Vikunja is a to-do list application to facilitate your life.
// Copyright 2018-present Vikunja and contributors. All rights reserved.
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package utils
import (
"context"
"net/http"
"net/http/httptest"
"sync/atomic"
"testing"
"time"
"code.vikunja.io/api/pkg/config"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestNewSSRFSafeHTTPClient(t *testing.T) {
t.Run("returns a non-nil client", func(t *testing.T) {
client := NewSSRFSafeHTTPClient()
assert.NotNil(t, client)
})
t.Run("can reach a routable test server", func(t *testing.T) {
config.OutgoingRequestsAllowNonRoutableIPs.Set("true")
defer config.OutgoingRequestsAllowNonRoutableIPs.Set("false")
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK)
}))
defer server.Close()
client := NewSSRFSafeHTTPClient()
req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, server.URL, nil)
require.NoError(t, err)
resp, err := client.Do(req) //nolint:gosec // testing SSRF-safe client
require.NoError(t, err)
defer resp.Body.Close()
assert.Equal(t, http.StatusOK, resp.StatusCode)
})
t.Run("blocks non-routable IPs when config is false", func(t *testing.T) {
config.OutgoingRequestsAllowNonRoutableIPs.Set("false")
client := NewSSRFSafeHTTPClient()
// Attempt to connect to localhost (non-routable)
req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, "http://127.0.0.1:1/test", nil)
require.NoError(t, err)
_, err = client.Do(req) //nolint:bodyclose,gosec // testing SSRF-safe client
require.Error(t, err)
})
t.Run("has default timeout from config", func(t *testing.T) {
config.OutgoingRequestsTimeoutSeconds.Set("30")
client := NewSSRFSafeHTTPClient()
assert.Equal(t, 30*time.Second, client.Timeout)
})
t.Run("respects custom timeout config", func(t *testing.T) {
config.OutgoingRequestsTimeoutSeconds.Set("15")
defer config.OutgoingRequestsTimeoutSeconds.Set("30")
client := NewSSRFSafeHTTPClient()
assert.Equal(t, 15*time.Second, client.Timeout)
})
t.Run("timeout fires on slow server", func(t *testing.T) {
config.OutgoingRequestsAllowNonRoutableIPs.Set("true")
config.OutgoingRequestsTimeoutSeconds.Set("1")
defer config.OutgoingRequestsAllowNonRoutableIPs.Set("false")
defer config.OutgoingRequestsTimeoutSeconds.Set("30")
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
time.Sleep(3 * time.Second)
w.WriteHeader(http.StatusOK)
}))
defer server.Close()
client := NewSSRFSafeHTTPClient()
req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, server.URL, nil)
require.NoError(t, err)
_, err = client.Do(req) //nolint:bodyclose,gosec
require.Error(t, err)
assert.Contains(t, err.Error(), "Client.Timeout")
})
t.Run("allows non-routable IPs when config is true", func(t *testing.T) {
config.OutgoingRequestsAllowNonRoutableIPs.Set("true")
defer config.OutgoingRequestsAllowNonRoutableIPs.Set("false")
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK)
}))
defer server.Close()
client := NewSSRFSafeHTTPClient()
req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, server.URL, nil)
require.NoError(t, err)
resp, err := client.Do(req) //nolint:gosec // testing SSRF-safe client
require.NoError(t, err)
defer resp.Body.Close()
assert.Equal(t, http.StatusOK, resp.StatusCode)
})
}
// newFakeProxy answers every request itself, so a hit proves the client routed through it.
func newFakeProxy(t *testing.T) (*httptest.Server, *atomic.Int32) {
t.Helper()
var hits atomic.Int32
proxy := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
hits.Add(1)
w.WriteHeader(http.StatusOK)
}))
t.Cleanup(proxy.Close)
return proxy, &hits
}
func TestNewSSRFSafeHTTPClientProxy(t *testing.T) {
config.OutgoingRequestsAllowNonRoutableIPs.Set("false")
t.Run("uses HTTP_PROXY from the environment", func(t *testing.T) {
proxy, hits := newFakeProxy(t)
t.Setenv("HTTP_PROXY", proxy.URL)
t.Setenv("NO_PROXY", "")
req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, "http://vikunja-proxy-test.invalid/", nil)
require.NoError(t, err)
resp, err := NewSSRFSafeHTTPClient().Do(req) //nolint:gosec // testing SSRF-safe client
require.NoError(t, err)
defer resp.Body.Close()
assert.Equal(t, int32(1), hits.Load())
})
t.Run("uses the configured proxy on a non-routable address", func(t *testing.T) {
proxy, hits := newFakeProxy(t)
config.OutgoingRequestsProxyURL.Set(proxy.URL)
config.OutgoingRequestsProxyPassword.Set("secret")
defer config.OutgoingRequestsProxyURL.Set("")
defer config.OutgoingRequestsProxyPassword.Set("")
req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, "http://vikunja-proxy-test.invalid/", nil)
require.NoError(t, err)
resp, err := NewSSRFSafeHTTPClient().Do(req) //nolint:gosec // testing SSRF-safe client
require.NoError(t, err)
defer resp.Body.Close()
assert.Equal(t, int32(1), hits.Load())
})
t.Run("still blocks direct non-routable targets when a proxy is set", func(t *testing.T) {
proxy, _ := newFakeProxy(t)
target, targetHits := newFakeProxy(t)
t.Setenv("HTTP_PROXY", proxy.URL)
// Loopback targets bypass the env proxy, so this dial is direct.
req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, target.URL, nil)
require.NoError(t, err)
_, err = NewSSRFSafeHTTPClient().Do(req) //nolint:bodyclose,gosec // testing SSRF-safe client
require.Error(t, err)
assert.Equal(t, int32(0), targetHits.Load())
})
}