diff --git a/config/security/securityConfig.go b/config/security/securityConfig.go index daf642129..5b4097abb 100644 --- a/config/security/securityConfig.go +++ b/config/security/securityConfig.go @@ -18,6 +18,7 @@ import ( "encoding/json" "errors" "fmt" + "net" "net/netip" "net/url" "reflect" @@ -212,6 +213,47 @@ func (c Config) CheckAllowedHTTPURL(u string) error { return nil } +// CheckAllowedHTTPAddress reports whether a dial-time destination address may +// be connected to. address is the resolved "host:port" passed to a net.Dialer +// control hook, i.e. the actual address the HTTP client is about to connect to. +// +// The security.http.urls allowlist only inspects the URL text and never sees +// the resolved address, so a hostname that resolves to a loopback, private or +// link-local (including the cloud metadata endpoint) address would otherwise +// satisfy the policy and let resources.GetRemote reach an internal endpoint. +// We deny any non–global-unicast or private address here to close that gap. +func (c Config) CheckAllowedHTTPAddress(network, address string) error { + // Only enforced under the default hardened allowlist. If the user has + // customized security.http.urls they have opted into whatever hosts they + // listed, including internal ones (e.g. a local dev server), so we do not + // second-guess the resolved address. + if !slices.Equal(c.HTTP.URLs.patternsStrings, DefaultConfig.HTTP.URLs.patternsStrings) { + return nil + } + deny := func(name string) error { + return &AccessDeniedError{ + name: name, + path: "security.http.urls", + policies: c.ToTOML(), + } + } + host, _, err := net.SplitHostPort(address) + if err != nil { + host = address + } + ip, err := netip.ParseAddr(host) + if err != nil { + // The dial hook always hands us a resolved IP literal; anything else + // is unexpected, so fail closed. + return deny(address) + } + ip = ip.Unmap() + if !ip.IsGlobalUnicast() || ip.IsPrivate() { + return deny(host) + } + 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. diff --git a/config/security/securityConfig_test.go b/config/security/securityConfig_test.go index 8318d5d6f..a89edc07d 100644 --- a/config/security/securityConfig_test.go +++ b/config/security/securityConfig_test.go @@ -247,6 +247,39 @@ urls = ['.*', '! ^https?://evil\.example\.com'] }) } +// A resolved destination address must be validated so a hostname that resolves +// to an internal address cannot reach an internal endpoint via GetRemote. +// See CVE-2026-10582. +func TestCheckAllowedHTTPAddress(t *testing.T) { + t.Parallel() + c := qt.New(t) + pc := DefaultConfig + + for _, addr := range []string{ + "93.184.216.34:80", + "[2001:db8::1]:443", + } { + c.Assert(pc.CheckAllowedHTTPAddress("tcp", addr), qt.IsNil, qt.Commentf(addr)) + } + + for _, addr := range []string{ + "127.0.0.1:80", + "[::1]:80", + "10.0.0.1:8080", + "172.16.0.1:80", + "192.168.1.1:80", + "169.254.169.254:80", // Cloud metadata. + "[fe80::1]:80", + "[fc00::1]:80", + "0.0.0.0:80", + "[::ffff:127.0.0.1]:80", // IPv4-mapped loopback. + } { + err := pc.CheckAllowedHTTPAddress("tcp", addr) + c.Assert(err, qt.IsNotNil, qt.Commentf(addr)) + c.Assert(err, qt.ErrorMatches, `(?s).*is not whitelisted in policy "security\.http\.urls".*`, qt.Commentf(addr)) + } +} + func TestCheckAllowedHTTPURLAtInPathIssue14825(t *testing.T) { t.Parallel() c := qt.New(t) diff --git a/resources/resource_factories/create/create.go b/resources/resource_factories/create/create.go index abea8c982..a34f9fc7b 100644 --- a/resources/resource_factories/create/create.go +++ b/resources/resource_factories/create/create.go @@ -17,11 +17,13 @@ package create import ( "errors" + "net" "net/http" "os" "path" "path/filepath" "strings" + "syscall" "time" "github.com/bep/helpers/contexthelpers" @@ -40,6 +42,7 @@ import ( "github.com/gohugoio/hugo/common/hugio" "github.com/gohugoio/hugo/common/tasks" + "github.com/gohugoio/hugo/config/security" "github.com/gohugoio/hugo/resources" "github.com/gohugoio/hugo/resources/resource" ) @@ -139,6 +142,7 @@ func New(rs *resources.Spec) *Client { Transport: &transport{ Cfg: rs.Cfg, Logger: rs.Logger, + base: newSecureBaseTransport(rs.ExecHelper.Sec()), }, }, }, @@ -146,6 +150,23 @@ func New(rs *resources.Spec) *Client { } } +// newSecureBaseTransport clones the default transport and installs a dial-time +// hook that validates the resolved destination address against the security +// policy, so a hostname resolving to an internal address cannot be used to +// reach internal endpoints. See CheckAllowedHTTPAddress. +func newSecureBaseTransport(sec security.Config) http.RoundTripper { + base := http.DefaultTransport.(*http.Transport).Clone() + d := &net.Dialer{ + Timeout: 30 * time.Second, + KeepAlive: 30 * time.Second, + Control: func(network, address string, _ syscall.RawConn) error { + return sec.CheckAllowedHTTPAddress(network, address) + }, + } + base.DialContext = d.DialContext + return base +} + // Copy copies r to the new targetPath. func (c *Client) Copy(r resource.Resource, targetPath string) (resource.Resource, error) { key := dynacache.CleanKey(targetPath) + "__copy" diff --git a/resources/resource_factories/create/remote.go b/resources/resource_factories/create/remote.go index 8d43ef31c..a05a4bfd4 100644 --- a/resources/resource_factories/create/remote.go +++ b/resources/resource_factories/create/remote.go @@ -464,6 +464,10 @@ var _ http.RoundTripper = (*transport)(nil) type transport struct { Cfg config.AllProvider Logger loggers.Logger + + // base does the actual round trip. It carries a dial-time hook that + // validates the resolved destination address (see New). + base http.RoundTripper } func (t *transport) RoundTrip(req *http.Request) (resp *http.Response, err error) { @@ -482,7 +486,7 @@ func (t *transport) RoundTrip(req *http.Request) (resp *http.Response, err error for { resp, retry, err = func() (*http.Response, bool, error) { - resp2, err := http.DefaultTransport.RoundTrip(req) + resp2, err := t.base.RoundTrip(req) if err != nil { return resp2, false, err } diff --git a/resources/resource_factories/create/remote_test.go b/resources/resource_factories/create/remote_test.go index 293845107..f9830c842 100644 --- a/resources/resource_factories/create/remote_test.go +++ b/resources/resource_factories/create/remote_test.go @@ -14,9 +14,14 @@ package create import ( + "net/http" + "net/http/httptest" "testing" qt "github.com/frankban/quicktest" + + "github.com/gohugoio/hugo/config" + "github.com/gohugoio/hugo/config/security" ) func TestDecodeRemoteOptions(t *testing.T) { @@ -134,3 +139,37 @@ func TestRemoteResourceKeys(t *testing.T) { check("asdf", map[string]any{"key": "1234", "bar": "asdf"}, "15578353952571222948", "15615023578599429261") check("asdf", map[string]any{"key": "12345", "bar": "asdf"}, "14335752410685132726", "15615023578599429261") } + +// The transport used for remote fetches must refuse to connect to an internal +// (here loopback) address under the default security policy, even though the +// URL text check happens elsewhere. See CVE-2026-10582. +func TestSecureBaseTransportBlocksInternalAddress(t *testing.T) { + t.Parallel() + + c := qt.New(t) + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Write([]byte("secret")) + })) + t.Cleanup(srv.Close) + + req, err := http.NewRequest("GET", srv.URL, nil) + c.Assert(err, qt.IsNil) + + // Default policy: the loopback address the httptest server listens on is + // refused at dial time. + _, err = newSecureBaseTransport(security.DefaultConfig).RoundTrip(req) + c.Assert(err, qt.IsNotNil) + c.Assert(security.IsAccessDenied(err), qt.IsTrue) + + // Customized policy: the user has opted into their own hosts, so the dial + // check stands down and the fetch succeeds. + sec, err := security.DecodeConfig(config.FromTOMLConfigString(` +[security.http] +urls = ['.*'] +`)) + c.Assert(err, qt.IsNil) + resp, err := newSecureBaseTransport(sec).RoundTrip(req) + c.Assert(err, qt.IsNil) + resp.Body.Close() +}