diff --git a/pkg/utils/httpclient.go b/pkg/utils/httpclient.go new file mode 100644 index 000000000..57b9b0b58 --- /dev/null +++ b/pkg/utils/httpclient.go @@ -0,0 +1,64 @@ +// 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 . + +package utils + +import ( + "encoding/base64" + "net" + "net/http" + "net/url" + + "code.vikunja.io/api/pkg/config" + "code.vikunja.io/api/pkg/version" + + "code.dny.dev/ssrf" +) + +// NewSSRFSafeHTTPClient returns an *http.Client with SSRF protection applied. +// It blocks connections to non-globally-routable IP addresses (loopback, +// private ranges, link-local, etc.) unless outgoingrequests.allownonroutableips +// is set to true. It also configures proxy settings from outgoingrequests config. +// +// Deprecated webhooks.* config keys are migrated to outgoingrequests.* at +// config init time (see config.InitDefaultConfig), so this function only +// reads the new keys. +func NewSSRFSafeHTTPClient() *http.Client { + client := &http.Client{} + transport := &http.Transport{} + + if !config.OutgoingRequestsAllowNonRoutableIPs.GetBool() { + guardian := ssrf.New(ssrf.WithAnyPort()) + transport.DialContext = (&net.Dialer{ + Control: guardian.Safe, + }).DialContext + } + + proxyURL := config.OutgoingRequestsProxyURL.GetString() + proxyPassword := config.OutgoingRequestsProxyPassword.GetString() + + if proxyURL != "" && proxyPassword != "" { + parsedURL, _ := url.Parse(proxyURL) + transport.Proxy = http.ProxyURL(parsedURL) + transport.ProxyConnectHeader = http.Header{ + "Proxy-Authorization": []string{"Basic " + base64.StdEncoding.EncodeToString([]byte("vikunja:"+proxyPassword))}, + "User-Agent": []string{"Vikunja/" + version.Version}, + } + } + + client.Transport = transport + return client +} diff --git a/pkg/utils/httpclient_test.go b/pkg/utils/httpclient_test.go new file mode 100644 index 000000000..82280b676 --- /dev/null +++ b/pkg/utils/httpclient_test.go @@ -0,0 +1,76 @@ +// 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 . + +package utils + +import ( + "net/http" + "net/http/httptest" + "testing" + + "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, r *http.Request) { + w.WriteHeader(http.StatusOK) + })) + defer server.Close() + + client := NewSSRFSafeHTTPClient() + resp, err := client.Get(server.URL) + 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) + _, err := client.Get("http://127.0.0.1:1/test") + require.Error(t, err) + }) + + 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, r *http.Request) { + w.WriteHeader(http.StatusOK) + })) + defer server.Close() + + client := NewSSRFSafeHTTPClient() + resp, err := client.Get(server.URL) + require.NoError(t, err) + defer resp.Body.Close() + assert.Equal(t, http.StatusOK, resp.StatusCode) + }) +}