mirror of
https://github.com/gohugoio/hugo.git
synced 2026-09-03 20:22:38 +00:00
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:
@@ -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.
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user