Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 28 additions & 11 deletions config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down
26 changes: 24 additions & 2 deletions config/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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},
Expand All @@ -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) {
Expand Down
17 changes: 17 additions & 0 deletions config/mode_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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")
}
}
36 changes: 21 additions & 15 deletions docs/tiered-mode.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading