Skip to content

Commit 71c93be

Browse files
authored
feat: allow safehttp trusted private networks (#4784)
## Context Some deployments run Cozy services on a closed private network. In those setups, safehttp currently blocks calls to internal services because their hosts resolve to private IPs. This adds an explicit opt-in allowlist for those deployments. By default nothing changes: private IPs are still blocked unless an operator configures trusted private CIDRs. ## Changes - Add `safe_http.trusted_private_networks` to the global config. - Load and validate the configured CIDRs at startup. - Allow safehttp calls to private or loopback IPs inside those CIDRs, including custom ports. - Keep public custom ports, link-local addresses, unspecified addresses, malformed addresses, and unsupported networks blocked.
2 parents f9d2cd6 + d04cd63 commit 71c93be

6 files changed

Lines changed: 237 additions & 0 deletions

File tree

cozy.example.yaml

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -592,6 +592,18 @@ contexts:
592592
auto_accept_trusted_contacts: false
593593
trusted_domains: []
594594

595+
# WARNING: Closed-network deployments only.
596+
# Allows safehttp callers to reach private CIDRs, bypassing SSRF protections
597+
# for those addresses (including non-standard ports).
598+
# This affects sharing, move/import/export, WebDAV, BI, common settings,
599+
# Bitwarden icon fetching, and all other safehttp-backed outbound calls.
600+
# Default behavior (empty list) keeps all private addresses blocked.
601+
# safe_http:
602+
# trusted_private_networks:
603+
# - 10.0.0.0/8
604+
# - 172.16.0.0/12
605+
# - 192.168.0.0/16
606+
595607
rabbitmq:
596608
enabled: true
597609
nodes:

pkg/config/config/config.go

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ import (
3030
"github.com/cozy/cozy-stack/pkg/lock"
3131
"github.com/cozy/cozy-stack/pkg/logger"
3232
"github.com/cozy/cozy-stack/pkg/pdf"
33+
"github.com/cozy/cozy-stack/pkg/safehttp"
3334
"github.com/cozy/cozy-stack/pkg/tlsclient"
3435
"github.com/cozy/cozy-stack/pkg/utils"
3536
"github.com/cozy/gomail"
@@ -151,6 +152,10 @@ type Config struct {
151152

152153
RabbitMQ RabbitMQ
153154

155+
// SafeHTTPTrustedNetworks is a list of private CIDRs that safehttp
156+
// callers are allowed to reach. For closed-network deployments only.
157+
SafeHTTPTrustedNetworks []string
158+
154159
RemoteAllowCustomPort bool
155160

156161
CSPDisabled bool
@@ -1138,6 +1143,11 @@ func UseViper(v *viper.Viper) error {
11381143
return fmt.Errorf(`failed to parse the config for "rabbitmq": %w`, err)
11391144
}
11401145

1146+
config.SafeHTTPTrustedNetworks = v.GetStringSlice("safe_http.trusted_private_networks")
1147+
if err = safehttp.SetTrustedPrivateNetworks(config.SafeHTTPTrustedNetworks); err != nil {
1148+
return fmt.Errorf("invalid safe_http.trusted_private_networks config: %w", err)
1149+
}
1150+
11411151
// For compatibility
11421152
if len(config.CSPAllowList) == 0 {
11431153
config.CSPAllowList = v.GetStringMapString("csp_whitelist")

pkg/config/config/config_test.go

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import (
88
"time"
99

1010
"github.com/cozy/cozy-stack/pkg/prefixer"
11+
"github.com/cozy/cozy-stack/pkg/safehttp"
1112
"github.com/cozy/gomail"
1213
"github.com/sirupsen/logrus"
1314
"github.com/spf13/viper"
@@ -302,6 +303,9 @@ func TestConfigUnmarshal(t *testing.T) {
302303
"connect": "https://connect-url",
303304
},
304305
}, cfg.CSPPerContext)
306+
307+
// SafeHTTP
308+
assert.Equal(t, []string{"10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16"}, cfg.SafeHTTPTrustedNetworks)
305309
}
306310

307311
func TestUseViper(t *testing.T) {
@@ -513,3 +517,35 @@ rabbitmq:
513517
assert.Equal(t, 8, queue2b.Prefetch)
514518
assert.Equal(t, 2, queue2b.DeliveryLimit)
515519
}
520+
521+
func TestSafeHTTPConfig(t *testing.T) {
522+
t.Cleanup(func() { _ = safehttp.SetTrustedPrivateNetworks(nil) })
523+
524+
t.Run("valid CIDRs map through UseViper", func(t *testing.T) {
525+
cfg := viper.New()
526+
cfg.Set("safe_http.trusted_private_networks", []string{"10.0.0.0/8", "172.16.0.0/12"})
527+
require.NoError(t, UseViper(cfg))
528+
assert.Equal(t, []string{"10.0.0.0/8", "172.16.0.0/12"}, GetConfig().SafeHTTPTrustedNetworks)
529+
})
530+
531+
t.Run("invalid CIDR makes UseViper fail", func(t *testing.T) {
532+
cfg := viper.New()
533+
cfg.Set("safe_http.trusted_private_networks", []string{"not-a-cidr"})
534+
err := UseViper(cfg)
535+
require.Error(t, err)
536+
assert.Contains(t, err.Error(), "invalid safe_http.trusted_private_networks config")
537+
})
538+
539+
t.Run("empty config keeps strict defaults", func(t *testing.T) {
540+
cfg := viper.New()
541+
require.NoError(t, UseViper(cfg))
542+
assert.Empty(t, GetConfig().SafeHTTPTrustedNetworks)
543+
})
544+
545+
t.Run("missing key keeps strict defaults", func(t *testing.T) {
546+
cfg := viper.New()
547+
cfg.Set("host", "localhost")
548+
require.NoError(t, UseViper(cfg))
549+
assert.Empty(t, GetConfig().SafeHTTPTrustedNetworks)
550+
})
551+
}

pkg/config/config/testdata/full_config.yaml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,12 @@ clouderies:
115115
url: https://manager-url
116116
token: manager-token
117117

118+
safe_http:
119+
trusted_private_networks:
120+
- 10.0.0.0/8
121+
- 172.16.0.0/12
122+
- 192.168.0.0/16
123+
118124
rabbitmq:
119125
enabled: true
120126
nodes:

pkg/safehttp/client.go

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,18 +2,49 @@
22
// not trusted (user inputs). It will avoid SSRF by ensuring that the IP
33
// address which will connect is not a private address, or loopback. It also
44
// checks that the port is 80 or 443, not anything else.
5+
//
6+
// Operators can configure trusted private networks (CIDRs) to allow safehttp
7+
// callers to reach private addresses in closed-network deployments.
58
package safehttp
69

710
import (
811
"fmt"
912
"net"
1013
"net/http"
14+
"sync/atomic"
1115
"syscall"
1216
"time"
1317

1418
build "github.com/cozy/cozy-stack/pkg/config"
1519
)
1620

21+
// trustedPrivateNetworks holds the parsed CIDRs that are allowed to bypass
22+
// private-IP and port restrictions. Set once at startup via
23+
// SetTrustedPrivateNetworks. Stored as an atomic.Value to avoid data races
24+
// between config/test setup and concurrent outbound requests.
25+
var trustedPrivateNetworks atomic.Value // holds []*net.IPNet
26+
27+
// SetTrustedPrivateNetworks parses the given CIDR strings and stores them for
28+
// use by safeControl. It should be called once at startup. If any CIDR is
29+
// invalid, it returns an error and does not update the allowlist.
30+
func SetTrustedPrivateNetworks(cidrs []string) error {
31+
nets := make([]*net.IPNet, 0, len(cidrs))
32+
for _, cidr := range cidrs {
33+
_, ipnet, err := net.ParseCIDR(cidr)
34+
if err != nil {
35+
return fmt.Errorf("invalid trusted private network CIDR %q: %w", cidr, err)
36+
}
37+
nets = append(nets, ipnet)
38+
}
39+
trustedPrivateNetworks.Store(nets)
40+
return nil
41+
}
42+
43+
func loadTrustedNetworks() []*net.IPNet {
44+
nets, _ := trustedPrivateNetworks.Load().([]*net.IPNet)
45+
return nets
46+
}
47+
1748
var safeDialer = &net.Dialer{
1849
Timeout: 30 * time.Second,
1950
KeepAlive: 30 * time.Second,
@@ -81,6 +112,14 @@ func safeControl(network string, address string, conn syscall.RawConn) error {
81112
return fmt.Errorf("%s is not a valid IP address", host)
82113
}
83114

115+
// Trusted private networks bypass private-IP, loopback, and port
116+
// restrictions only for private or loopback destinations. This is for
117+
// closed-network deployments where internal services run on private
118+
// addresses and non-standard ports.
119+
if (ipaddress.IsPrivate() || ipaddress.IsLoopback()) && isInTrustedNetwork(ipaddress) {
120+
return nil
121+
}
122+
84123
if ipaddress.IsPrivate() {
85124
return fmt.Errorf("%s is not a public IP address", ipaddress)
86125
}
@@ -102,3 +141,12 @@ func safeControl(network string, address string, conn syscall.RawConn) error {
102141

103142
return nil
104143
}
144+
145+
func isInTrustedNetwork(ip net.IP) bool {
146+
for _, ipnet := range loadTrustedNetworks() {
147+
if ipnet.Contains(ip) {
148+
return true
149+
}
150+
}
151+
return false
152+
}

pkg/safehttp/client_test.go

Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,3 +23,128 @@ func TestDefaultClient(t *testing.T) {
2323
require.Error(t, err)
2424
assert.Contains(t, err.Error(), "is not a safe port")
2525
}
26+
27+
func TestSetTrustedPrivateNetworks(t *testing.T) {
28+
t.Run("invalid CIDR returns error", func(t *testing.T) {
29+
err := SetTrustedPrivateNetworks([]string{"not-a-cidr"})
30+
assert.Error(t, err)
31+
assert.Contains(t, err.Error(), "invalid trusted private network CIDR")
32+
})
33+
34+
t.Run("valid CIDRs are accepted", func(t *testing.T) {
35+
err := SetTrustedPrivateNetworks([]string{"10.0.0.0/8", "192.168.0.0/16"})
36+
assert.NoError(t, err)
37+
t.Cleanup(resetTrustedNetworks)
38+
})
39+
40+
t.Run("invalid CIDR does not change existing allowlist", func(t *testing.T) {
41+
err := SetTrustedPrivateNetworks([]string{"10.0.0.0/8"})
42+
require.NoError(t, err)
43+
require.Len(t, loadTrustedNetworks(), 1)
44+
45+
err = SetTrustedPrivateNetworks([]string{"bad"})
46+
require.Error(t, err)
47+
// The previous valid config is still in place.
48+
assert.Len(t, loadTrustedNetworks(), 1)
49+
t.Cleanup(resetTrustedNetworks)
50+
})
51+
52+
t.Run("empty list clears allowlist", func(t *testing.T) {
53+
err := SetTrustedPrivateNetworks([]string{"10.0.0.0/8"})
54+
require.NoError(t, err)
55+
56+
err = SetTrustedPrivateNetworks([]string{})
57+
require.NoError(t, err)
58+
assert.Empty(t, loadTrustedNetworks())
59+
})
60+
}
61+
62+
func resetTrustedNetworks() {
63+
_ = SetTrustedPrivateNetworks(nil)
64+
}
65+
66+
func TestSafeControl(t *testing.T) {
67+
build.BuildMode = build.ModeProd
68+
t.Cleanup(resetTrustedNetworks)
69+
70+
t.Run("private IP blocked by default", func(t *testing.T) {
71+
err := safeControl("tcp4", "192.168.1.1:80", nil)
72+
assert.Error(t, err)
73+
assert.Contains(t, err.Error(), "is not a public IP address")
74+
})
75+
76+
t.Run("loopback blocked by default", func(t *testing.T) {
77+
err := safeControl("tcp4", "127.0.0.1:80", nil)
78+
assert.Error(t, err)
79+
assert.Contains(t, err.Error(), "is not a public IP address")
80+
})
81+
82+
t.Run("private IP allowed when inside configured CIDR", func(t *testing.T) {
83+
require.NoError(t, SetTrustedPrivateNetworks([]string{"192.168.0.0/16"}))
84+
err := safeControl("tcp4", "192.168.1.1:80", nil)
85+
assert.NoError(t, err)
86+
})
87+
88+
t.Run("private IP on non-standard port allowed when inside configured CIDR", func(t *testing.T) {
89+
require.NoError(t, SetTrustedPrivateNetworks([]string{"10.0.0.0/8"}))
90+
err := safeControl("tcp4", "10.0.0.1:8080", nil)
91+
assert.NoError(t, err)
92+
})
93+
94+
t.Run("private IP outside configured CIDR remains blocked", func(t *testing.T) {
95+
require.NoError(t, SetTrustedPrivateNetworks([]string{"10.0.0.0/8"}))
96+
err := safeControl("tcp4", "192.168.1.1:80", nil)
97+
assert.Error(t, err)
98+
assert.Contains(t, err.Error(), "is not a public IP address")
99+
})
100+
101+
t.Run("loopback allowed only if explicitly configured", func(t *testing.T) {
102+
require.NoError(t, SetTrustedPrivateNetworks([]string{"192.168.0.0/16"}))
103+
err := safeControl("tcp4", "127.0.0.1:80", nil)
104+
assert.Error(t, err)
105+
106+
require.NoError(t, SetTrustedPrivateNetworks([]string{"127.0.0.0/8"}))
107+
err = safeControl("tcp4", "127.0.0.1:80", nil)
108+
assert.NoError(t, err)
109+
})
110+
111+
t.Run("link-local remains blocked even with trusted networks", func(t *testing.T) {
112+
require.NoError(t, SetTrustedPrivateNetworks([]string{"0.0.0.0/0"}))
113+
err := safeControl("tcp4", "169.254.1.1:80", nil)
114+
assert.Error(t, err)
115+
assert.Contains(t, err.Error(), "is not a valid IP address")
116+
})
117+
118+
t.Run("unspecified address remains blocked even with trusted networks", func(t *testing.T) {
119+
require.NoError(t, SetTrustedPrivateNetworks([]string{"0.0.0.0/0"}))
120+
err := safeControl("tcp4", "0.0.0.0:80", nil)
121+
assert.Error(t, err)
122+
assert.Contains(t, err.Error(), "is not a valid IP address")
123+
})
124+
125+
t.Run("unsupported network type rejected", func(t *testing.T) {
126+
err := safeControl("udp", "10.0.0.1:80", nil)
127+
assert.Error(t, err)
128+
assert.Contains(t, err.Error(), "is not a safe network type")
129+
})
130+
131+
t.Run("public IP on non-standard port still blocked", func(t *testing.T) {
132+
require.NoError(t, SetTrustedPrivateNetworks([]string{"10.0.0.0/8"}))
133+
err := safeControl("tcp4", "8.8.8.8:8080", nil)
134+
assert.Error(t, err)
135+
assert.Contains(t, err.Error(), "is not a safe port number")
136+
})
137+
138+
t.Run("public IP on non-standard port blocked even with broad trusted CIDR", func(t *testing.T) {
139+
require.NoError(t, SetTrustedPrivateNetworks([]string{"0.0.0.0/0"}))
140+
err := safeControl("tcp4", "8.8.8.8:8080", nil)
141+
assert.Error(t, err)
142+
assert.Contains(t, err.Error(), "is not a safe port number")
143+
})
144+
145+
t.Run("IPv6 private address with matching CIDR", func(t *testing.T) {
146+
require.NoError(t, SetTrustedPrivateNetworks([]string{"::1/128"}))
147+
err := safeControl("tcp6", "[::1]:8080", nil)
148+
assert.NoError(t, err)
149+
})
150+
}

0 commit comments

Comments
 (0)