mirror of
https://github.com/go-gitea/gitea.git
synced 2026-08-02 11:27:06 +02:00
fix: various security fixes (#38406)
Addresses a batch of privately reported security issues, grouped by area: - **SSRF** - migration PR-patch/asset fetches, OAuth2 avatar & OpenID discovery, pull-mirror URL re-validation, and the outbound proxy path. - **Access-token scope** - prevent scope escalation on token creation; keep public-only tokens confined (feeds, packages, Actions listings, star/watch lists, limited/private owners). - **Access control / disclosure** - go-get default-branch leak, webhook authorization-header leak, watch clearing on private transitions, label/attachment scoping. - **Denial of service** - input bounds for npm dist-tags, Debian control files, Arch file lists, and SSH keys. ### 📌 Attention for site admins Not breaking - existing configs keep working - but two changes are worth a look: - **New SSRF protection** Outbound requests (migrations, OAuth2 avatars, OpenID discovery, pull mirrors, proxy path) are now validated against the allow/block host lists. If your instance legitimately reaches internal hosts, you may need to add them to `[security].ALLOWED_HOST_LIST` (and the relevant `ALLOW_LOCALNETWORKS` settings). - **Deprecation** `[webhook].ALLOWED_HOST_LIST` is deprecated and will be removed in a future release. Use `[security].ALLOWED_HOST_LIST` instead; the old key still works for now. --------- Co-authored-by: TheFox0x7 <thefox0x7@gmail.com> Co-authored-by: techknowlogick <techknowlogick@gitea.io> Co-authored-by: Lunny Xiao <xiaolunwen@gmail.com> Co-authored-by: wxiaoguang <wxiaoguang@gmail.com> Co-authored-by: Zettat123 <zettat123@gmail.com>
This commit is contained in:
co-authored by
TheFox0x7
techknowlogick
Lunny Xiao
wxiaoguang
Zettat123
parent
d2bd1589fe
commit
f69e15afe7
@@ -4,8 +4,14 @@
|
||||
package openid
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"gitea.dev/modules/hostmatcher"
|
||||
"gitea.dev/modules/proxy"
|
||||
"gitea.dev/modules/setting"
|
||||
|
||||
"github.com/yohcop/openid-go"
|
||||
)
|
||||
|
||||
@@ -19,11 +25,23 @@ import (
|
||||
var (
|
||||
nonceStore = openid.NewSimpleNonceStore()
|
||||
discoveryCache = newTimedDiscoveryCache(24 * time.Hour)
|
||||
|
||||
// openIDInstance does discovery/verification via an SSRF-protected client, so a user-supplied
|
||||
// OpenID identifier can't reach internal/loopback/reserved addresses. It honors the operator's
|
||||
// [security] ALLOWED_HOST_LIST (empty defaults to "external"), matching the avatar/webhook/migration
|
||||
// clients, and validates the proxy path too. Lazy: reads proxy/settings once.
|
||||
openIDInstance = sync.OnceValue(func() *openid.OpenID {
|
||||
allowList := hostmatcher.ParseHostMatchList("security.ALLOWED_HOST_LIST", setting.Security.AllowedHostList)
|
||||
return openid.NewOpenID(&http.Client{
|
||||
Timeout: 30 * time.Second,
|
||||
Transport: hostmatcher.NewHTTPTransport("openid", allowList, nil, proxy.Proxy(), setting.Proxy.ProxyURLFixed, nil),
|
||||
})
|
||||
})
|
||||
)
|
||||
|
||||
// Verify handles response from OpenID provider
|
||||
func Verify(fullURL string) (id string, err error) {
|
||||
return openid.Verify(fullURL, discoveryCache, nonceStore)
|
||||
return openIDInstance().Verify(fullURL, discoveryCache, nonceStore)
|
||||
}
|
||||
|
||||
// Normalize normalizes an OpenID URI
|
||||
@@ -33,5 +51,5 @@ func Normalize(url string) (id string, err error) {
|
||||
|
||||
// RedirectURL redirects browser
|
||||
func RedirectURL(id, callbackURL, realm string) (string, error) {
|
||||
return openid.RedirectURL(id, callbackURL, realm)
|
||||
return openIDInstance().RedirectURL(id, callbackURL, realm)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package openid
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestOpenIDDiscoveryBlocksInternalHost(t *testing.T) {
|
||||
var reached atomic.Bool
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
reached.Store(true)
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
// RedirectURL performs server-side discovery of the identifier URL; a loopback URL
|
||||
// must be refused at dial time instead of reaching the internal server
|
||||
_, err := RedirectURL(srv.URL, "http://example.com/callback", "http://example.com/")
|
||||
require.Error(t, err)
|
||||
assert.False(t, reached.Load(), "OpenID discovery must not reach an internal/loopback host")
|
||||
}
|
||||
@@ -5,8 +5,10 @@ package hostmatcher
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"syscall"
|
||||
"time"
|
||||
@@ -63,3 +65,17 @@ func NewDialContext(usage string, allowList, blockList *HostMatchList, proxy *ur
|
||||
return dialer.DialContext(ctx, network, addrOrHost)
|
||||
}
|
||||
}
|
||||
|
||||
// NewHTTPTransport builds an http.Transport that validates the request target against the allow/block
|
||||
// lists on the direct-dial path (DialContext). When an HTTP proxy is configured the proxy resolves and
|
||||
// dials the target itself, so restricting the proxied target is the proxy server's responsibility, not
|
||||
// Gitea's. proxyFunc selects the proxy URL per request (the http.Transport.Proxy selector, e.g.
|
||||
// proxy.Proxy()); proxyURLFixed is the fixed proxy address the dialer must always permit; tlsConfig may
|
||||
// be nil. blockList may be nil for callers that only maintain an allow-list.
|
||||
func NewHTTPTransport(usage string, allowList, blockList *HostMatchList, proxyFunc func(*http.Request) (*url.URL, error), proxyURLFixed *url.URL, tlsConfig *tls.Config) *http.Transport {
|
||||
return &http.Transport{
|
||||
TLSClientConfig: tlsConfig,
|
||||
Proxy: proxyFunc,
|
||||
DialContext: NewDialContext(usage, allowList, blockList, proxyURLFixed),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"gitea.dev/modules/packages"
|
||||
"gitea.dev/modules/util"
|
||||
"gitea.dev/modules/validation"
|
||||
|
||||
@@ -46,6 +47,11 @@ var (
|
||||
namePattern = regexp.MustCompile(`\A[a-zA-Z0-9@._+-]+\z`)
|
||||
// (epoch:pkgver-pkgrel)
|
||||
versionPattern = regexp.MustCompile(`\A(?:\d:)?[\w.+~]+(?:-[-\w.+~]+)?\z`)
|
||||
|
||||
// caps on the accumulated package file list (vars so tests can lower them); far above
|
||||
// any legitimate package, but low enough to stop metadata amplification
|
||||
maxFileEntries = 100000
|
||||
maxFileNameBytes = 16 * 1024 * 1024
|
||||
)
|
||||
|
||||
type Package struct {
|
||||
@@ -124,7 +130,7 @@ func ParsePackage(r io.Reader) (*Package, error) {
|
||||
}
|
||||
|
||||
var p *Package
|
||||
files := make([]string, 0, 10)
|
||||
files := packages.NewBoundedFileList(maxFileEntries, maxFileNameBytes)
|
||||
|
||||
tr := tar.NewReader(inner)
|
||||
for {
|
||||
@@ -147,7 +153,9 @@ func ParsePackage(r io.Reader) (*Package, error) {
|
||||
return nil, err
|
||||
}
|
||||
} else if !strings.HasPrefix(filename, ".") {
|
||||
files = append(files, hd.Name)
|
||||
if err := files.Add(hd.Name); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -155,7 +163,7 @@ func ParsePackage(r io.Reader) (*Package, error) {
|
||||
return nil, ErrMissingPKGINFOFile
|
||||
}
|
||||
|
||||
p.FileMetadata.Files = files
|
||||
p.FileMetadata.Files = files.Files()
|
||||
p.FileCompressionExtension = compressionType
|
||||
|
||||
return p, nil
|
||||
|
||||
@@ -10,6 +10,9 @@ import (
|
||||
"io"
|
||||
"testing"
|
||||
|
||||
"gitea.dev/modules/test"
|
||||
"gitea.dev/modules/util"
|
||||
|
||||
"github.com/klauspost/compress/zstd"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/ulikunitz/xz"
|
||||
@@ -167,3 +170,25 @@ func TestParsePackageInfo(t *testing.T) {
|
||||
assert.ElementsMatch(t, []string{"usr/bin/paket1"}, p.FileMetadata.Backup)
|
||||
})
|
||||
}
|
||||
|
||||
// TestParsePackageTooManyFiles ensures the accumulated file list is bounded to prevent
|
||||
// metadata amplification from a package with a huge number of (tiny) file entries.
|
||||
func TestParsePackageTooManyFiles(t *testing.T) {
|
||||
defer test.MockVariableValue(&maxFileEntries, 3)()
|
||||
buf := test.WriteTarCompression(func(w io.Writer) io.WriteCloser { return gzip.NewWriter(w) }, map[string]string{
|
||||
"file1": "content1",
|
||||
".PKGINFO": string(createPKGINFOContent(packageName, packageVersion)),
|
||||
})
|
||||
_, err := ParsePackage(buf)
|
||||
assert.NoError(t, err)
|
||||
|
||||
buf = test.WriteTarCompression(func(w io.Writer) io.WriteCloser { return gzip.NewWriter(w) }, map[string]string{
|
||||
"file1": "content1",
|
||||
"file2": "content2",
|
||||
"file3": "content3",
|
||||
"file4": "content4",
|
||||
".PKGINFO": string(createPKGINFOContent(packageName, packageVersion)),
|
||||
})
|
||||
_, err = ParsePackage(buf)
|
||||
assert.ErrorIs(t, err, util.ErrInvalidArgument)
|
||||
}
|
||||
|
||||
@@ -135,7 +135,10 @@ func ParsePackage(r io.Reader) (*Package, error) {
|
||||
return nil, GlobalVars().ErrUnsupportedCompression
|
||||
}
|
||||
|
||||
tr := tar.NewReader(inner)
|
||||
// bound the decompressed control archive: it holds only the small control file
|
||||
// and maintainer scripts, so a much larger stream is a decompression bomb
|
||||
const maxControlTarSize = 32 * 1024 * 1024
|
||||
tr := tar.NewReader(io.LimitReader(inner, maxControlTarSize))
|
||||
for {
|
||||
hd, err := tr.Next()
|
||||
if err == io.EOF {
|
||||
@@ -168,6 +171,7 @@ func ParseControlFile(r io.Reader) (*Package, error) {
|
||||
key := ""
|
||||
var depends strings.Builder
|
||||
var control strings.Builder
|
||||
var description strings.Builder
|
||||
|
||||
// https://www.debian.org/doc/debian-policy/ch-controlfields.html#syntax-of-control-files
|
||||
s := bufio.NewScanner(r)
|
||||
@@ -189,10 +193,13 @@ func ParseControlFile(r io.Reader) (*Package, error) {
|
||||
control.WriteString(line)
|
||||
control.WriteByte('\n')
|
||||
|
||||
// a leading space or tab marks a folded continuation line that belongs to the previous field
|
||||
// (identified by key), not a new "Key: value" pair; only the multi-line fields append here.
|
||||
// Continuation lines may themselves contain a colon, so they must not be re-split on ":".
|
||||
if line[0] == ' ' || line[0] == '\t' {
|
||||
switch key {
|
||||
case "Description":
|
||||
p.Metadata.Description += line
|
||||
description.WriteString(line)
|
||||
case "Depends":
|
||||
depends.WriteString(trimmed)
|
||||
}
|
||||
@@ -219,7 +226,8 @@ func ParseControlFile(r io.Reader) (*Package, error) {
|
||||
p.Metadata.Maintainer = a.Name
|
||||
}
|
||||
case "Description":
|
||||
p.Metadata.Description = value
|
||||
description.Reset()
|
||||
description.WriteString(value)
|
||||
case "Depends":
|
||||
depends.WriteString(value)
|
||||
case "Homepage":
|
||||
@@ -243,6 +251,8 @@ func ParseControlFile(r io.Reader) (*Package, error) {
|
||||
return nil, GlobalVars().ErrInvalidArchitecture
|
||||
}
|
||||
|
||||
p.Metadata.Description = description.String()
|
||||
|
||||
dependencies := strings.Split(depends.String(), ",")
|
||||
for i := range dependencies {
|
||||
dependencies[i] = strings.TrimSpace(dependencies[i])
|
||||
|
||||
@@ -232,3 +232,15 @@ func TestValidateDistributionOrComponent(t *testing.T) {
|
||||
assert.True(t, IsValidDistributionOrComponent(name), "good=%q", name)
|
||||
}
|
||||
}
|
||||
|
||||
// TestParseControlFileMultilineDescription verifies a multi-line Description is assembled in order
|
||||
// (the parser accumulates it in a strings.Builder); it guards the assembled value, not its timing.
|
||||
func TestParseControlFileMultilineDescription(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
buf.WriteString("Package: testpkg\nVersion: 1.0\nArchitecture: amd64\nDescription: short summary\n more details\n even more\n")
|
||||
|
||||
p, err := ParseControlFile(&buf)
|
||||
assert.NoError(t, err)
|
||||
assert.NotNil(t, p)
|
||||
assert.Equal(t, "short summary more details even more", p.Metadata.Description)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package packages
|
||||
|
||||
import "gitea.dev/modules/util"
|
||||
|
||||
// BoundedFileList accumulates file names from a package archive while enforcing caps on the number of
|
||||
// entries and their total name length, returning an error once either cap would be exceeded.
|
||||
type BoundedFileList struct {
|
||||
files []string
|
||||
nameBytes int
|
||||
maxFiles int
|
||||
maxBytes int
|
||||
}
|
||||
|
||||
// NewBoundedFileList creates a BoundedFileList with the given caps; a non-positive cap falls back to the
|
||||
// corresponding default.
|
||||
func NewBoundedFileList(maxFiles, maxNameBytes int) *BoundedFileList {
|
||||
return &BoundedFileList{maxFiles: maxFiles, maxBytes: maxNameBytes}
|
||||
}
|
||||
|
||||
// Add appends name, returning util.ErrInvalidArgument once the entry count or accumulated byte length
|
||||
// would exceed the configured cap.
|
||||
func (b *BoundedFileList) Add(name string) error {
|
||||
if len(b.files) >= b.maxFiles || b.nameBytes+len(name) > b.maxBytes {
|
||||
return util.NewInvalidArgumentErrorf("package contains too many file entries")
|
||||
}
|
||||
b.nameBytes += len(name)
|
||||
b.files = append(b.files, name)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Files returns the accumulated file names.
|
||||
func (b *BoundedFileList) Files() []string {
|
||||
return b.files
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"crypto/tls"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
@@ -53,12 +54,37 @@ func dialContextInternalAPI(ctx context.Context, network, address string) (conn
|
||||
return conn, nil
|
||||
}
|
||||
|
||||
// internalAPIConnectionIsLocal reports whether the internal API transport connects to a local target,
|
||||
// where the self-signed local certificate cannot be verified so skipping verification is safe. It mirrors
|
||||
// what dialContextInternalAPI actually dials: a unix socket whenever Protocol is HTTPUnix (always local,
|
||||
// whatever LOCAL_ROOT_URL says), otherwise the LOCAL_ROOT_URL host directly. A non-loopback LOCAL_ROOT_URL
|
||||
// is a real network hop, so its certificate must be verified, else the internal token can be MITM'd. An
|
||||
// unparseable LOCAL_ROOT_URL is a hard misconfiguration and fails closed (verify).
|
||||
func internalAPIConnectionIsLocal(protocol setting.Scheme, localURL string) bool {
|
||||
if protocol == setting.HTTPUnix {
|
||||
return true
|
||||
}
|
||||
u, err := url.Parse(localURL)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
host := u.Hostname()
|
||||
if host == "localhost" {
|
||||
return true
|
||||
}
|
||||
ip := net.ParseIP(host)
|
||||
return ip != nil && ip.IsLoopback()
|
||||
}
|
||||
|
||||
var internalAPITransport = sync.OnceValue(func() http.RoundTripper {
|
||||
return &http.Transport{
|
||||
DialContext: dialContextInternalAPI,
|
||||
TLSClientConfig: &tls.Config{
|
||||
InsecureSkipVerify: true,
|
||||
ServerName: setting.Domain,
|
||||
// Skip verification only for a local target (unix socket, or a loopback LOCAL_ROOT_URL), where the
|
||||
// self-signed local cert can't be verified anyway; a non-loopback LOCAL_ROOT_URL is a real network
|
||||
// hop and must be verified so the internal token can't be MITM'd. When verifying, Go's default
|
||||
// ServerName (the dialed LOCAL_ROOT_URL host) is already correct, so it is not overridden.
|
||||
InsecureSkipVerify: internalAPIConnectionIsLocal(setting.Protocol, setting.LocalURL),
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package private
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"gitea.dev/modules/setting"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestInternalAPIConnectionIsLocal(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
protocol setting.Scheme
|
||||
localURL string
|
||||
want bool
|
||||
}{
|
||||
// HTTPUnix always dials the unix socket (a local target), whatever LOCAL_ROOT_URL says
|
||||
{"unix socket", setting.HTTPUnix, "https://gitea.example.com/", true},
|
||||
{"localhost", setting.HTTP, "http://localhost:3000/", true},
|
||||
{"loopback ipv4", setting.HTTPS, "https://127.0.0.1:3000/", true},
|
||||
{"loopback ipv6", setting.HTTPS, "https://[::1]:3000/", true},
|
||||
// a non-loopback LOCAL_ROOT_URL is a real network hop and must be verified
|
||||
{"remote host", setting.HTTPS, "https://gitea.internal:443/", false},
|
||||
{"remote ip", setting.HTTPS, "https://10.0.0.5:3000/", false},
|
||||
// an unparseable LOCAL_ROOT_URL is a hard misconfiguration; fail closed to verification
|
||||
{"invalid url", setting.HTTPS, "://bad", false},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
assert.Equal(t, c.want, internalAPIConnectionIsLocal(c.protocol, c.localURL))
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -20,9 +20,11 @@ var Security = struct {
|
||||
XContentTypeOptions string
|
||||
|
||||
ContentSecurityPolicyGeneral string // it only supports empty (default policy) or "unset", maybe it can support more in the future
|
||||
AllowedHostList string
|
||||
}{
|
||||
XFrameOptions: "SAMEORIGIN",
|
||||
XContentTypeOptions: "nosniff",
|
||||
AllowedHostList: "external",
|
||||
}
|
||||
|
||||
var (
|
||||
|
||||
@@ -10,13 +10,20 @@ import (
|
||||
)
|
||||
|
||||
func TestLoadSecurityFrom(t *testing.T) {
|
||||
assert.Equal(t, "SAMEORIGIN", Security.XFrameOptions)
|
||||
assert.Equal(t, "nosniff", Security.XContentTypeOptions)
|
||||
assert.Equal(t, "external", Security.AllowedHostList)
|
||||
|
||||
cfg, err := NewConfigProviderFromData(`[security]
|
||||
X_FRAME_OPTIONS = DENY
|
||||
X_CONTENT_TYPE_OPTIONS = unset
|
||||
CONTENT_SECURITY_POLICY_GENERAL = "script-src *; foo"`)
|
||||
ALLOWED_HOST_LIST = foo
|
||||
CONTENT_SECURITY_POLICY_GENERAL = "script-src *; foo"
|
||||
`)
|
||||
assert.NoError(t, err)
|
||||
loadSecurityFrom(cfg)
|
||||
assert.Equal(t, "DENY", Security.XFrameOptions)
|
||||
assert.Equal(t, "unset", Security.XContentTypeOptions)
|
||||
assert.Equal(t, "foo", Security.AllowedHostList)
|
||||
assert.Equal(t, `"script-src *`, Security.ContentSecurityPolicyGeneral) // holy shit ini package bug
|
||||
}
|
||||
|
||||
@@ -34,7 +34,10 @@ func loadWebhookFrom(rootCfg ConfigProvider) {
|
||||
Webhook.QueueLength = sec.Key("QUEUE_LENGTH").MustInt(1000)
|
||||
Webhook.DeliverTimeout = sec.Key("DELIVER_TIMEOUT").MustInt(5)
|
||||
Webhook.SkipTLSVerify = sec.Key("SKIP_TLS_VERIFY").MustBool()
|
||||
Webhook.AllowedHostList = sec.Key("ALLOWED_HOST_LIST").MustString("")
|
||||
|
||||
deprecatedSetting(rootCfg, "webhook", "ALLOWED_HOST_LIST", "security", "ALLOWED_HOST_LIST", "v28.0.0")
|
||||
Webhook.AllowedHostList = sec.Key("ALLOWED_HOST_LIST").MustString(Security.AllowedHostList)
|
||||
|
||||
Webhook.Types = []string{"gitea", "gogs", "slack", "discord", "dingtalk", "telegram", "msteams", "feishu", "matrix", "wechatwork", "packagist"}
|
||||
Webhook.PagingNum = sec.Key("PAGING_NUM").MustInt(10)
|
||||
Webhook.ProxyURL = sec.Key("PROXY_URL").MustString("")
|
||||
|
||||
+9
-1
@@ -23,13 +23,21 @@ func (e ErrURISchemeNotSupported) Error() string {
|
||||
|
||||
// Open open a local file or a remote file
|
||||
func Open(uriStr string) (io.ReadCloser, error) {
|
||||
return OpenWithClient(uriStr, http.DefaultClient)
|
||||
}
|
||||
|
||||
// OpenWithClient opens a local file or a remote file, using the given (non-nil) HTTP client
|
||||
// for http/https URLs. Callers that must confine remote access (e.g. to defeat SSRF via
|
||||
// redirects) should pass a client whose transport validates the peer at dial time; Open
|
||||
// passes http.DefaultClient.
|
||||
func OpenWithClient(uriStr string, client *http.Client) (io.ReadCloser, error) {
|
||||
u, err := url.Parse(uriStr)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
switch strings.ToLower(u.Scheme) {
|
||||
case "http", "https":
|
||||
f, err := http.Get(uriStr)
|
||||
f, err := client.Get(uriStr)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -4,10 +4,18 @@
|
||||
package uri
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestReadURI(t *testing.T) {
|
||||
@@ -17,3 +25,45 @@ func TestReadURI(t *testing.T) {
|
||||
assert.NoError(t, err)
|
||||
defer f.Close()
|
||||
}
|
||||
|
||||
// TestOpenWithClientValidatesRedirectTarget verifies OpenWithClient routes the
|
||||
// whole request chain (including redirects) through the provided client, so a
|
||||
// client whose transport refuses to dial an internal target blocks a redirect to
|
||||
// it — whereas the default client (old Open behavior) follows it.
|
||||
func TestOpenWithClientValidatesRedirectTarget(t *testing.T) {
|
||||
var internalHit atomic.Bool
|
||||
internal := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
internalHit.Store(true)
|
||||
_, _ = w.Write([]byte("secret"))
|
||||
}))
|
||||
defer internal.Close()
|
||||
|
||||
front := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
http.Redirect(w, r, internal.URL, http.StatusFound)
|
||||
}))
|
||||
defer front.Close()
|
||||
|
||||
internalAddr := strings.TrimPrefix(internal.URL, "http://")
|
||||
|
||||
// a client that refuses to dial the internal target, mimicking the migration
|
||||
// hostmatcher dialer that re-validates every hop
|
||||
blockingClient := &http.Client{Transport: &http.Transport{
|
||||
DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) {
|
||||
if addr == internalAddr {
|
||||
return nil, errors.New("blocked internal address")
|
||||
}
|
||||
return (&net.Dialer{}).DialContext(ctx, network, addr)
|
||||
},
|
||||
}}
|
||||
|
||||
_, err := OpenWithClient(front.URL, blockingClient)
|
||||
require.Error(t, err)
|
||||
assert.False(t, internalHit.Load(), "the redirect target must not be reached through the validating client")
|
||||
|
||||
// the default client (the previous behavior) follows the redirect to the internal target
|
||||
internalHit.Store(false)
|
||||
rc, err := Open(front.URL)
|
||||
require.NoError(t, err)
|
||||
_ = rc.Close()
|
||||
assert.True(t, internalHit.Load(), "sanity check: the default client follows the redirect")
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user