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 <noreply@anthropic.com>
This commit is contained in:
Bjørn Erik Pedersen
2026-09-03 13:29:38 +02:00
committed by GitHub
parent 5f0d88b8a3
commit 24d5e42ffa
3 changed files with 61 additions and 6 deletions
+42 -3
View File
@@ -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.
+18 -2
View File
@@ -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))
+1 -1
View File
@@ -2242,7 +2242,7 @@ config:
- (?i)GET|POST
urls:
- (?i)^https?://[a-z0-9]
- '! ^https?://\d+\.'
- '! (?i)^https?://\d+\.'
- '! (?i)localhost'
- '! (?i)^https?://[^/?#]*@'
node: