mirror of
https://github.com/gohugoio/hugo.git
synced 2026-08-24 15:28:54 +00:00
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:
@@ -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{
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user