mirror of
https://github.com/seaweedfs/seaweedfs.git
synced 2026-09-14 18:40:48 +02:00
* iceberg: confine commit/transaction/view-update write paths to authorized bucket The create, register, and createView handlers already confine the client- supplied metadata location to the caller table bucket and reject ".." segments. The commit, create-on-commit, transaction, and view-update paths read the stored metadataLocation back from the catalog and skipped the same guard, so a location poisoned via the raw S3Tables UpdateTable API (which persists metadataLocation verbatim) could escape the caller bucket through a ".." segment that path.Join collapses in saveMetadataBlob. Add confineMetadataLocation and apply it after parseS3Location on every commit/update/transaction/view write path, mirroring the create/register/ createView check. Reject with 400 so a poisoned stored location fails the commit instead of writing into another tenant bucket tree. * s3tables: validate metadataLocation at the store layer The raw S3Tables API (CreateTable, RegisterTable, UpdateTable, CreateView, UpdateView) persisted the client-supplied metadataLocation verbatim with no bucket-confinement or traversal check, so a caller could store a location pointing outside its own bucket. The Iceberg REST gateway commit paths then read that stored value back and wrote through it. Add ValidateMetadataLocation and call it in every s3tables store handler that accepts a metadataLocation, rejecting locations whose bucket differs from the caller table bucket or whose path contains traversal segments. This prevents a poisoned location from ever being persisted, complementing the per-write-path guard added to the Iceberg commit handlers. * iceberg/s3tables: validate location before repair and after idempotency check Address review feedback: - Move the commit-path confinement check ahead of repairManifests so a poisoned stored location cannot reach manifest repair I/O before the commit is rejected. - Move ValidateMetadataLocation in CreateTable/CreateView to after the existing-resource check so idempotent retries that do not consume the requested location are not rejected for an unused bad location. - Assert HTTP 400 in the cross-tenant reproduction tests so an unrelated failure cannot satisfy them. * iceberg: confine staged metadata location before load in create-on-commit The create-on-commit path parsed the staged metadata location from the stage-create marker and called loadMetadataFile before validating that the staged bucket/path stay within the authorized bucket. Add the same confineMetadataLocation guard before the read so a tampered marker cannot direct a cross-tenant metadata read. * iceberg/s3tables: reject bucket-only metadata locations ValidateMetadataLocation and confineMetadataLocation accepted s3://bucket with an empty table path. metadataDirPath then maps every such table to the shared <TablesPath>/<bucket>/metadata directory, so tables could overwrite or read each other's metadata files. Require a non-empty table path in both validators; the empty-location case (where the catalog derives one) is unaffected. * iceberg/s3tables: reject slash-only table paths in location validation s3://bkt/// parses to tablePath="/" which passed the empty-string check but path.Join cleans it away, mapping to the bucket-level metadata directory shared across tables. Update isValidTablePath to require at least one non-empty segment and mirror the same check in ValidateMetadataLocation, closing the gap in all callers.
98 lines
3.5 KiB
Go
98 lines
3.5 KiB
Go
package iceberg
|
|
|
|
import (
|
|
"encoding/json"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/gorilla/mux"
|
|
"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
|
|
"github.com/seaweedfs/seaweedfs/weed/s3api/s3_constants"
|
|
"github.com/seaweedfs/seaweedfs/weed/s3api/s3tables"
|
|
)
|
|
|
|
// seedPoisonedTable registers a table entry whose stored metadataLocation
|
|
// points outside its own bucket via a ".." segment, simulating a value
|
|
// persisted verbatim by the raw S3Tables UpdateTable API.
|
|
func seedPoisonedTable(t *testing.T, fc *memFiler, bucket, namespace, tableName, metadataLocation string) {
|
|
t.Helper()
|
|
meta := s3tables.TableMetadata{
|
|
Iceberg: &s3tables.IcebergMetadata{TableUUID: "00000000-0000-0000-0000-000000000001"},
|
|
}
|
|
internal := map[string]any{
|
|
"name": tableName,
|
|
"namespace": namespace,
|
|
"format": "ICEBERG",
|
|
"ownerAccountId": s3_constants.AccountAdminId,
|
|
"versionToken": "v1",
|
|
"metadataVersion": 1,
|
|
"metadataLocation": metadataLocation,
|
|
"metadata": meta,
|
|
}
|
|
metaBytes, _ := json.Marshal(internal)
|
|
fc.seed(s3tables.GetTablePath(bucket, namespace, tableName), &filer_pb.Entry{
|
|
Name: tableName,
|
|
IsDirectory: true,
|
|
Extended: map[string][]byte{s3tables.ExtendedKeyMetadata: metaBytes},
|
|
})
|
|
}
|
|
|
|
func TestCommitTableRejectsStoredTraversalLocation(t *testing.T) {
|
|
const attacker = "attacker"
|
|
const victim = "victim"
|
|
fc := newMemFiler()
|
|
seedNamespace(fc, attacker, "ns", s3_constants.AccountAdminId)
|
|
seedPoisonedTable(t, fc, attacker, "ns", "t",
|
|
"s3://attacker/../victim/planted/metadata/v1.metadata.json")
|
|
|
|
s := NewServer(fc, nil)
|
|
|
|
r := httptest.NewRequest(http.MethodPost, "/v1/"+attacker+"/namespaces/ns/tables/t",
|
|
strings.NewReader(`{"requirements":[],"updates":[]}`))
|
|
r = mux.SetURLVars(r, map[string]string{"prefix": attacker, "namespace": "ns", "table": "t"})
|
|
r = r.WithContext(s3_constants.SetIdentityNameInContext(r.Context(), s3_constants.AccountAdminId))
|
|
|
|
w := httptest.NewRecorder()
|
|
s.handleUpdateTable(w, r)
|
|
|
|
if w.Code != http.StatusBadRequest {
|
|
t.Fatalf("status = %d, want %d (body=%s)", w.Code, http.StatusBadRequest, w.Body.String())
|
|
}
|
|
for p := range fc.entries {
|
|
if strings.Contains(p, "/"+victim+"/") && !strings.Contains(p, "/"+attacker+"/") {
|
|
t.Fatalf("cross-tenant write escaped into victim tree at %s (status=%d body=%s)", p, w.Code, w.Body.String())
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestCommitTransactionRejectsStoredTraversalLocation(t *testing.T) {
|
|
const attacker = "attacker"
|
|
const victim = "victim"
|
|
fc := newMemFiler()
|
|
seedNamespace(fc, attacker, "ns", s3_constants.AccountAdminId)
|
|
seedPoisonedTable(t, fc, attacker, "ns", "t",
|
|
"s3://attacker/../victim/planted/metadata/v1.metadata.json")
|
|
|
|
s := NewServer(fc, nil)
|
|
|
|
body := `{"table-changes":[{"identifier":{"namespace":["ns"],"name":"t"},"requirements":[],"updates":[]}]}`
|
|
r := httptest.NewRequest(http.MethodPost, "/v1/"+attacker+"/transactions/commit",
|
|
strings.NewReader(body))
|
|
r = mux.SetURLVars(r, map[string]string{"prefix": attacker})
|
|
r = r.WithContext(s3_constants.SetIdentityNameInContext(r.Context(), s3_constants.AccountAdminId))
|
|
|
|
w := httptest.NewRecorder()
|
|
s.handleCommitTransaction(w, r)
|
|
|
|
if w.Code != http.StatusBadRequest {
|
|
t.Fatalf("status = %d, want %d (body=%s)", w.Code, http.StatusBadRequest, w.Body.String())
|
|
}
|
|
for p := range fc.entries {
|
|
if strings.Contains(p, "/"+victim+"/") && !strings.Contains(p, "/"+attacker+"/") {
|
|
t.Fatalf("cross-tenant transaction write escaped into victim tree at %s (status=%d body=%s)", p, w.Code, w.Body.String())
|
|
}
|
|
}
|
|
}
|