R2: re-sign every retry, bound every attempt, and give the signer a test bed - #822
Merged
Merged
Conversation
…est bed
The SigV4 audit's remaining findings against .github/scripts/{download,upload}-r2-object.sh.
F2 -- retries reused the signature, and nothing bounded an attempt.
`amz_date` was computed once and curl owned the retry (`--retry 5
--retry-all-errors`). curl replays the argv it was handed, so all six attempts
carried the SAME x-amz-date and the SAME Authorization: a retry that cannot
re-sign. R2 only accepts a signature inside its ~15 minute clock-skew window,
so a run that spends long enough retrying turns a transient 503 into a
permanent-looking 403 RequestTimeTooSkewed. With no --max-time/--connect-timeout
a peer that accepts the connection and then sends nothing triggered neither a
retry nor a timeout: the transfer "succeeded" with an empty body, or the job
hung to its own 30-minute limit with no diagnosis.
The transfer is now a bash loop. Every attempt calls sign_request(), which
recomputes amz_date, date_stamp, the canonical request, the string-to-sign and
the signature, and every attempt carries --connect-timeout 30 --max-time 600.
Six attempts, as before -- what changed is WHO retries. The --output file is
truncated at the top of each iteration, because curl opens it lazily and a
connect-time failure never truncates it, so a 503 <Error> document could
otherwise sit in front of the object the next attempt writes. The
status-gated error-body reporting from #810 is unchanged.
F4 -- the upload payload hash broke on exotic paths.
`shasum -a 256 "${input_path}"` escapes the filename and prefixes the digest
with a backslash when the path contains a backslash or a newline:
$ shasum -a 256 'back\slash.json' | awk '{print $1}'
\b7367c22dfc669fdf6f9fcdb91112e6aee109312a2fe68a1508b00dba48cc9cb (65 chars)
That 65-character non-hex value went into BOTH the signed canonical request
and the x-amz-content-sha256 header -- self-consistent and still rejected. A
path starting with '-' was parsed as options. Now `shasum -a 256 < "${path}"`,
which has no filename in its output at all.
F5 -- --location removed from both scripts.
Measured against curl 8.7.1 with two local servers: on a cross-host redirect
curl DROPS the custom Authorization header but FORWARDS x-amz-date and
x-amz-content-sha256, so the follow-up arrives unsigned with the signing
headers still attached; on a same-host redirect it re-sends a signature bound
to the OLD path. R2 path-style issues no legitimate redirects, so following
one could only convert a loud failure into a confusing one.
F3 -- the object key charset is now an enforced invariant.
Nothing percent-encodes: the same raw bytes are signed and handed to curl. For
the keys in use ([A-Za-z0-9._/-]) identity encoding is correct; outside it a
space is curl exit 3, '#' truncates at the fragment, '?' becomes a query
string the canonical request signs as empty, and non-ASCII is percent-encoded
on the wire after being signed raw. Deliberately NOT an RFC 3986 encoder --
the aws-cli branch encodes by botocore's rules and a second, disagreeing
encoder would be a new divergence. object_path is validated against
^[A-Za-z0-9._/-]+$ next to the existing checks instead.
F6 -- the signed path now has an execution environment.
download_with_aws_cli() is silently PREFERRED, so the bash signer only ran on
boxes without `aws` -- which is how two signing bugs shipped undetected, and
why M5-C's first ever execution of it was a production hidden-golden fetch.
R2_FORCE_SIGNED=1 skips the fallback. A comment at each call site records the
other half of the problem: if `aws` is present but FAILS the script falls
through to the signed path silently, so which implementation performed a given
transfer varies run to run and only the banner line records it.
Tests
-----
Tests/MLXFastTests/R2RequestExecutionTests.swift (new, 12 tests) executes the
real scripts with stub curl/date/aws in front of M5-C's minimal PATH and
asserts on the argv curl was actually handed, attempt by attempt. The stub
curl honours --retry in-process, replaying its argv, so the pre-fix script
still produces six attempts here -- with identical headers.
R2SignatureTests gains the two gaps the audit named: nothing asserted the
string_to_sign CONSTRUCTION (the CR test stops at the hash, the HMAC test
starts from a hand-written string-to-sign ending in `deadbeef`), and nothing
composed CR -> hash -> STS -> signature into one pinned end-to-end signature,
so a mismatch BETWEEN links passed every per-link pin. The string-to-sign test
is pinned against the AWS documentation's worked example (its published
CanonicalRequest, StringToSign and Signature, reproduced byte for byte); the
end-to-end test is pinned against botocore's canonical-request hashes and
signatures independently recomputed with Python's hmac.
Attacked: reintroducing each defect fires the intended test and nothing else
silently passes. The between-link case (credential_scope advertising a region
that is not the one keyed into k_region) passes ALL pre-existing coverage and
is caught only by the two tests this commit adds.
swift test: 549 tests / 25 suites pass (535 / 24 on the parent commit).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
anupsv
added a commit
that referenced
this pull request
Jul 31, 2026
I had stripped the leading "gautham-experiments/" segment on the assumption it was the bucket name already carried by R2_BUCKET_ENDPOINT, because the serial keys are written without a bucket segment. That was me second-guessing the operator, who said where the objects are. Use the path as given: gautham-experiments/correctness_prompts/laguna-xs-2.1-dflash/dflash_correctness_golden_hidden.json gautham-experiments/correctness_prompts/laguna-xs-2.1-dflash/dflash_benchmark_golden_hidden.json Both keys pass the object-key charset guard added in #822 (^[A-Za-z0-9._/-]+$), asserted at pin time rather than discovered on the box: a key outside that set is refused before any request is signed, and a leading segment does not change the canonical path construction. If this key is wrong the failure is unambiguous rather than mysterious: the download helper reports the HTTP status and the R2 error code separately, so 404 NoSuchKey means the key is wrong and 403 means the bucket or the credentials' scope is. swift test: 564 tests, 26 suites, green. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
anupsv
added a commit
that referenced
this pull request
Jul 31, 2026
The operator uploaded to gautham-experiments/correctness_prompts/laguna-xs-2.1-dflash/ and the pinned keys omit the leading segment, so the fetch would fail 404 NoSuchKey after a full dispatch. This is the second time the prefix has been lost. First I stripped it myself, assuming "gautham-experiments" was the bucket already carried by R2_BUCKET_ENDPOINT -- the serial keys are written with no bucket segment, which made the assumption feel safe, and it was still me overriding what the operator had told me. Then the commit that corrected it (18ccd58) missed the merge of #824, which went in from the preceding commit, so main kept the wrong key. #825 did not touch paths. Fixed in both places -- the correctness step's env and the timed_prompt_pool entry -- and pinned by DFlashGoldenKeyTests so a third loss fails in CI rather than 30-40 minutes into a ranked dispatch. The test also asserts each key stays inside the signer's charset guard (^[A-Za-z0-9._/-]+$ from #822), because that guard refuses a key BEFORE signing and would otherwise turn a typo into a confusing dispatch-time abort. If the objects genuinely move, change that test in the same commit that moves them. swift test: 582 tests, 28 suites, green. Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Lands the SigV4 audit's remaining findings against
.github/scripts/download-r2-object.shand.github/scripts/upload-r2-object.sh.Base note. The task said to branch off
fix/r2-sigv4-canonical-requestso asnot to revert #818's fix. #818 landed on
mainascfc644bbefore this workstarted, so this branches off
main(3e9f072), which already contains it — thecanonical-request construction is carried forward unchanged (it just moved inside
sign_request()).F2 — retries reused the signature, and nothing bounded an attempt
amz_datewas computed once and curl owned the retry (--retry 5 --retry-all-errors). curl replays the argv it was handed, so all six attemptscarried the same
x-amz-dateand the sameAuthorization: a retry thatcannot re-sign. R2 only accepts a signature inside its ~15 minute clock-skew
window, so a run that spends long enough retrying converts a transient 503 into a
permanent-looking
403 RequestTimeTooSkewed.With no
--max-time/--connect-timeout, a peer that accepts the connection andthen sends nothing triggered neither a retry nor a timeout: the transfer
"succeeded" with an empty body, or the job hung to its own 30-minute limit with no
diagnosis.
The transfer is now a bash loop:
sign_request(), which recomputesamz_date,date_stamp, the canonical request, the string-to-sign and the signature;--connect-timeout 30 --max-time 600;R2_RETRY_DELAY_SECONDS(default 2, validated as a non-negative integer) letsa test exercise the loop's shape without its wall clock;
--outputfile is truncated at the top of each iteration. curl opens itlazily, so an attempt that dies at connect time never truncates it and a 503
<Error>document could otherwise sit in front of the object the next attemptwrites. The caller verifies a pinned sha256 and would report a golden mismatch
for a transport artefact.
The status-gated error-body reporting from #810 is unchanged.
F4 — the upload payload hash broke on exotic paths
shasum -a 256 "${input_path}"escapes the filename and prefixes the digestwith a backslash when the path contains a backslash or a newline. Measured:
That 65-character non-hex value went into both the signed canonical request
and the
x-amz-content-sha256header — self-consistent, and still rejected,because R2 compares it against the body it received. A path starting with
-wasparsed as options (
Unknown option: d). Nowshasum -a 256 < "${input_path}",which has no filename in its output at all.
F5 —
--locationremoved from both scriptsVerified against real curl 8.7.1 (macOS system curl, what the runners use) with
two local servers:
authorizationx-amz-datex-amz-content-sha256So a cross-host redirect delivers an unsigned request that still carries the
signing headers, and a same-host redirect delivers a signature for the wrong
path. R2 path-style issues no legitimate redirects, so following one could only
convert a loud failure into a confusing one.
F3 — the object key charset is now an enforced invariant
Nothing percent-encodes anywhere: the same raw bytes are signed into the canonical
request and handed to curl. For the keys actually in use (
[A-Za-z0-9._/-])identity encoding is exactly right. Outside that set it is not, and every way it
breaks is quiet: a space is curl exit 3,
#truncates the URL at the fragment,?turns the tail into a query string that the canonical request signs as empty,and non-ASCII is percent-encoded on the wire after being signed raw.
Deliberately not an RFC 3986 encoder — the aws-cli branch encodes by
botocore's rules, and a second encoder here that disagreed with it would be a
fresh divergence between the two branches for the same key.
object_pathisvalidated against
^[A-Za-z0-9._/-]+$next to the existing empty/absolute/dot-segment/control-character checks, so an unsupported key is a clear refusal before
any request instead of a 403 that reads like a credentials fault.
A companion test reads the literal
*_R2_PATHkeys out ofbenchmark.ymlanddflash-benchmark.ymland runs each one through the script, so a future key thecharset would reject fails in CI rather than on the runner.
F6 — the signed path now has an execution environment
download_with_aws_cliis silently preferred, so the bash signer only everran on boxes without
aws. That is exactly how two signing bugs shippedundetected, and why M5-C's first ever execution of this signer was a production
hidden-golden fetch.
R2_FORCE_SIGNED=1skips the fallback.A comment at each call site records the other half of the problem: if
awsispresent but fails, the script falls through to the signed path silently — so
which implementation performed a given transfer varies run to run, and the only
record is the banner line each branch prints.
Tests
Tests/MLXFastTests/R2RequestExecutionTests.swift(new, 12 tests) executes thereal scripts, unmodified, with stub
curl/date/awsfirst on M5-C's minimalPATH (
/usr/bin:/bin:/usr/sbin:/sbin), and asserts on the argv curl was actuallyhanded, attempt by attempt. The stub curl honours
--retryin-process, replayingits argv, so the pre-fix script still produces six attempts here — with identical
headers. The stub
dateadvances 7 minutes per SigV4 clock read, so two attemptsstraddle R2's skew window.
R2SignatureTestsgains the two gaps the audit named:string_to_signCONSTRUCTION. The canonical-requesttest stops at the CR hash and the HMAC test starts from a hand-written
string-to-sign whose last line is the literal
deadbeef, so the line thatassembles them could lose a newline, swap the date and the scope, or
interpolate
canonical_requestwhere it meanscanonical_request_hash, andevery existing assertion still passed. Pinned against the AWS SigV4
documentation's worked example — its published CanonicalRequest, StringToSign
and Signature, reproduced byte for byte.
separately, so a mismatch between links passes all of them. One pinned
end-to-end signature per script now runs the shipped chain start to finish over
R2-shaped inputs.
Both new tests splice the script's own assignment lines and
hmac_hex()definition into a shell, so they track the shipped construction rather than a
restatement of it. Every pinned value was recomputed independently with Python's
hmac/hashlibbefore being trusted.swift test: 549 tests / 25 suites pass (535 / 24 on the parent commit).Attack
Each fix was reverted in turn, with the tests left alone:
everyRetryIsSignedAfresh,everyUploadRetryIsSignedAfresh--retry 5 --retry-all-errors)noAttemptDelegatesRetryToCurl,theOutputFileIsTruncatedBetweenAttempts--connect-timeout/--max-timeeveryAttemptBoundsConnectAndTotalTime,aStalledAttemptTimesOutAndIsRetriedRatherThanSucceedingEmpty--outputnot truncated between attemptstheOutputFileIsTruncatedBetweenAttempts,everyRetryIsSignedAfreshshasum -a 256 "${path}"restoredthePayloadHashIsTheContentDigestForExoticInputPaths--locationrestorednoAttemptFollowsRedirectsunsignableObjectKeysAreRefusedBeforeAnyRequestR2_FORCE_SIGNEDremovedforceSignedBypassesAWorkingAWSCLIstring_to_signswaps date and scopetheStringToSignMatchesTheAWSDocumentationWorkedExample,theWholeSigningChainReproducesOnePinnedEndToEndSignaturestring_to_signsigns the request, not its hashk_regiontheWholeSigningChainReproducesOnePinnedEndToEndSignature,theEmittedRequestMatchesWhatWasSignedThe last row is the one worth reading. It is a defect between links, and under
it all three pre-existing tests still pass:
Only the tests this PR adds catch it.
Honest scope note. One assertion in this PR is text-based rather than
executed:
R2SignatureTests.noOpensslDigestParsesTheTextOutputFormat(pre-existing, unchanged). Everything else runs the real scripts or splices their real
lines into a shell.
noAttemptFollowsRedirectsasserts on the argv the realscript handed curl — the consequence of
--locationwas measured separatelywith the two-server probe above, not asserted in CI, because reproducing a
cross-host redirect inside the test suite would need two listening hosts.
🤖 Generated with Claude Code
Need help on this PR? Tag
@codesmithwith what you need. Autofix is disabled.