mirror of
https://github.com/seaweedfs/seaweedfs.git
synced 2026-10-23 06:20:48 +02:00
* s3: keep '\' a literal object-key character when AllowBackslashInKey is set NormalizeObjectKey rewrites every S3 object key: '\' becomes '/' and duplicate slashes collapse. On AWS S3 both are ordinary key characters, so keys that are distinct there alias one object here — writing probe/back/slash.txt silently overwrites probe/back\slash.txt. The filer only treats '/' as a path separator, so a verbatim '\' stays inside one entry name. Gate the backslash rewrite and the matching '\' handling in IsValidObjectKey behind a process-wide mode the S3 server sets from the new S3ApiServerOption. Duplicate-slash collapsing stays: an entry name cannot contain an empty segment. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> * command: add -s3.allowBackslashInKey flag for AWS-compatible key handling Registers the flag on all four S3 entry points (s3, server -s3, filer -s3, mini) and passes it through S3Options into S3ApiServerOption. Default off, so existing normalization is unchanged. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> * s3: correct IsValidObjectKey/NormalizeObjectKey docs under allowBackslashInKey Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> * s3: normalize DeleteObjects body keys the same as path keys DeleteObjects keys arrive in the XML body, so they bypassed the normalization applied to route-var keys. The lock, the authorization checks, the table-bucket layout check and the filer delete itself all addressed the raw key while the conditional check resolved the normalized one, so a key spelled with '\' or '//' could be reported deleted while the stored entry survived. Echo the client-sent key in the result while operating on the normalized form. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> * s3: report a normalized-empty DeleteObjects key as a delete no-op A multi-delete key of only slashes normalizes to the empty string. Left to reach the filer delete, the empty name resolves to the bucket entry itself, so an unauthorized caller could remove a bucket through the body-key path. Such a key can never name a stored entry, so like a missing key the delete is an idempotent no-op reported before any permission check or filer call. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> * s3: drop unused AllowBackslashInObjectKey getter * s3: answer conditional delete of a normalized-empty key with NoSuchKey * s3: normalize the probed key in authorizeAnonymousBucketList The masked-miss probe read the raw mux var, so a spelling like folder// or folder\ was authorized against a key that can never exist while the request itself targets the normalized folder/. Probe the normalized key so the answer matches what the read path authorizes. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> * s3: cover anonymous authz of a literal-backslash key under allowBackslashInKey A public grant on the slash key must not authorize an anonymous read of the distinct backslash object when the flag keeps '\' literal; grant the backslash key's own ACL and it reads back. Default mode still folds the spelling onto the covered slash key. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> * s3: publish the key mode only after the S3 server is constructed A failed NewS3ApiServerWithStore could previously flip the process-wide key mode out from under an already-running gateway. * s3: normalize the copy-source key before validation and authorization pathToBucketObjectAndVersion returned the raw header spelling, so an equivalent spelling (a//b, a\b) was authorized and compared against the normalized destination while resolveCopySourceEntry looked up the normalized key — a deny on the canonical key could be bypassed and self-copy detection missed. Normalize once at parse so the CopyObject and UploadPartCopy paths agree. --------- Co-authored-by: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
529 lines
22 KiB
Go
529 lines
22 KiB
Go
/*
|
|
* MinIO Cloud Storage, (C) 2019 MinIO, Inc.
|
|
*
|
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
* you may not use this file except in compliance with the License.
|
|
* You may obtain a copy of the License at
|
|
*
|
|
* http://www.apache.org/licenses/LICENSE-2.0
|
|
*
|
|
* Unless required by applicable law or agreed to in writing, software
|
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
* See the License for the specific language governing permissions and
|
|
* limitations under the License.
|
|
*/
|
|
|
|
package s3_constants
|
|
|
|
import (
|
|
"context"
|
|
"net/http"
|
|
"strings"
|
|
"sync/atomic"
|
|
|
|
"github.com/gorilla/mux"
|
|
)
|
|
|
|
// S3 XML namespace
|
|
const (
|
|
S3Namespace = "http://s3.amazonaws.com/doc/2006-03-01/"
|
|
)
|
|
|
|
// S3 object key limits
|
|
const (
|
|
MaxS3ObjectKeyLength = 1024
|
|
)
|
|
|
|
// Standard S3 HTTP request constants
|
|
const (
|
|
// S3 storage class
|
|
AmzStorageClass = "X-Amz-Storage-Class"
|
|
|
|
// S3 user-defined metadata
|
|
AmzUserMetaPrefix = "X-Amz-Meta-"
|
|
AmzUserMetaDirective = "X-Amz-Metadata-Directive"
|
|
AmzUserMetaMtime = "X-Amz-Meta-Mtime"
|
|
|
|
// S3 object tagging
|
|
AmzObjectTagging = "X-Amz-Tagging"
|
|
AmzObjectTaggingPrefix = "X-Amz-Tagging-"
|
|
AmzObjectTaggingDirective = "X-Amz-Tagging-Directive"
|
|
AmzTagCount = "x-amz-tagging-count"
|
|
|
|
// GetObjectAttributes headers
|
|
AmzObjectAttributes = "X-Amz-Object-Attributes"
|
|
AmzMaxParts = "X-Amz-Max-Parts"
|
|
AmzPartNumberMarker = "X-Amz-Part-Number-Marker"
|
|
AmzDeleteMarker = "X-Amz-Delete-Marker"
|
|
|
|
SeaweedFSUploadId = "X-Seaweedfs-Upload-Id"
|
|
SeaweedFSExtract = "X-Seaweedfs-Extract"
|
|
SeaweedFSExtractedCount = "X-Seaweedfs-Extracted-Count"
|
|
AmzMetaSnowballAutoExtract = "X-Amz-Meta-Snowball-Auto-Extract"
|
|
SeaweedFSMultipartPartsCount = "X-Seaweedfs-Multipart-Parts-Count"
|
|
SeaweedFSMultipartPartBoundaries = "X-Seaweedfs-Multipart-Part-Boundaries" // JSON: [{part:1,start:0,end:2,etag:"abc"},{part:2,start:2,end:3,etag:"def"}]
|
|
SeaweedFSExpiresS3 = "X-Seaweedfs-Expires-S3"
|
|
AmzMpPartsCount = "x-amz-mp-parts-count"
|
|
|
|
// S3 ACL headers
|
|
AmzCannedAcl = "X-Amz-Acl"
|
|
AmzAclFullControl = "X-Amz-Grant-Full-Control"
|
|
AmzAclRead = "X-Amz-Grant-Read"
|
|
AmzAclWrite = "X-Amz-Grant-Write"
|
|
AmzAclReadAcp = "X-Amz-Grant-Read-Acp"
|
|
AmzAclWriteAcp = "X-Amz-Grant-Write-Acp"
|
|
|
|
// S3 Object Lock headers
|
|
AmzBucketObjectLockEnabled = "X-Amz-Bucket-Object-Lock-Enabled"
|
|
AmzObjectLockMode = "X-Amz-Object-Lock-Mode"
|
|
AmzObjectLockRetainUntilDate = "X-Amz-Object-Lock-Retain-Until-Date"
|
|
AmzObjectLockLegalHold = "X-Amz-Object-Lock-Legal-Hold"
|
|
|
|
// S3 checksum headers
|
|
AmzChecksumAlgorithm = "X-Amz-Checksum-Algorithm"
|
|
AmzChecksumCRC32 = "X-Amz-Checksum-Crc32"
|
|
AmzChecksumCRC32C = "X-Amz-Checksum-Crc32c"
|
|
AmzChecksumCRC64NVME = "X-Amz-Checksum-Crc64nvme"
|
|
AmzChecksumSHA1 = "X-Amz-Checksum-Sha1"
|
|
AmzChecksumSHA256 = "X-Amz-Checksum-Sha256"
|
|
AmzTrailer = "X-Amz-Trailer"
|
|
AmzSdkChecksumAlgorithm = "X-Amz-Sdk-Checksum-Algorithm"
|
|
AmzChecksumType = "X-Amz-Checksum-Type"
|
|
AmzChecksumMode = "X-Amz-Checksum-Mode"
|
|
|
|
// S3 checksum type values (x-amz-checksum-type). A COMPOSITE checksum is a
|
|
// checksum-of-per-part-checksums ("base64-N"); a FULL_OBJECT checksum is the
|
|
// checksum of the whole object as if uploaded in a single request ("base64").
|
|
ChecksumTypeComposite = "COMPOSITE"
|
|
ChecksumTypeFullObject = "FULL_OBJECT"
|
|
|
|
// S3 conditional headers
|
|
IfMatch = "If-Match"
|
|
IfNoneMatch = "If-None-Match"
|
|
IfModifiedSince = "If-Modified-Since"
|
|
IfUnmodifiedSince = "If-Unmodified-Since"
|
|
|
|
// S3 conditional copy headers
|
|
AmzCopySourceIfMatch = "X-Amz-Copy-Source-If-Match"
|
|
AmzCopySourceIfNoneMatch = "X-Amz-Copy-Source-If-None-Match"
|
|
AmzCopySourceIfModifiedSince = "X-Amz-Copy-Source-If-Modified-Since"
|
|
AmzCopySourceIfUnmodifiedSince = "X-Amz-Copy-Source-If-Unmodified-Since"
|
|
|
|
// RenameObject
|
|
// AmzClientToken makes a rename idempotent. The AWS SDKs fill it in on every
|
|
// call, so it arrives on requests that were never written with it in mind.
|
|
AmzClientToken = "X-Amz-Client-Token"
|
|
AmzRenameSource = "X-Amz-Rename-Source"
|
|
AmzRenameSourceIfMatch = "X-Amz-Rename-Source-If-Match"
|
|
AmzRenameSourceIfNoneMatch = "X-Amz-Rename-Source-If-None-Match"
|
|
AmzRenameSourceIfModifiedSince = "X-Amz-Rename-Source-If-Modified-Since"
|
|
AmzRenameSourceIfUnmodifiedSince = "X-Amz-Rename-Source-If-Unmodified-Since"
|
|
|
|
// S3 Server-Side Encryption with Customer-provided Keys (SSE-C)
|
|
AmzServerSideEncryptionCustomerAlgorithm = "X-Amz-Server-Side-Encryption-Customer-Algorithm"
|
|
AmzServerSideEncryptionCustomerKey = "X-Amz-Server-Side-Encryption-Customer-Key"
|
|
AmzServerSideEncryptionCustomerKeyMD5 = "X-Amz-Server-Side-Encryption-Customer-Key-MD5"
|
|
AmzServerSideEncryptionContext = "X-Amz-Server-Side-Encryption-Context"
|
|
|
|
// S3 Server-Side Encryption with KMS (SSE-KMS)
|
|
AmzServerSideEncryption = "X-Amz-Server-Side-Encryption"
|
|
AmzServerSideEncryptionAwsKmsKeyId = "X-Amz-Server-Side-Encryption-Aws-Kms-Key-Id"
|
|
AmzServerSideEncryptionBucketKeyEnabled = "X-Amz-Server-Side-Encryption-Bucket-Key-Enabled"
|
|
|
|
// S3 SSE-C copy source headers
|
|
AmzCopySourceServerSideEncryptionCustomerAlgorithm = "X-Amz-Copy-Source-Server-Side-Encryption-Customer-Algorithm"
|
|
AmzCopySourceServerSideEncryptionCustomerKey = "X-Amz-Copy-Source-Server-Side-Encryption-Customer-Key"
|
|
AmzCopySourceServerSideEncryptionCustomerKeyMD5 = "X-Amz-Copy-Source-Server-Side-Encryption-Customer-Key-MD5"
|
|
)
|
|
|
|
// Metadata keys for internal storage
|
|
const (
|
|
// SSE-KMS metadata keys
|
|
AmzEncryptedDataKey = "x-amz-encrypted-data-key"
|
|
AmzEncryptionContextMeta = "x-amz-encryption-context"
|
|
|
|
// SeaweedFS internal metadata prefix (used to filter internal headers from client responses)
|
|
SeaweedFSInternalPrefix = "x-seaweedfs-"
|
|
|
|
// SeaweedFSPrefixObject marks a directory entry that also holds the object named by
|
|
// its path without a trailing slash. S3 keys are flat, so "a/b" and "a/b/c" are
|
|
// independent keys, and the filer stores the first one on the directory the second
|
|
// one lives under.
|
|
SeaweedFSPrefixObject = "x-seaweedfs-prefix-object"
|
|
|
|
// SeaweedFS internal metadata keys for encryption (prefixed to avoid automatic HTTP header conversion)
|
|
SeaweedFSSSEKMSKey = "x-seaweedfs-sse-kms-key" // Key for storing serialized SSE-KMS metadata
|
|
SeaweedFSSSES3Key = "x-seaweedfs-sse-s3-key" // Key for storing serialized SSE-S3 metadata
|
|
SeaweedFSSSEIV = "x-seaweedfs-sse-c-iv" // Key for storing SSE-C IV
|
|
|
|
// Multipart upload metadata keys for SSE-KMS (consistent with internal metadata key pattern)
|
|
SeaweedFSSSEKMSKeyID = "x-seaweedfs-sse-kms-key-id" // Key ID for multipart upload SSE-KMS inheritance
|
|
SeaweedFSSSEKMSEncryption = "x-seaweedfs-sse-kms-encryption" // Encryption type for multipart upload SSE-KMS inheritance
|
|
SeaweedFSSSEKMSBucketKeyEnabled = "x-seaweedfs-sse-kms-bucket-key-enabled" // Bucket key setting for multipart upload SSE-KMS inheritance
|
|
SeaweedFSSSEKMSEncryptionContext = "x-seaweedfs-sse-kms-encryption-context" // Encryption context for multipart upload SSE-KMS inheritance
|
|
SeaweedFSSSEKMSBaseIV = "x-seaweedfs-sse-kms-base-iv" // Base IV for multipart upload SSE-KMS (for IV offset calculation)
|
|
|
|
// SeaweedFSRenameToken records the x-amz-client-token of the rename that put an
|
|
// object at its key, together with the source that rename named. It rides on the
|
|
// object itself, so every gateway reads the same answer for a retry of that
|
|
// rename, and it is dropped whenever the key is written again.
|
|
SeaweedFSRenameToken = "x-seaweedfs-rename-token"
|
|
|
|
// SeaweedFSWriteId identifies the single write that stored an entry: putToFiler
|
|
// stamps a fresh id into Extended on every call. Recovery for an ambiguously
|
|
// committed write compares it to tell its own entry from an identical older
|
|
// object — chunk lists cannot, since an empty object carries none.
|
|
SeaweedFSWriteId = "x-seaweedfs-write-id"
|
|
|
|
// Multipart upload metadata keys for SSE-S3
|
|
SeaweedFSSSES3Encryption = "x-seaweedfs-sse-s3-encryption" // Encryption type for multipart upload SSE-S3 inheritance
|
|
SeaweedFSSSES3BaseIV = "x-seaweedfs-sse-s3-base-iv" // Base IV for multipart upload SSE-S3 (for IV offset calculation)
|
|
SeaweedFSSSES3KeyData = "x-seaweedfs-sse-s3-key-data" // Encrypted key data for multipart upload SSE-S3 inheritance
|
|
)
|
|
|
|
// SeaweedFS internal headers for filer communication
|
|
const (
|
|
SeaweedFSSSEKMSKeyHeader = "X-SeaweedFS-SSE-KMS-Key" // Header for passing SSE-KMS metadata to filer
|
|
SeaweedFSSSEIVHeader = "X-SeaweedFS-SSE-IV" // Header for passing SSE-C IV to filer (SSE-C only)
|
|
SeaweedFSSSEKMSBaseIVHeader = "X-SeaweedFS-SSE-KMS-Base-IV" // Header for passing base IV for multipart SSE-KMS
|
|
SeaweedFSSSES3BaseIVHeader = "X-SeaweedFS-SSE-S3-Base-IV" // Header for passing base IV for multipart SSE-S3
|
|
SeaweedFSSSES3KeyDataHeader = "X-SeaweedFS-SSE-S3-Key-Data" // Header for passing key data for multipart SSE-S3
|
|
|
|
// SeaweedFSPrincipalHeader and SeaweedFSSessionTokenHeader carry the
|
|
// server-derived principal ARN and session token from the auth layer to
|
|
// downstream authorization (IAM policy evaluation, S3Tables IAM checks).
|
|
// These headers are trusted: callers of AuthSignatureOnly /
|
|
// authenticateRequestInternal must scrub any client-supplied values at the
|
|
// very start of authentication to prevent privilege escalation via header
|
|
// injection. Prefer these constants over literals so that any future scrub
|
|
// or consumer cannot drift by typo.
|
|
SeaweedFSPrincipalHeader = "X-SeaweedFS-Principal"
|
|
SeaweedFSSessionTokenHeader = "X-SeaweedFS-Session-Token"
|
|
)
|
|
|
|
// Non-Standard S3 HTTP request constants
|
|
const (
|
|
AmzIdentityId = "s3-identity-id"
|
|
AmzAccountId = "s3-account-id"
|
|
AmzAuthType = "s3-auth-type"
|
|
)
|
|
|
|
func GetBucketAndObject(r *http.Request) (bucket, object string) {
|
|
vars := mux.Vars(r)
|
|
bucket = vars["bucket"]
|
|
object = NormalizeObjectKey(vars["object"])
|
|
return
|
|
}
|
|
|
|
// allowBackslashInObjectKey keeps '\' a literal object-key character instead
|
|
// of normalizing it to '/'. It is a process-wide mode set once at S3 server
|
|
// startup; the filer only treats '/' as a path separator, so a verbatim '\'
|
|
// stays inside one filer entry name.
|
|
var allowBackslashInObjectKey atomic.Bool
|
|
|
|
func SetAllowBackslashInObjectKey(allow bool) {
|
|
allowBackslashInObjectKey.Store(allow)
|
|
}
|
|
|
|
// IsValidObjectKey rejects S3 object keys that — after the mode-dependent
|
|
// normalization NormalizeObjectKey applies — contain a `.` or `..` path
|
|
// segment, or embed a NUL byte. Such keys are collapsed by filepath.Join
|
|
// inside the filer and would escape the bucket directory, so they must not
|
|
// reach the gRPC layer.
|
|
// Gorilla mux URL-decodes captured vars before this runs, so `%2e%2e` is
|
|
// already `..` here.
|
|
func IsValidObjectKey(object string) bool {
|
|
if object == "" {
|
|
return true
|
|
}
|
|
if strings.ContainsRune(object, '\x00') {
|
|
return false
|
|
}
|
|
if !allowBackslashInObjectKey.Load() {
|
|
object = strings.ReplaceAll(object, "\\", "/")
|
|
}
|
|
for _, seg := range strings.Split(object, "/") {
|
|
if seg == "." || seg == ".." {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
// IsValidBucketName rejects bucket names captured from the URL path that are
|
|
// unsafe to use in filer path construction (`.`, `..`, contain `/` or `\`, or
|
|
// embed NUL). This is a path-safety check, not a full S3 naming-rule check.
|
|
func IsValidBucketName(bucket string) bool {
|
|
if bucket == "" {
|
|
return true
|
|
}
|
|
if bucket == "." || bucket == ".." {
|
|
return false
|
|
}
|
|
return !strings.ContainsAny(bucket, "/\\\x00")
|
|
}
|
|
|
|
// IsValidPathSegment reports whether value is safe to use as one filer path
|
|
// component. It intentionally does not enforce any application-specific
|
|
// format; callers can layer stricter checks on top.
|
|
func IsValidPathSegment(value string) bool {
|
|
if value == "" || value == "." || value == ".." {
|
|
return false
|
|
}
|
|
return !strings.ContainsAny(value, "/\\\x00")
|
|
}
|
|
|
|
// NormalizeObjectKey normalizes object keys by removing duplicate slashes and converting backslashes.
|
|
// This normalizes keys from various sources (URL path, form values, etc.) to a consistent format.
|
|
// It also converts Windows-style backslashes to forward slashes for cross-platform compatibility,
|
|
// unless allowBackslashInObjectKey is set.
|
|
// Returns keys WITHOUT leading slash to match S3 API format (e.g., "foo/bar" not "/foo/bar").
|
|
// Preserves trailing slash if present (e.g., "foo/" stays "foo/").
|
|
func NormalizeObjectKey(object string) string {
|
|
// Preserve trailing slash if present
|
|
hasTrailingSlash := strings.HasSuffix(object, "/")
|
|
|
|
// Convert Windows-style backslashes to forward slashes
|
|
if !allowBackslashInObjectKey.Load() {
|
|
object = strings.ReplaceAll(object, "\\", "/")
|
|
}
|
|
object = removeDuplicateSlashes(object)
|
|
// Remove leading slash to match S3 API format
|
|
object = strings.TrimPrefix(object, "/")
|
|
|
|
// Restore trailing slash if it was present and result is not empty
|
|
if hasTrailingSlash && object != "" && !strings.HasSuffix(object, "/") {
|
|
object = object + "/"
|
|
}
|
|
|
|
return object
|
|
}
|
|
|
|
// removeDuplicateSlashes removes consecutive slashes from a path
|
|
func removeDuplicateSlashes(s string) string {
|
|
var result strings.Builder
|
|
result.Grow(len(s))
|
|
|
|
lastWasSlash := false
|
|
for _, r := range s {
|
|
if r == '/' {
|
|
if !lastWasSlash {
|
|
result.WriteRune(r)
|
|
}
|
|
lastWasSlash = true
|
|
} else {
|
|
result.WriteRune(r)
|
|
lastWasSlash = false
|
|
}
|
|
}
|
|
return result.String()
|
|
}
|
|
|
|
func GetPrefix(r *http.Request) string {
|
|
query := r.URL.Query()
|
|
prefix := query.Get("prefix")
|
|
prefix = removeDuplicateSlashes(prefix)
|
|
return prefix
|
|
}
|
|
|
|
var PassThroughHeaders = map[string]string{
|
|
"response-cache-control": "Cache-Control",
|
|
"response-content-disposition": "Content-Disposition",
|
|
"response-content-encoding": "Content-Encoding",
|
|
"response-content-language": "Content-Language",
|
|
"response-content-type": "Content-Type",
|
|
"response-expires": "Expires",
|
|
}
|
|
|
|
// IsSeaweedFSInternalHeader checks if a header key is a SeaweedFS internal header
|
|
// that should be filtered from client responses.
|
|
// Header names are case-insensitive in HTTP, so this function normalizes to lowercase.
|
|
func IsSeaweedFSInternalHeader(headerKey string) bool {
|
|
return strings.HasPrefix(strings.ToLower(headerKey), SeaweedFSInternalPrefix)
|
|
}
|
|
|
|
// Context keys for storing authenticated identity information
|
|
type contextKey string
|
|
|
|
const (
|
|
contextKeyIdentityName contextKey = "s3-identity-name"
|
|
contextKeyIdentityObject contextKey = "s3-identity-object"
|
|
contextKeyIdentityHolder contextKey = "s3-identity-holder"
|
|
contextKeyPrincipalArn contextKey = "s3-principal-arn"
|
|
contextKeyIdentityClaim contextKey = "s3-identity-claim"
|
|
contextKeyIgnoredSessionToken contextKey = "s3-ignored-session-token"
|
|
contextKeyOriginalRequestPath contextKey = "s3-original-request-path"
|
|
)
|
|
|
|
// identityHolder is a mutable container for the authenticated identity name,
|
|
// stored by pointer in the request context. Authentication runs as an inner
|
|
// handler that records the identity with r.WithContext, which returns a request
|
|
// copy; that copy's new context values are invisible to outer middleware (e.g.
|
|
// the audit logger) holding an earlier copy of the request. A pointer to a
|
|
// holder installed before authentication is shared across all copies, so the
|
|
// name written by the inner handler is readable by the outer middleware.
|
|
type identityHolder struct {
|
|
name atomic.Pointer[string]
|
|
principalArn atomic.Pointer[string]
|
|
identityClaim atomic.Pointer[string]
|
|
}
|
|
|
|
// EnsureIdentityHolder attaches a mutable identity holder to the request context
|
|
// if one is not already present, returning the (possibly new) request. Install
|
|
// it before authentication so the authenticated requester remains recoverable
|
|
// from the original request, e.g. when emitting an audit entry for a handler
|
|
// that does not log the requester itself.
|
|
func EnsureIdentityHolder(r *http.Request) *http.Request {
|
|
if r == nil {
|
|
return nil
|
|
}
|
|
if h, ok := r.Context().Value(contextKeyIdentityHolder).(*identityHolder); ok && h != nil {
|
|
return r
|
|
}
|
|
return r.WithContext(context.WithValue(r.Context(), contextKeyIdentityHolder, &identityHolder{}))
|
|
}
|
|
|
|
// SetIdentityNameInContext stores the authenticated identity name in the request context
|
|
// This is the secure way to propagate identity - headers can be spoofed, context cannot
|
|
func SetIdentityNameInContext(ctx context.Context, identityName string) context.Context {
|
|
if identityName == "" {
|
|
return ctx
|
|
}
|
|
// Also record into the mutable holder (if installed) so the name survives the
|
|
// request-context copy and stays visible to outer middleware such as the
|
|
// audit logger.
|
|
if h, ok := ctx.Value(contextKeyIdentityHolder).(*identityHolder); ok && h != nil {
|
|
h.name.Store(&identityName)
|
|
}
|
|
return context.WithValue(ctx, contextKeyIdentityName, identityName)
|
|
}
|
|
|
|
// GetIdentityNameFromContext retrieves the authenticated identity name from the request context
|
|
// Returns empty string if no identity is set (unauthenticated request)
|
|
// This is the secure way to retrieve identity - never read from headers directly
|
|
func GetIdentityNameFromContext(r *http.Request) string {
|
|
// Prefer the per-request context value; fall back to the mutable holder for
|
|
// callers (e.g. the audit middleware) that hold a copy of the request taken
|
|
// before authentication set the identity.
|
|
if name, ok := r.Context().Value(contextKeyIdentityName).(string); ok && name != "" {
|
|
return name
|
|
}
|
|
if h, ok := r.Context().Value(contextKeyIdentityHolder).(*identityHolder); ok && h != nil {
|
|
if name := h.name.Load(); name != nil {
|
|
return *name
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// SetPrincipalArnInContext stores the authenticated principal ARN in the request
|
|
// context. For an STS session the identity name is an opaque session subject, so
|
|
// the ARN is the only place the assumed role and session name survive to the
|
|
// audit log.
|
|
func SetPrincipalArnInContext(ctx context.Context, principalArn string) context.Context {
|
|
if principalArn == "" {
|
|
return ctx
|
|
}
|
|
if h, ok := ctx.Value(contextKeyIdentityHolder).(*identityHolder); ok && h != nil {
|
|
h.principalArn.Store(&principalArn)
|
|
}
|
|
return context.WithValue(ctx, contextKeyPrincipalArn, principalArn)
|
|
}
|
|
|
|
// GetPrincipalArnFromContext retrieves the authenticated principal ARN from the
|
|
// request context, or "" when the request is unauthenticated.
|
|
func GetPrincipalArnFromContext(r *http.Request) string {
|
|
if arn, ok := r.Context().Value(contextKeyPrincipalArn).(string); ok && arn != "" {
|
|
return arn
|
|
}
|
|
if h, ok := r.Context().Value(contextKeyIdentityHolder).(*identityHolder); ok && h != nil {
|
|
if arn := h.principalArn.Load(); arn != nil {
|
|
return *arn
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// SetIdentityClaimInContext stores the authoritative OIDC identity claim (e.g.
|
|
// preferred_username, email, sub) for the audit log. For an STS-assumed OIDC
|
|
// session the requester name is an opaque session subject, so the claim is the
|
|
// only place a stable, human-readable federated identity survives to the audit
|
|
// log. Empty for non-federated sessions, where the requester name already
|
|
// carries the real username.
|
|
func SetIdentityClaimInContext(ctx context.Context, identityClaim string) context.Context {
|
|
if identityClaim == "" {
|
|
return ctx
|
|
}
|
|
if h, ok := ctx.Value(contextKeyIdentityHolder).(*identityHolder); ok && h != nil {
|
|
h.identityClaim.Store(&identityClaim)
|
|
}
|
|
return context.WithValue(ctx, contextKeyIdentityClaim, identityClaim)
|
|
}
|
|
|
|
// GetIdentityClaimFromContext retrieves the authoritative OIDC identity claim
|
|
// from the request context, or "" when the request is unauthenticated or not
|
|
// federated.
|
|
func GetIdentityClaimFromContext(r *http.Request) string {
|
|
if claim, ok := r.Context().Value(contextKeyIdentityClaim).(string); ok && claim != "" {
|
|
return claim
|
|
}
|
|
if h, ok := r.Context().Value(contextKeyIdentityHolder).(*identityHolder); ok && h != nil {
|
|
if claim := h.identityClaim.Load(); claim != nil {
|
|
return *claim
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// SetOriginalRequestPathInContext records the path a client actually sent
|
|
// before an internal rewrite (e.g. the Iceberg catalog collapsing a doubled
|
|
// /v1 prefix). SigV4 verification must run against this path: the client
|
|
// signed the URI it requested, not the normalized path it was routed with.
|
|
func SetOriginalRequestPathInContext(ctx context.Context, path string) context.Context {
|
|
if path == "" {
|
|
return ctx
|
|
}
|
|
return context.WithValue(ctx, contextKeyOriginalRequestPath, path)
|
|
}
|
|
|
|
// GetOriginalRequestPathFromContext returns the pre-rewrite request path
|
|
// recorded by SetOriginalRequestPathInContext, or "" when unset.
|
|
func GetOriginalRequestPathFromContext(r *http.Request) string {
|
|
if path, ok := r.Context().Value(contextKeyOriginalRequestPath).(string); ok {
|
|
return path
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// IgnoreSessionTokenInContext marks the request's session token as tolerated
|
|
// rather than authenticated: a statically configured access key signed the
|
|
// request, so the token (which credential vendors like Unity Catalog emit on
|
|
// static credentials too) must not steer authorization into the STS session
|
|
// path. A context value because headers can be spoofed; this cannot.
|
|
func IgnoreSessionTokenInContext(ctx context.Context) context.Context {
|
|
return context.WithValue(ctx, contextKeyIgnoredSessionToken, true)
|
|
}
|
|
|
|
// IsSessionTokenIgnored reports whether the request carries a session token
|
|
// that was tolerated for a statically configured access key.
|
|
func IsSessionTokenIgnored(ctx context.Context) bool {
|
|
ignored, _ := ctx.Value(contextKeyIgnoredSessionToken).(bool)
|
|
return ignored
|
|
}
|
|
|
|
// SetIdentityInContext stores the full authenticated identity object in the request context
|
|
// This is used to pass the full identity (including for JWT users) to handlers
|
|
func SetIdentityInContext(ctx context.Context, identity interface{}) context.Context {
|
|
if identity != nil {
|
|
return context.WithValue(ctx, contextKeyIdentityObject, identity)
|
|
}
|
|
return ctx
|
|
}
|
|
|
|
// GetIdentityFromContext retrieves the full identity object from the request context
|
|
// Returns nil if no identity is set (unauthenticated request)
|
|
func GetIdentityFromContext(r *http.Request) interface{} {
|
|
return r.Context().Value(contextKeyIdentityObject)
|
|
}
|