security: Normalize integer IPv4 host encodings in http.urls check

Canonicalize integer/hex/octal IPv4 hosts to dotted-decimal before
applying the security.http.urls policy so all encodings of an address
are treated alike.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Bjørn Erik Pedersen
2026-06-09 13:08:22 +02:00
parent cf9c8f93ca
commit a00b5c72ac
4 changed files with 132 additions and 13 deletions
+91 -3
View File
@@ -18,8 +18,11 @@ import (
"encoding/json"
"errors"
"fmt"
"net/netip"
"net/url"
"reflect"
"slices"
"strconv"
"strings"
"github.com/gohugoio/hugo/common/herrors"
@@ -189,17 +192,102 @@ func (c Config) CheckAllowedGetEnv(name string) error {
return nil
}
func (c Config) CheckAllowedHTTPURL(url string) error {
if !c.HTTP.URLs.Accept(url) {
func (c Config) CheckAllowedHTTPURL(u string) error {
deny := func(name string) error {
return &AccessDeniedError{
name: url,
name: name,
path: "security.http.urls",
policies: c.ToTOML(),
}
}
if !c.HTTP.URLs.Accept(u) {
return deny(u)
}
// A host can be written as an integer/hex/octal IPv4 literal
// (e.g. http://2130706433/ == http://127.0.0.1/) that has no dot and
// thus slips past IP-literal deny rules. Re-check the canonical form so
// the policy treats every encoding of the same address alike.
if canon, ok := canonicalIPv4URL(u); ok && !c.HTTP.URLs.Accept(canon) {
return deny(u)
}
return nil
}
// 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.
func canonicalIPv4URL(rawURL string) (string, bool) {
u, err := url.Parse(rawURL)
if err != nil {
return "", false
}
host := u.Hostname()
ip, ok := parseInetAtonIPv4(host)
if !ok || ip.String() == host {
return "", false
}
if port := u.Port(); port != "" {
u.Host = ip.String() + ":" + port
} else {
u.Host = ip.String()
}
return u.String(), true
}
// parseInetAtonIPv4 parses the inet_aton IPv4 forms (14 dot-separated parts,
// each decimal, octal "0..." or hex "0x..."), e.g. "2130706433", "0x7f.0.0.1".
func parseInetAtonIPv4(host string) (netip.Addr, bool) {
if host == "" {
return netip.Addr{}, false
}
parts := strings.Split(host, ".")
if len(parts) > 4 {
return netip.Addr{}, false
}
vals := make([]uint64, len(parts))
for i, p := range parts {
v, ok := parseCInt(p)
if !ok {
return netip.Addr{}, false
}
vals[i] = v
}
maxLast := []uint64{0xffffffff, 0xffffff, 0xffff, 0xff}[len(parts)-1]
var n uint64
for i, v := range vals {
if i == len(parts)-1 {
if v > maxLast {
return netip.Addr{}, false
}
n |= v
} else {
if v > 0xff {
return netip.Addr{}, false
}
n |= v << (8 * (3 - i))
}
}
return netip.AddrFrom4([4]byte{byte(n >> 24), byte(n >> 16), byte(n >> 8), byte(n)}), true
}
func parseCInt(s string) (uint64, bool) {
base := 10
switch {
case len(s) >= 2 && s[0] == '0' && (s[1] == 'x' || s[1] == 'X'):
base, s = 16, s[2:]
case len(s) >= 2 && s[0] == '0':
base, s = 8, s[1:]
}
if s == "" {
return 0, false
}
v, err := strconv.ParseUint(s, base, 64)
if err != nil {
return 0, false
}
return v, true
}
func (c Config) CheckAllowedHTTPMethod(method string) error {
if !c.HTTP.Methods.Accept(method) {
return &AccessDeniedError{
+35
View File
@@ -298,6 +298,41 @@ func TestCheckAllowedHTTPURLDigitHostnameIssue14837(t *testing.T) {
}
}
// Integer/hex/octal IPv4 encodings must be denied just like their dotted-decimal
// literals; digit-leading hostnames must still be allowed. See issue 14856.
func TestCheckAllowedHTTPURLIntegerIPEncodings(t *testing.T) {
t.Parallel()
c := qt.New(t)
pc, err := DecodeConfig(config.New())
c.Assert(err, qt.IsNil)
for _, u := range []string{
"http://2130706433/", // 127.0.0.1 decimal
"http://2130706433:9777/x", // 127.0.0.1 decimal, port
"http://2852039166/", // 169.254.169.254 (cloud metadata)
"http://0x7f000001/", // 127.0.0.1 hex
"http://017700000001/", // 127.0.0.1 octal
"http://0x7f.0.0.1/", // 127.0.0.1 dotted hex
"http://0177.0.0.1/", // 127.0.0.1 dotted octal
"http://127.1/", // 127.0.0.1 short form
"http://0/", // 0.0.0.0
"http://0xa9fea9fe/", // 169.254.169.254 hex
} {
err := pc.CheckAllowedHTTPURL(u)
c.Assert(err, qt.IsNotNil, qt.Commentf(u))
}
for _, u := range []string{
"https://1password.com/",
"https://37signals.com/foo",
"https://3com.com/",
"https://0x.tools/",
} {
c.Assert(pc.CheckAllowedHTTPURL(u), qt.IsNil, qt.Commentf(u))
}
}
func TestCheckAllowedContent(t *testing.T) {
t.Parallel()
c := qt.New(t)
+2 -2
View File
@@ -136,7 +136,7 @@ func (ns *Namespace) FileExists(i any) (bool, error) {
}
if path == "" {
return false, errors.New("fileExists needs a path to a file")
return false, nil
}
status, err := afero.Exists(ns.readFileFs, path)
@@ -155,7 +155,7 @@ func (ns *Namespace) Stat(i any) (_os.FileInfo, error) {
}
if path == "" {
return nil, errors.New("fileStat needs a path to a file")
return nil, nil
}
r, err := ns.readFileFs.Stat(path)
+4 -8
View File
@@ -64,21 +64,16 @@ func TestFileExists(t *testing.T) {
for _, test := range []struct {
filename string
expect any
expect bool
}{
{filepath.FromSlash("/f/f1.txt"), true},
{filepath.FromSlash("f/f1.txt"), true},
{filepath.FromSlash("../f2.txt"), false},
{"b", false},
{"", nil},
{"", false},
} {
result, err := ns.FileExists(test.filename)
if test.expect == nil {
c.Assert(err, qt.Not(qt.IsNil))
continue
}
c.Assert(err, qt.IsNil)
c.Assert(result, qt.Equals, test.expect)
}
@@ -101,7 +96,8 @@ func TestStat(t *testing.T) {
result, err := ns.Stat(test.filename)
if test.expect == nil {
b.Assert(err, qt.Not(qt.IsNil))
b.Assert(err, qt.IsNil)
b.Assert(result, qt.IsNil)
continue
}