resources: Validate the resolved address on remote fetches

security.http.urls only inspects the URL text, so a hostname that
resolves to a loopback, private or link-local address (e.g. the cloud
metadata endpoint) satisfied the policy and let resources.GetRemote
reach an internal endpoint, embedding the response in the built site.

Install a dial-time hook on the remote-fetch transport that rejects
connections to non-global-unicast or private addresses. It is enforced
only under the default hardened allowlist; a user who customizes
security.http.urls has opted into their own hosts, so the check stands
down and local/internal fetches keep working.

See CVE-2026-10582.
Closes #15247.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Bjørn Erik Pedersen
2026-08-28 11:15:53 +02:00
parent e4dc48cf7f
commit d6e6f9e500
5 changed files with 140 additions and 1 deletions
+42
View File
@@ -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 nonglobal-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.
+33
View File
@@ -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)
@@ -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"
@@ -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
}
@@ -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()
}