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)
+ })
+}