From 24d5e42ffa33eebdf906c495713f0d2bae30c0cb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B8rn=20Erik=20Pedersen?= Date: Thu, 3 Sep 2026 13:29:38 +0200 Subject: [PATCH] config/security: Harden the default http.urls and resolved address checks (#15285) The default IP-literal deny rule was case-sensitive, so an uppercase scheme (e.g. HTTP://127.0.0.1/) slipped past it. Make it case-insensitive like the other default rules. CheckAllowedHTTPAddress relied on IsGlobalUnicast/IsPrivate, which admit CGNAT (100.64.0.0/10), TEST-NET, benchmarking, reserved and IPv6 documentation ranges, and NAT64 addresses embedding an internal IPv4 address. Deny those explicitly and unwrap NAT64 before classifying. Thanks to @0xdeadbab3 for finding and reporting this issue. Co-authored-by: Claude Fable 5.1 --- config/security/securityConfig.go | 45 ++++++++++++++++++++++++-- config/security/securityConfig_test.go | 20 ++++++++++-- docs/data/docs.yaml | 2 +- 3 files changed, 61 insertions(+), 6 deletions(-) diff --git a/config/security/securityConfig.go b/config/security/securityConfig.go index b47cdf497..f7cc157b8 100644 --- a/config/security/securityConfig.go +++ b/config/security/securityConfig.go @@ -61,7 +61,7 @@ var DefaultConfig = Config{ // blocks; users who need them can override security.http.urls. URLs: MustNewWhitelist( `(?i)^https?://[a-z0-9]`, - `! ^https?://\d+\.`, + `! (?i)^https?://\d+\.`, `! (?i)localhost`, `! (?i)^https?://[^/?#]*@`, ), @@ -249,13 +249,52 @@ func (c Config) CheckAllowedHTTPAddress(network, address string) error { // is unexpected, so fail closed. return deny(address) } - ip = ip.Unmap() - if !ip.IsGlobalUnicast() || ip.IsPrivate() { + if !isPublicAddr(ip) { return deny(host) } return nil } +// Special-purpose ranges that Go classifies as global unicast and +// non-private, but that are never reachable on the public Internet. +var nonPublicPrefixes = []netip.Prefix{ + netip.MustParsePrefix("100.64.0.0/10"), // Shared address space (CGNAT), RFC 6598. + netip.MustParsePrefix("192.0.0.0/24"), // IETF protocol assignments. + netip.MustParsePrefix("192.0.2.0/24"), // TEST-NET-1. + netip.MustParsePrefix("198.18.0.0/15"), // Benchmarking. + netip.MustParsePrefix("198.51.100.0/24"), // TEST-NET-2. + netip.MustParsePrefix("203.0.113.0/24"), // TEST-NET-3. + netip.MustParsePrefix("240.0.0.0/4"), // Reserved. + netip.MustParsePrefix("2001:db8::/32"), // Documentation. + netip.MustParsePrefix("3fff::/20"), // Documentation. + netip.MustParsePrefix("2001:2::/48"), // Benchmarking. +} + +// nat64Prefixes embed an IPv4 address in the low 32 bits, RFC 6052/8215. +var nat64Prefixes = []netip.Prefix{ + netip.MustParsePrefix("64:ff9b::/96"), + netip.MustParsePrefix("64:ff9b:1::/48"), +} + +func isPublicAddr(ip netip.Addr) bool { + ip = ip.Unmap() + for _, p := range nat64Prefixes { + if p.Contains(ip) { + b := ip.As16() + return isPublicAddr(netip.AddrFrom4([4]byte(b[12:]))) + } + } + if !ip.IsGlobalUnicast() || ip.IsPrivate() { + return false + } + for _, p := range nonPublicPrefixes { + if p.Contains(ip) { + return false + } + } + return true +} + // canonicalIPv4URL rewrites an integer/hex/octal IPv4 host in rawURL to its // canonical dotted-decimal form (inet_aton semantics), returning ok=false when // the host is a normal name or already dotted-decimal. diff --git a/config/security/securityConfig_test.go b/config/security/securityConfig_test.go index 9bc70f71d..4ac059a67 100644 --- a/config/security/securityConfig_test.go +++ b/config/security/securityConfig_test.go @@ -135,7 +135,7 @@ func TestToTOML(t *testing.T) { got := DefaultConfig.ToTOML() c.Assert(got, qt.Equals, - "[security]\n allowContent = ['! ^text/html$', '! ^text/org$']\n enableInlineShortcodes = false\n\n [security.exec]\n allow = ['^(dart-)?sass$', '^go$', '^git$', '^node$', '^postcss$']\n osEnv = ['(?i)^((HTTPS?|NO)_PROXY|PATH(EXT)?|APPDATA|TE?MP|TERM|GO\\w+|(XDG_CONFIG_)?HOME|USERPROFILE|SSH_AUTH_SOCK|DISPLAY|LANG|SYSTEMDRIVE|PROGRAMDATA)$']\n\n [security.funcs]\n getenv = ['^HUGO_', '^CI$']\n\n [security.http]\n methods = ['(?i)GET|POST']\n urls = ['(?i)^https?://[a-z0-9]', '! ^https?://\\d+\\.', '! (?i)localhost', '! (?i)^https?://[^/?#]*@']\n\n [security.node]\n [security.node.permissions]\n allowAddons = ['tailwindcss']\n allowChildProcess = ['tailwindcss']\n allowRead = ['.']\n allowWorker = ['tailwindcss']\n allowWrite = []\n disable = false", + "[security]\n allowContent = ['! ^text/html$', '! ^text/org$']\n enableInlineShortcodes = false\n\n [security.exec]\n allow = ['^(dart-)?sass$', '^go$', '^git$', '^node$', '^postcss$']\n osEnv = ['(?i)^((HTTPS?|NO)_PROXY|PATH(EXT)?|APPDATA|TE?MP|TERM|GO\\w+|(XDG_CONFIG_)?HOME|USERPROFILE|SSH_AUTH_SOCK|DISPLAY|LANG|SYSTEMDRIVE|PROGRAMDATA)$']\n\n [security.funcs]\n getenv = ['^HUGO_', '^CI$']\n\n [security.http]\n methods = ['(?i)GET|POST']\n urls = ['(?i)^https?://[a-z0-9]', '! (?i)^https?://\\d+\\.', '! (?i)localhost', '! (?i)^https?://[^/?#]*@']\n\n [security.node]\n [security.node.permissions]\n allowAddons = ['tailwindcss']\n allowChildProcess = ['tailwindcss']\n allowRead = ['.']\n allowWorker = ['tailwindcss']\n allowWrite = []\n disable = false", ) } @@ -196,6 +196,8 @@ func TestCheckAllowedHTTPURLHardenedDefaultsIssue14792(t *testing.T) { "http://LOCALHOST:8080/", "http://foo.localhost/", "http://127.0.0.1/", + "HTTP://127.0.0.1/", // The deny rules must be case-insensitive. + "HtTpS://169.254.169.254/", "http://127.1.2.3:8080/x", "http://user:pass@127.0.0.1/", // userinfo must not sneak past the deny. "http://10.0.0.1/", @@ -257,7 +259,8 @@ func TestCheckAllowedHTTPAddress(t *testing.T) { for _, addr := range []string{ "93.184.216.34:80", - "[2001:db8::1]:443", + "[2606:4700::1]:443", + "[64:ff9b::5db8:d822]:80", // NAT64-embedded 93.184.216.34. } { c.Assert(pc.CheckAllowedHTTPAddress("tcp", addr), qt.IsNil, qt.Commentf(addr)) } @@ -273,6 +276,19 @@ func TestCheckAllowedHTTPAddress(t *testing.T) { "[fc00::1]:80", "0.0.0.0:80", "[::ffff:127.0.0.1]:80", // IPv4-mapped loopback. + "100.64.0.1:80", // CGNAT. + "[::ffff:100.64.0.1]:80", + "192.0.0.9:80", + "192.0.2.1:80", // TEST-NET-1. + "198.18.0.1:80", + "198.51.100.1:80", + "203.0.113.1:80", + "240.0.0.1:80", + "[2001:db8::1]:443", + "[3fff::1]:443", + "[64:ff9b::7f00:1]:80", // NAT64-embedded 127.0.0.1. + "[64:ff9b::a9fe:a9fe]:80", // NAT64-embedded 169.254.169.254. + "[64:ff9b:1::a00:1]:80", // NAT64-embedded 10.0.0.1. } { err := pc.CheckAllowedHTTPAddress("tcp", addr) c.Assert(err, qt.IsNotNil, qt.Commentf(addr)) diff --git a/docs/data/docs.yaml b/docs/data/docs.yaml index 19b950dd5..b392c6a71 100644 --- a/docs/data/docs.yaml +++ b/docs/data/docs.yaml @@ -2242,7 +2242,7 @@ config: - (?i)GET|POST urls: - (?i)^https?://[a-z0-9] - - '! ^https?://\d+\.' + - '! (?i)^https?://\d+\.' - '! (?i)localhost' - '! (?i)^https?://[^/?#]*@' node: