Skip to content

fix(sig): refuse SigV2 on the STS form and zip download floors - #1603

Merged
overtrue merged 10 commits into
mainfrom
p9/1592-sts-refuse-sigv2
Nov 11, 2026
Merged

overtrue merged 10 commits into
mainfrom
p9/1592-sts-refuse-sigv2

Conversation

@overtrue

@overtrue overtrue commented Nov 11, 2026 •

Copy link
Copy Markdown
Collaborator

Fixes #1592

What this changes

A new floor rule, OperationFloor::refuse_sigv2_under_every_policy, refuses every SigV2 request — header, presigned URL or POST policy — under every SigV2Policy. The refusal is 403 AccessDenied at the floor's allow-list step (H3), before the SigV2 credential is parsed and before any credential lookup. It is the narrower sibling of require_sigv4_under_every_policy (ADR-0049): SigV4 and requests without an AWS credential (anonymous, bearer token, custom scheme) are decided exactly as before.

It is applied to:

  • rustfs:StsFormPost (generator template xtask/src/rustfs_admin_dialect/form.rs, regenerated ops/sts_form_post.rs). The floor stays builtin(Sts) with anonymous allowed, so unsigned Web Identity still reaches the handler, and SigV4 AssumeRole is unchanged.

  • admin::zip_token_floor. The rule composes with the bearer claim, so the difference recorded on rustfs:StsFormPost admits SigV2 where RustFS refuses it #1592 (a SigV2 header without a token passed the floor and was refused later) is closed: the SigV2 header is refused at the floor, with or without a token or verifier.

  • admin::listing_floor, which applies the SigV2 refusal to the two RustFS metadata listing operations.

ADR-0052 records the decision and refines ADR-0042, ADR-0044, and ADR-0050.

Property, and parity: SigV2 does not cover the request body, and every STS parameter travels in the body. RustFS refuses SigV2 on this route once credentials are presented.

API added (rustfs-gateway-sig)

  • OperationFloor::refuse_sigv2_under_every_policy(self) -> Self (const)
  • OperationFloor::refuses_sigv2_under_every_policy(&self) -> bool: true for the new rule or for require_sigv4_under_every_policy, which implies it. SecurityFloor::enforce_scheme_allowed now reads this predicate, so every SigV4-only floor behaves exactly as before.

Composition (ADR-0052)

Combined with Decision
require_sigv4_under_every_policy Allowed, not refused at registration: both only narrow, and the stronger rule stands in either order. The registry refuses contradictions only.
A query bearer claim Allowed (the zip download). The bearer path never sees an AWS-marked request. The SigV4 requirement beside a bearer claim is still refused, also when this rule is declared too.
A custom-scheme registry Unaffected: a custom scheme is only offered requests with no AWS marker.

Why sec1_14 and sec1_15 were wrong

sec1_14 asserted that a verified SigV2 header AssumeRole reaches the handler, and sec1_15 that a SigV2-presigned Web Identity form does. Both pinned the gateway's own behaviour, not RustFS's. The legacy stand-in in sts_parity.rs modelled an older RustFS access check that let every signed request through.

Evidence: https://github.com/rustfs/rustfs/blob/443e8afee194fe38b32d62a9289a99c1f703715c/rustfs/src/admin/router.rs#L3391-L3421. At this commit, RustFS's router refuses a SigV2 Authorization header or Signature query key on its custom routes once credentials are present, and its own test (#L6226) lists the /?Action=AssumeRole query among those routes.

The SigV2 rows now refuse:

  • sec1_14 keeps its SigV4 rows, and the SigV2 row moves to the new negative sec1_14_v2_header.
  • sec1_15 keeps its anonymous and SigV4-presigned rows, and the SigV2 row moves to the new negative sec1_15_v2_presigned.
  • sec1_16 changes from SignatureDoesNotMatch to AccessDenied: the floor refuses before any signature is computed.
  • sec1_17 (a correct SigV2 signature over the claimed route) and sec1_25_v2_header change from positives to refusals.

The legacy stand-in's access check now models RustFS's router at that commit. I re-expressed the rule as a short predicate and did not copy RustFS code. With that change, the matrix tests compare SigV2 against RustFS again:

  • ordinary_assume_role_preserves_header_modes_…;
  • the_sts_endpoint_is_taken_on_every_host_…;
  • ordinary_assume_role_on_a_virtual_host_….

Both stacks answer 403 AccessDenied and run no handler.

Known differences (both fail closed, recorded in ADR-0052)

  • Forged SigV2 header. Legacy answers SignatureDoesNotMatch, because it verifies before its SigV2 check. The gateway answers AccessDenied, because it refuses before any lookup. n_a_forged_credential_is_refused_and_runs_no_handler pins both answers.
  • SigV2 header plus a zip token. Legacy's access check returns on a token-carrying zip path before its SigV2 refusal. The gateway refuses the SigV2 header at the floor. The minted token-only URL and the SigV4-signed download are unchanged.

Merge with main after #1583 (merge commit, no rebase)

#1583 (d66fb5c47) made every claimed admin floor require SigV4 and added a SigV2 census. Two of its tests were written to flip with #1592, and this merge flips them deliberately:

Guard list updated deliberately

scripts/check_sig_test_target_consolidation.sh freezes the module list of the sig crate's one integration target. This PR adds tests/sigv2_refused_floor.rs to that target (tests/integration.rs), so the list gains sigv2_refused_floor, in the same order as integration.rs.

The first CI run on 88147f9e failed this guard in Static checks, and failed Guard self-test 5's "fails on the current tree" case. Both now pass locally:

  • the guard itself;
  • GATEWAY_GUARD_SHARD_GROUP=4 of 6: 340 ok, 0 FAIL;
  • all 142 repository guards in CI's loop;
  • the protected-files, corpus and role-verdict steps.

#1597 (STS handler receives the caller's secret) is unblocked by this change.

Tests

Tests were written first. Before the implementation, the sig tests did not compile against the missing API. With the floors still unchanged, 13 of 39 STS-parity and zip goldens failed, and every failure was a SigV2 row.

Where Negative Positive
crates/sig/tests/sigv2_refused_floor.rs 6 3
crates/sig/src/operation.rs unit tests 2 0
crates/core/src/registry/reject_floor_tests.rs 1 1
crates/goldens/src/operation_diff/sts_parity/sigv2.rs 2 2
crates/goldens/src/rustfs_admin_dialect/zip_sigv2_tests.rs 2 1
sec1 changes (sec1_14_v2_header, sec1_15_v2_presigned, sec1_16, sec1_17, sec1_25_v2_header) 5 0
Dialect floor declarations (sts_form, extensions zip pair: added assertions; census::the_zip_download_refuses_sigv2_without_requiring_sigv4) 3 0
Total 21 7

Coverage against the issue's acceptance list:

  • Every SigV2 shape is refused.
    • At the floor: header, presigned and POST policy, under HeaderOnly, HeaderAndPresigned and the RustFS-profile floor.
    • Through the service: header (true and forged secret) and presigned, on both AssumeRole and Web Identity, on the default and the RustFS-profile floor.
    • A SigV2 POST policy cannot reach the STS route, because the claim takes only a urlencoded body, so the floor suite covers that shape.
  • The refusal precedes credential lookup. A recording CredentialProvider sees no lookup for any SigV2 request. The control (HeadObject, no rule) shows the same recorder does see the SigV2 key, so it is not deaf. At the floor, an unreadable SigV2 signature is also refused AccessDenied, where the control without the rule reaches the parser.
  • Known and unknown keys get the identical answer (status, code, message, identifiers), with no lookup.
  • SigV4 header AssumeRole reaches the handler as the caller, after exactly one lookup. Anonymous Web Identity reaches it with no caller and no lookup.
  • Other direction: an operation without the rule (HeadObject, and an OIDC admin row) still admits SigV2 under the RustFS profile.
  • Registration: the SigV4-with-bearer contradiction is still refused when the SigV2 refusal is also declared. The SigV2 refusal beside a bearer claim, and beside the SigV4 requirement, registers.

Mutations

Every mutation was applied to the working tree, run with the crate's own filtered tests, and reverted. None was committed. All ran locally. None needs the full matrix, so no probe PR was opened.

# Mutation Ran Went red
M1 enforce_scheme_allowed ignores the new rule (reads requires_sigv4_… only) local: sig, goldens sig: 5 sigv2_refused_floor negatives + an_operation_refusing_sigv2_…. Goldens: all 13 SigV2 rows (sec1_14_v2_header, sec1_15_v2_presigned, sec1_16, sec1_17, sec1_25_v2_header, both sigv2::n_*, both zip_sigv2_tests::n_*, the 4 matrix tests)
M2 refuses_sigv2_… drops the SigV4 implication local: sig n_the_answer_equals_the_sigv4_requirements_answer, requiring_sigv4_implies_refusing_sigv2_in_either_order, an_operation_requiring_sigv4_refuses_sigv2_under_every_policy
M3 the setter also sets sigv4_required local: sig, goldens anonymous_bearer_and_custom_admissions_are_unchanged, an_operation_refusing_sigv2_… (!requires_sigv4); goldens: 39, because the registry then refuses the zip floor and every dialect assembly fails
M4 the refusal moved after the SigV2 credential parse local: sig n_the_refusal_precedes_the_sigv2_credential_parse, n_every_sigv2_shape_…, n_the_answer_equals_…, an_operation_refusing_sigv2_…
M5 rustfs:StsFormPost's floor without the rule local: dialect, goldens sts_form::the_sts_operation_declares_…; goldens: 11 STS SigV2 rows
M6 zip_token_floor without the rule (re-run after the merge with #1583) local: dialect, goldens extensions::the_zip_pair_declares_…, census::the_zip_download_refuses_sigv2_without_requiring_sigv4, both zip_sigv2_tests::n_*, #1583's sigv2_census_tests::n_a_sigv2_header_signature_on_every_claimed_admin_row_… (which now includes the zip rows)
M7 the legacy stand-in admits credentialed SigV2 local: goldens the 3 matrix tests that compare SigV2 with legacy
M8 the recording provider records nothing (a deaf spy) local: goldens sigv4_assume_role_and_unsigned_web_identity_…, an_operation_without_the_rule_still_admits_sigv2_and_looks_its_key_up
M9 the unknown-key rewrite is a no-op local: goldens n_a_known_and_an_unknown_sigv2_key_get_one_answer
M10 the registry refuses the SigV2 refusal beside a bearer claim local: core refusing_sigv2_registers_beside_a_bearer_claim_…
M11 the registry drops the SigV4-with-bearer refusal local: core n_a_sigv4_only_floor_with_a_bearer_claim_is_refused, n_refusing_sigv2_does_not_rescue_…
M12 the rule refuses every family, not only SigV2 local: sig, goldens sigv4_header_presigned_and_post_policy_are_still_admitted, anonymous_bearer_and_custom_…, requiring_sigv4_keeps_…; goldens: sigv2::sigv4_assume_role_…, sec1_14, sec1_15 and 11 more
M13 refuses_sigv2_… always true local: sig without_the_rule_the_same_operations_admit_sigv2, n_the_refusal_precedes_… (control half), n_a_sigv2_header_carrying_a_bearer_token_is_refused (control half), an_operation_refusing_sigv2_…

The two one-directional risks are covered:

  • The recorder could be deaf. M8 turns it deaf, and the SigV4 and HeadObject controls go red.
  • The control floors could refuse for their own reasons. M13 makes every floor refuse, and the controls go red.

Checklist

  • The four-command gate passed locally: cargo fmt --all --check, cargo clippy --workspace --all-targets -- -D warnings, cargo test --workspace, cargo xtask verify --crate for rustfs-gateway-sig, rustfs-gateway-core, rustfs-gateway-dialect-rustfs-admin and rustfs-gateway-goldens
  • New or changed public API has rustdoc
  • Negative test cases outnumber positive ones (21 / 7)
  • Every new assertion was mutated — see the table above
  • No Protected File touched (docs/adr/0050-* is a new ADR; no accepted ADR edited)
  • No unsafe introduced
  • No inventory / linkme introduced
  • No #[non_exhaustive] added to a DTO
  • No s3s code copied; behavioural evidence is URL + self-written summary (no RustFS code copied either)
  • No credentials, signatures, or expected-signature values reachable from logs or error bodies
  • No plan document, analysis report, or working note committed
  • Commits follow Conventional Commits; PR title ≤72 characters
  • A Handoff comment has been appended to the issue

Role Verdicts

  • simplicity-adversary: attacked the second bool beside sigv4_required (an enum would buy nothing: the only reader is the derived refuses_sigv2_under_every_policy, so the two fields cannot disagree), whether the new public predicate needs to be public (the dialect's floor-declaration tests read it across the crate boundary), the test-only visibility widening (tests::rows, lambda_tests::sigv2_header_signed, zip_token_tests::with_verifier, sec1's HeadObject fixtures; the first two are hunk-identical to fix(dialect-rustfs-admin): refuse SigV2 on claimed admin routes #1583), and the gateway_builder_on split, which has two callers — no break found
  • security-adversary: attacked refusal order (H6 duplicates and the H4 two-surface check run first and answer the same for every key; M4 shows a refusal placed after the parse is caught), known vs unknown and correct vs forged SigV2 answers (identical, no provider lookup, recorder proven live), every SigV2Policy and the legacy-recognition floor, a percent-encoded %53ignature key (count_query_param decodes keys, so it is recognized as SigV2 and refused), an unsigned AWSAccessKeyId query under legacy recognition (read as anonymous with no identity, so ordinary AssumeRole is still refused by the codec and nothing authenticates), custom-scheme laundering (only marker-less requests reach a scheme), a SigV2 header carrying a good zip token (refused at the floor, verifier never asked), and error bodies for key or signature echoes — no break found
  • test-adversary: attacked one-directional controls (the recorder could be deaf, M8; the controls could refuse for their own reasons, M13), whether each changed sec1 assertion fails on revert (M1, M5), the POST-policy shape (unreachable on the urlencoded STS claim, so it is covered at the floor in both directions), negative/positive balance (21/7), and determinism (the new tests use RequestNow::capture only where the existing suite does, and sign and send in the same instant) — no break found

Add OperationFloor::refuse_sigv2_under_every_policy, the narrower
sibling of require_sigv4_under_every_policy: every SigV2 shape is
refused AccessDenied at the floor, before its credential is parsed
or resolved, while SigV4, anonymous, bearer and custom-scheme
admissions are unchanged. SigV2 covers neither the form body that
carries every STS parameter nor most of the query, and RustFS's
router refuses it on these routes.

Apply it to rustfs:StsFormPost and admin::zip_token_floor, record
the decision in ADR-0050, and turn the goldens' SigV2 STS positives
into refusals, with the legacy stand-in now modelling RustFS's
SigV2 refusal.

Fixes #1592
Resolve against #1583: zip_token_floor keeps #1583's custom floor
without the SigV4 requirement and adds the SigV2 refusal, and the
two census tests written to flip with #1592 now pin the refusal.
@overtrue

Copy link
Copy Markdown
Collaborator Author

Role findings (gateway-adversarial, single session, author-run)

I ran these passes myself in the authoring session. They are advisory and do not replace the independent security review this PR needs before merge.

simplicity-adversary: no break found. Attacked:

  • The second bool (sigv2_refused) beside sigv4_required. A three-state enum was the alternative. Only the derived refuses_sigv2_under_every_policy reads the field, and the SigV4 requirement implies it, so the two fields cannot disagree. M2 and M13 show that predicate is load-bearing.
  • Whether the new predicate must be public. The dialect crate's floor-declaration tests read it across the crate boundary, as they read requires_sigv4_under_every_policy.
  • The test-only visibility widening:
  • gateway_builder_on. It has two callers, and gateway_builder delegates to it.
  • A one-element loop left by the census merge. Removed.

security-adversary: no break found. Attacked:

  • Refusal order. H6 duplicates and H4 two-surface ambiguity run first and answer the same for every key. The rule then runs at H3, before the SigV2 parse, the clock and the lookup. M4 moves it after the parse, and the precedence test goes red.
  • Answer uniformity. A known and an unknown key, and a correct and a forged signature, get one answer. The recording provider shows no lookup happens, and it is proven live by M8.
  • Every policy. Every SigV2Policy, and the legacy-recognition floor.
  • An encoded key. %53ignature: count_query_param percent-decodes keys, so it is recognized as SigV2 and refused.
  • An unsigned partial SigV2 query. Under legacy recognition it is read as anonymous with no identity. Ordinary AssumeRole stays refused by the codec, and Web Identity's handler sees no principal.
  • Custom-scheme laundering. Only marker-less requests reach a scheme.
  • A SigV2 header carrying a good zip token. It is refused at the floor, and the verifier is never asked.
  • Error bodies. No key or signature echo.

test-adversary: no break found. Attacked:

  • One-directional controls. M8 makes the recorder deaf, and M13 makes every floor refuse; the controls go red in both directions.
  • Revert sensitivity. M1 shows every changed sec1 and matrix assertion fails when the rule is reverted, and M5 shows it per dialect floor. M6 shows it for the zip floor, including fix(dialect-rustfs-admin): refuse SigV2 on claimed admin routes #1583's census rows, which now include the zip download.
  • The POST-policy shape. The urlencoded STS claim cannot receive one, so the floor suite covers it in both directions.
  • Negative/positive balance. 21 negative, 7 positive.
  • Determinism. RequestNow::capture is used as the existing STS suite uses it, signing and sending in the same instant. No ports, no map ordering.

What I would attack first as an independent reviewer:

  • crates/sig/src/floor.rs, enforce_scheme_allowed: is there any SigV2 path into the facade that does not pass through admit, for example a re-admission of the framed view?
  • crates/goldens/src/operation_diff/sts_parity.rs, signed_with_sigv2: the legacy stand-in now models RustFS 443e8afee rather than 95268a3b9. Check that it is a faithful reading.

Takes #1600 (ADR-0050, the metadata listings). This branch's ADR becomes
ADR-0051 (file, title, index row and every reference of this branch;
#1600's own ADR-0050 references are untouched).

Extends the SigV2 refusal to rustfs:ListObjectsV2WithMetadata and
rustfs:ListObjectVersionsWithMetadata through admin::listing_floor and
the generator's floor text. SigV2 does not sign `metadata`, so an
appended metadata=true turned a SigV2 listing into a user-metadata
disclosure. SigV4 and unsigned listings stay exactly as on the
standard listings, which keep admitting SigV2. This is a deliberate
difference from RustFS, which serves the SigV2 form (recorded in
ADR-0051).

Tests flipped deliberately:
- metadata_listing_tests: the three tests that pinned SigV2 presigned,
  appended-presigned and appended-header listings as admitted are now
  refusals (403 AccessDenied, no authorizer question) on both floors,
  plus a correctly signed SigV2 metadata listing, also refused;
- dialect metadata_listing: the floor equals the standard listing
  floor except refuses_sigv2, which the standard floors do not;
- #1583's SigV2 census includes the zip rows (670-8), its control keeps
  only the OIDC row, and the dialect census record becomes
  the_zip_download_refuses_sigv2_without_requiring_sigv4.

Also: the sig test-target guard lists sigv2_refused_floor. This fixes
both CI failures on 88147f9 (Static checks; Guard self-test 5,
"fails on the current tree"). The redundant zip assertions are dropped
from extensions.rs to keep it within 800 lines; census.rs keeps them.

Mutations, all run locally, each reverted, all red:
M1 floor ignores the rule; M2 predicate loses the SigV4 implication;
M3 setter also requires SigV4; M4 refusal after the credential parse;
M5 STS floor without the rule; M6 zip floor without the rule (census
included); M7 legacy stand-in admits SigV2; M8 deaf credential
recorder; M9 no-op unknown-key rewrite; M10 and M11 both registry
directions; M12 the rule refuses every family; M13 predicate always
true; M14 metadata listing floor without the rule.
Takes #1594 (ADR-0051, the credential-store fault answer); this
branch's record is ADR-0052. #1594 touches none of this branch's
lines: its security-model paragraph and STS body-digest change are
independent of the floor rule.
Address the independent security review of 828818f, which found no
bypass:

- ADR-0052: the mutation table is in #1592's Handoff comment, not in
  a pull request. "Always 403 AccessDenied" is corrected in the ADR
  and the rustdoc: the floor's H6 and H4 rules, and the RustFS
  profile's header-signature view policy (a missing or unreadable
  SigV2 date answers 400 InvalidRequest, a skewed one 403
  RequestTimeTooSkewed), answer first, and none of them reads the key
  or the signature.
- sec1_25_v2_header sends a Web Identity body, so a floor that read
  the filtered view would answer 200. sec1_10 asserts that no
  authorizer question was asked, and its doc and sec1_24_v2_query's
  now say what they pin since the floor refuses first. sec1_16/17
  state their shared property, and ADR-0044's claimed-route SigV2
  canonical resource is pinned on an OIDC row on a virtual host
  under RustFS addressing (sigv2_census_tests).
- metadata_listing_tests: a standard-listing control shows the
  file's SigV2 signers produce requests served as the signer. The
  "versions added" and "forged" rows move to the standard listings,
  where SignatureDoesNotMatch can still be observed.
- The review's odd spellings are pinned to "no principal reaches the
  handler":
  - %53ignature and every name encoded: through the service the query
    reader answers 400 first, and the sig floor test shows the floor
    refuses them as SigV2;
  - AWSAccessKeyId alone: anonymous with no principal under the
    profile, refused under the default floor;
  - a lowercase `aws` scheme: refused, no principal;
  - the zip SigV2 header with a token (verifier never asked) was
    already pinned.
- A new test sends a bad SigV2 date through the STS assembly, which
  has the view policy on, and checks known and unknown keys get the
  same answer.

Mutations, all local, each reverted: M15-M22 (see the Handoff comment
on #1592 for the table).
Takes #1605, a one-line rustdoc link fix in crates/gateway/src/close.rs
that touches none of this branch's files.
Joins b75e2bd, which merged #1594 into this branch remotely and
renumbered this branch's record to ADR-0052, the same resolution as
e1ca635/b953ca9f. Its only differences in the two conflicting files
were those renumbers, which this side already has, so the merged tree
equals 2dc9061's.
@overtrue
overtrue marked this pull request as ready for review November 11, 2026 11:04
@overtrue
overtrue merged commit ca70101 into main Nov 11, 2026
32 of 33 checks passed
@overtrue
overtrue deleted the p9/1592-sts-refuse-sigv2 branch November 11, 2026 11:04
overtrue added a commit that referenced this pull request Nov 11, 2026
main gained ADR-0052 with #1603, so the record of this change
moves to the next free number: its file, title, index row and every
reference to it. The sig test-target inventory keeps both sides' modules.
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.

rustfs:StsFormPost admits SigV2 where RustFS refuses it

1 participant