diff --git a/config/config.go b/config/config.go index c0c18b0b..bdace42c 100644 --- a/config/config.go +++ b/config/config.go @@ -191,6 +191,10 @@ func (c *Config) ForwardsTransparently() bool { case ModeTransparent: return true case ModeTiered: + // Strict Tigris-domain check, NOT the transparent allowlist: the + // transparent flavor re-enables the cross-tier cleanup DELETE, whose + // safety needs a backend verified to enforce If-Match. A localhost + // stand-in (a local MinIO) earns no such trust and forwards by signing. return IsTigrisEndpoint(c.Upstream.Endpoint) default: return false @@ -909,24 +913,37 @@ func validateTLS(server *ServerConfig) error { return nil } -// IsTigrisEndpoint reports whether the endpoint host is localhost or a Tigris -// domain (*.tigris.dev, *.storage.dev). Transparent proxy mode requires a Tigris -// endpoint; signing mode works with any S3-compatible endpoint. Returns false if -// the endpoint cannot be parsed. +// IsTigrisEndpoint reports whether the endpoint host is a Tigris domain +// (*.tigris.dev, *.storage.dev) — the backends whose behavior TAG has +// verified: X-Tigris-Proxy-* headers understood, keys learnable, If-Match on +// DELETE enforced. It selects tiered mode's forwarding flavor and therefore +// whether the cross-tier cleanup may run, so it is deliberately STRICT: +// localhost is not Tigris (a local MinIO is not), see IsTransparentEndpoint +// for the transparent-mode allowlist. Hostnames are case-insensitive +// (RFC 4343). Returns false if the endpoint cannot be parsed. func IsTigrisEndpoint(endpoint string) bool { u, err := url.Parse(endpoint) if err != nil { return false } + host := strings.ToLower(u.Hostname()) // strips port if present + return strings.HasSuffix(host, ".tigris.dev") || strings.HasSuffix(host, ".storage.dev") +} - host := u.Hostname() // strips port if present - if host == "localhost" { +// IsTransparentEndpoint reports whether transparent proxy mode may target the +// endpoint: a Tigris domain, or localhost for local testing against a Tigris +// stand-in. This is an ALLOWLIST for the transparent-mode validator only — it +// carries no trust claim about the backend, which is why tiered mode's flavor +// selection uses IsTigrisEndpoint instead. +func IsTransparentEndpoint(endpoint string) bool { + if IsTigrisEndpoint(endpoint) { return true } - // Hostnames are case-insensitive (RFC 4343); this predicate now selects - // the forwarding flavor, so a mixed-case URL must not fall into signing. - host = strings.ToLower(host) - return strings.HasSuffix(host, ".tigris.dev") || strings.HasSuffix(host, ".storage.dev") + u, err := url.Parse(endpoint) + if err != nil { + return false + } + return strings.ToLower(u.Hostname()) == "localhost" } // validateUpstreamEndpoint ensures the upstream endpoint is a well-formed @@ -946,7 +963,7 @@ func validateUpstreamEndpoint(endpoint string, transparent bool) error { if u.Hostname() == "" { return fmt.Errorf("upstream endpoint %q must include a host", endpoint) } - if transparent && !IsTigrisEndpoint(endpoint) { + if transparent && !IsTransparentEndpoint(endpoint) { return fmt.Errorf("upstream endpoint %q is not allowed in transparent proxy mode: host must be localhost, *.tigris.dev, or *.storage.dev (use signing mode for other S3-compatible services)", endpoint) } return nil diff --git a/config/config_test.go b/config/config_test.go index a543cb32..a00f5d42 100644 --- a/config/config_test.go +++ b/config/config_test.go @@ -888,8 +888,9 @@ func TestIsTigrisEndpoint(t *testing.T) { }{ {"tigris.dev domain", "https://fly.storage.tigris.dev", true}, {"storage.dev domain", "https://t3.storage.dev", true}, - {"localhost", "http://localhost:8080", true}, - {"localhost no port", "http://localhost", true}, + {"mixed-case tigris host", "https://T3.Storage.Dev", true}, + {"localhost is not Tigris", "http://localhost:8080", false}, + {"localhost no port is not Tigris", "http://localhost", false}, {"third-party s3", "https://s3.amazonaws.com", false}, {"minio", "http://minio.internal:9000", false}, {"not a suffix match", "https://nottrigris.dev", false}, @@ -905,6 +906,27 @@ func TestIsTigrisEndpoint(t *testing.T) { } } +// The transparent-mode allowlist admits Tigris domains and localhost (any +// case) for local testing, and nothing else. +func TestIsTransparentEndpoint(t *testing.T) { + tests := []struct { + endpoint string + want bool + }{ + {"https://t3.storage.dev", true}, + {"http://localhost:8080", true}, + {"http://Localhost", true}, + {"http://LOCALHOST:9000", true}, + {"http://minio.internal:9000", false}, + {"https://s3.amazonaws.com", false}, + } + for _, tt := range tests { + if got := IsTransparentEndpoint(tt.endpoint); got != tt.want { + t.Errorf("IsTransparentEndpoint(%q) = %v, want %v", tt.endpoint, got, tt.want) + } + } +} + // Signing mode re-signs with standard SigV4 and works against any S3-compatible // service, so the Tigris endpoint allowlist must not be enforced there. func TestLoad_SigningModeAllowsNonTigrisEndpoint(t *testing.T) { diff --git a/config/mode_test.go b/config/mode_test.go index b159c44b..81e3d0b3 100644 --- a/config/mode_test.go +++ b/config/mode_test.go @@ -223,6 +223,8 @@ func TestMode_ForwardsTransparently(t *testing.T) { {"tiered/oci", ModeTiered, oci, false}, {"default(transparent)/tigris", "", tigris, true}, {"tiered/tigris mixed-case host", ModeTiered, "https://T3.Storage.Dev", true}, + {"tiered/localhost is NOT trusted → signing", ModeTiered, "http://localhost:9000", false}, + {"tiered/Localhost mixed-case → signing", ModeTiered, "http://Localhost:9000", false}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { @@ -236,3 +238,18 @@ func TestMode_ForwardsTransparently(t *testing.T) { }) } } + +// Transparent mode keeps its localhost allowance for local testing; tiered +// treats the same endpoint as untrusted (signing flavor, cleanup disabled). +func TestMode_TransparentAllowsLocalhost(t *testing.T) { + var cfg Config + applyDefaults(&cfg) + cfg.Mode = ModeTransparent + cfg.Upstream.Endpoint = "http://Localhost:9000" + if err := validate(&cfg); err != nil { + t.Fatalf("validate rejected transparent mode on localhost: %v", err) + } + if !cfg.ForwardsTransparently() { + t.Fatal("transparent mode on localhost must forward transparently") + } +} diff --git a/docs/tiered-mode.md b/docs/tiered-mode.md index d8366299..b2d2304c 100644 --- a/docs/tiered-mode.md +++ b/docs/tiered-mode.md @@ -57,21 +57,27 @@ on their first validated read. **Forwarding flavor is decided by the endpoint.** Tiering is a caching topology, not a forwarding flavor: a tiered deployment fronting a Tigris -endpoint (`*.tigris.dev`, `*.storage.dev`, localhost) forwards the transparent -way (client signature preserved, `X-Tigris-Proxy-*` identity headers, keys -learned from Tigris); one fronting any other S3-compatible endpoint forwards -the signing way (TAG validates the caller against its credential store and -re-signs). Only transparent mode itself is pinned to Tigris. The startup log -states which flavor was selected. - -**Credential requirement**: unlike proxy mode's read-only guidance (which -targets customer buckets), tiered mode's upstream is the operator's own cache -bucket, and the credentials TAG forwards with — the validated caller's key on -Tigris, the credential-store key otherwise — must have **delete** permission -there: the cross-tier cleanup DELETE and the re-tier fetch are signed with -them. With read-only credentials every cleanup is rejected (visible as -`tag_tiered_cleanup_total{outcome="rejected"}`) and displaced upstream copies -accumulate until bucket expiry. +domain (`*.tigris.dev`, `*.storage.dev`) forwards the transparent way (client +signature preserved, `X-Tigris-Proxy-*` identity headers, keys learned from +Tigris); one fronting any other endpoint — including `localhost`, which +transparent mode allows for local testing but which earns no trust claim +about the backend behind it — forwards the signing way (TAG validates the +caller against its credential store and re-signs). The startup log states +which flavor was selected. + +**Credential requirement** (Tigris-backed tiered deployments): unlike proxy +mode's read-only guidance (which targets customer buckets), tiered mode's +upstream is the operator's own cache bucket, and the validated caller's key +must have **delete** permission there — the cross-tier cleanup DELETE is +signed with it. With read-only credentials every cleanup is rejected (visible +as `tag_tiered_cleanup_total{outcome="rejected"}`) and displaced upstream +copies accumulate until bucket expiry. On any other endpoint the cleanup is +never issued (next paragraph), so TAG's *background* operations need only +**read** on the key (the re-tier fetch is a GET) and `rejected` cannot occur. +That is not the key's overall requirement: on the signing flavor the same +credential-store key re-signs every forwarded client operation — large-object +PUTs, upstream-tier DELETEs, multipart, copies — so it must carry whatever +those operations need. **Non-Tigris endpoints: cross-tier cleanup is disabled.** The cleanup DELETE carries `If-Match` so a racing replacement is never deleted, and that safety