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.
105 lines
3.6 KiB
Go
105 lines
3.6 KiB
Go
package iceberg
|
|
|
|
import (
|
|
"fmt"
|
|
"net/http"
|
|
"strings"
|
|
|
|
"github.com/gorilla/mux"
|
|
"github.com/seaweedfs/seaweedfs/weed/s3api/s3_constants"
|
|
)
|
|
|
|
// validateRequestPath rejects Iceberg REST requests whose captured
|
|
// {prefix}/{namespace}/{table} mux vars would produce a parent-directory
|
|
// traversal when joined into a filer path. The iceberg router runs with
|
|
// SkipClean(true), so `..` survives routing; downstream path.Join calls
|
|
// (stageCreateMarkerDir, location builders, etc.) then collapse it and
|
|
// escape the table-bucket directory.
|
|
//
|
|
// {prefix} maps to a table-bucket name; {table} is a single path segment;
|
|
// {namespace} is unit-separator (0x1F) joined parts that get flattened into
|
|
// a single dotted name for the on-disk layout — each part is validated
|
|
// individually.
|
|
func validateRequestPath(next http.Handler) http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
vars := mux.Vars(r)
|
|
// Use the comma-ok form so vars only checked when the matched route
|
|
// actually captures them; when captured, an empty value is itself a
|
|
// rejection because downstream path.Join would collapse it.
|
|
if prefix, ok := vars["prefix"]; ok {
|
|
if prefix == "" || !s3_constants.IsValidBucketName(prefix) {
|
|
writeError(w, http.StatusBadRequest, "BadRequest", "invalid prefix")
|
|
return
|
|
}
|
|
}
|
|
if table, ok := vars["table"]; ok {
|
|
if table == "" || !isValidNameSegment(table) {
|
|
writeError(w, http.StatusBadRequest, "BadRequest", "invalid table name")
|
|
return
|
|
}
|
|
}
|
|
if ns, ok := vars["namespace"]; ok {
|
|
if ns == "" {
|
|
writeError(w, http.StatusBadRequest, "BadRequest", "invalid namespace")
|
|
return
|
|
}
|
|
// Reject leading/trailing/consecutive unit separators so distinct
|
|
// inputs cannot collapse to the same parsed namespace via
|
|
// parseNamespace's empty-part filter.
|
|
for _, part := range strings.Split(ns, "\x1F") {
|
|
if part == "" || !isValidNameSegment(part) {
|
|
writeError(w, http.StatusBadRequest, "BadRequest", "invalid namespace")
|
|
return
|
|
}
|
|
}
|
|
}
|
|
next.ServeHTTP(w, r)
|
|
})
|
|
}
|
|
|
|
// isValidTablePath reports whether a "/"-separated table path (the part below
|
|
// the bucket) is free of segments that path.Join would collapse to escape the
|
|
// bucket directory. Empty segments (from leading/duplicate slashes) are
|
|
// ignored, but the path must resolve to at least one real segment so it does
|
|
// not collapse to the bucket-level metadata directory.
|
|
func isValidTablePath(tablePath string) bool {
|
|
hasSegment := false
|
|
for _, segment := range strings.Split(tablePath, "/") {
|
|
if segment == "" {
|
|
continue
|
|
}
|
|
if !isValidNameSegment(segment) {
|
|
return false
|
|
}
|
|
hasSegment = true
|
|
}
|
|
return hasSegment
|
|
}
|
|
|
|
// isValidNameSegment rejects a single path-segment value (bucket prefix slot,
|
|
// table name, or one namespace part) that would be unsafe to embed in a filer
|
|
// path: `.`, `..`, embedded slash/backslash, or NUL.
|
|
func isValidNameSegment(s string) bool {
|
|
if s == "" {
|
|
return true
|
|
}
|
|
if s == "." || s == ".." {
|
|
return false
|
|
}
|
|
return !strings.ContainsAny(s, "/\\\x00")
|
|
}
|
|
|
|
// confineMetadataLocation checks that a parsed s3 location stays within the
|
|
// authorized table bucket and rejects traversal segments that path.Join in
|
|
// saveMetadataBlob would collapse to escape it. The table path must resolve
|
|
// to at least one real segment so its metadata directory is table-specific.
|
|
func confineMetadataLocation(metadataBucket, metadataPath, bucketName string) error {
|
|
if metadataBucket != bucketName {
|
|
return fmt.Errorf("table location must be within bucket %s", bucketName)
|
|
}
|
|
if !isValidTablePath(metadataPath) {
|
|
return fmt.Errorf("invalid table location path")
|
|
}
|
|
return nil
|
|
}
|