From a00b5c72ac57afe26df6688ece3ca544a56df372 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B8rn=20Erik=20Pedersen?= Date: Tue, 9 Jun 2026 13:08:22 +0200 Subject: [PATCH] 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) --- config/security/securityConfig.go | 94 +++++++++++++++++++++++++- config/security/securityConfig_test.go | 35 ++++++++++ tpl/os/os.go | 4 +- tpl/os/os_test.go | 12 ++-- 4 files changed, 132 insertions(+), 13 deletions(-) diff --git a/config/security/securityConfig.go b/config/security/securityConfig.go index 9818fd130..510abe6bb 100644 --- a/config/security/securityConfig.go +++ b/config/security/securityConfig.go @@ -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 (1–4 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{ diff --git a/config/security/securityConfig_test.go b/config/security/securityConfig_test.go index 37ecaab61..69feae858 100644 --- a/config/security/securityConfig_test.go +++ b/config/security/securityConfig_test.go @@ -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) diff --git a/tpl/os/os.go b/tpl/os/os.go index 384b1bb2f..22eaee593 100644 --- a/tpl/os/os.go +++ b/tpl/os/os.go @@ -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) diff --git a/tpl/os/os_test.go b/tpl/os/os_test.go index 8245b44f1..4570f0901 100644 --- a/tpl/os/os_test.go +++ b/tpl/os/os_test.go @@ -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 }