Skip to content

Refuse unsigned x-amz-* headers and require the lock permissions at write time - #104

Merged
danielmarv merged 1 commit into
mainfrom
fix/s3-unsigned-headers-and-lock-permissions
Sep 24, 2026
Merged

danielmarv merged 1 commit into
mainfrom
fix/s3-unsigned-headers-and-lock-permissions

Conversation

@danielmarv

Copy link
Copy Markdown
Collaborator

Summary

Closes two authorization gaps on the S3 data plane.

What was exploitable

  1. Unsigned x-amz-* headers were acted on. Only the headers named in
    SignedHeaders / X-Amz-SignedHeaders go into the canonical request, but the
    handlers read every header present. Anyone holding a presigned PUT URL could
    therefore add headers the signer never approved and have them honoured under the
    signer's identity: a copy source (turning the upload into a copy of anything the
    signer can read), custom metadata, and on main Object Lock headers. AWS refuses
    such requests.
  2. Object Lock at write time needed only s3:PutObject (main only, not in any
    release). The lock headers on a PUT, copy, or multipart initiation added no
    permission, so any writer on a lock-enabled bucket could create COMPLIANCE
    versions nobody can delete.
  3. Copying a named version was checked against s3:GetObject, not
    s3:GetObjectVersion, so an account limited to current objects could read any
    version by copying it.

What changed

  • sigv4::reject_unsigned_amz_headers refuses any request carrying an x-amz-* header
    that is not in the signed-header list. verify_request calls it for both header-auth
    and presigned requests, right after parsing and before the credential lookup, so
    these requests never reach the secret store. Because header auth already requires
    x-amz-date and x-amz-content-sha256 to be present, this also requires them to be
    signed. Presigned X-Amz-* query parameters are not headers and are unaffected.
  • A new S3ErrorKind::UnsignedAmzHeader answers 403 AccessDenied with AWS's message,
    There were headers present in the request which were not signed. This follows the
    existing lock-refusal kinds: the code a client branches on stays standard, and the
    message tells the operator why.
  • request_permissions adds s3:PutObjectRetention when x-amz-object-lock-mode or
    x-amz-object-lock-retain-until-date is present on a keyed request, and
    s3:PutObjectLegalHold when x-amz-object-lock-legal-hold is present. This follows
    the existing bypass pattern. It covers PutObject, CopyObject, and
    CreateMultipartUpload (where a multipart lock is fixed). The check is on presence, not
    value: legal-hold: OFF is an explicit lock choice that replaces the bucket default.
    A bucket default retention still needs only s3:PutObject.
  • A copy source containing ?versionId= requires s3:GetObjectVersion on the source.

Content-MD5 on presigned requests: deliberately not required to be signed. It can
only cause the server to refuse a body the holder already chose, so leaving it unsigned
grants nothing. AWS does not require it to be signed either.

Allowlisted headers

None. Every x-amz-* header must be signed. The boto3, JavaScript v3, Go v2 and Java v2
compatibility suites pass unchanged: the SDKs sign every x-amz-* header they send, and
the suites' presigned URLs are sent by plain HTTP clients that add none.

Compatibility impact (potentially breaking)

  • A client sending an unsigned x-amz-* header is now refused. This affects
    hand-built requests and presigned-URL flows where the uploader adds headers; the fix
    is to sign the URL with those headers.
  • An account that set locks at write time with only s3:PutObject, or copied named
    versions with only s3:GetObject, needs s3:PutObjectRetention /
    s3:PutObjectLegalHold / s3:GetObjectVersion.

Tests

New tests in crates/record-store-s3/src/auth.rs:

  • A presigned PUT with an unsigned x-amz-object-lock-mode: COMPLIANCE is refused and
    nothing is stored. The same request with an unknown access key is refused as unsigned,
    not reported as InvalidAccessKeyId, which shows it was checked before the lookup.
  • A presigned PUT with an unsigned x-amz-copy-source is refused, and nothing is copied.
  • A header-auth PUT with an unsigned x-amz-meta-foo is refused. The same request with
    the header signed succeeds.
  • A header-auth request with x-amz-content-sha256 or x-amz-date unsigned is refused.
  • Retention at write time: refused without s3:PutObjectRetention for both PUT and
    multipart initiation, and allowed with it.
  • Legal hold at write time (ON and OFF): refused without s3:PutObjectLegalHold,
    and allowed with it.
  • With a bucket default retention, a PutObject-only writer succeeds and the default is
    materialized.
  • A copy with ?versionId= is refused without s3:GetObjectVersion, a current-version
    copy still works, and the versioned copy works once the permission is granted.

test_support::signed_request_leaving_unsigned signs a request while leaving the named
headers out of SignedHeaders. The signature over the rest is genuine, so a refusal can
only come from the new check. signed_request already signed every header it set, and
no existing test sent an unsigned x-amz-* header, so no existing test changed.

With the fix disabled, 7 of the 8 new tests fail. The default-retention test passes
either way, as intended: it guards against over-tightening.

  • cargo fmt --all --check
  • cargo clippy --workspace --all-targets --all-features --locked -- -D warnings
  • cargo test --workspace --all-features --locked
  • cargo +nightly fuzz build (SigV4 targets), plus tests/fuzz-smoke.sh
  • tests/compatibility/run.sh (boto3, JS v3, Go v2, Java v2)

Docs

docs/administration/policies.md, docs/administration/object-lock.md,
docs/reference/s3-compatibility.md, docs/reference/errors.md, and a CHANGELOG.md
Security entry flagged as potentially breaking.

…rite time

Only the headers named in SignedHeaders reach the canonical request, but
the handlers act on every header present. A request, above all a
presigned URL in someone else's hands, could therefore carry x-amz-*
terms its signer never approved. verify_request now refuses any x-amz-*
header the signature does not cover, for header and presigned
authentication alike, before the credential lookup. It answers
AccessDenied with the message AWS uses. For header authentication this
also makes x-amz-date and x-amz-content-sha256 signed. Content-MD5 may
stay unsigned: it can only make the server refuse a body.

Object Lock headers on a PUT, copy or multipart initiation now also
require s3:PutObjectRetention (mode, retain-until date) or
s3:PutObjectLegalHold (legal hold), as setting the same lock afterwards
does. A bucket default retention still needs only s3:PutObject. A copy
source naming ?versionId= requires s3:GetObjectVersion rather than
s3:GetObject.
@danielmarv
danielmarv merged commit 4ac6a14 into main Sep 24, 2026
39 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant