Repository navigation
Description
awsUriEscape in src/aws/signature.ts is encodeURIComponent(str) plus * → %2A. encodeURIComponent leaves ! ' ( ) as they are, but SigV4 requires every byte outside A-Z a-z 0-9 - . _ ~ to be percent-encoded, so these four must become %21 %27 %28 %29. The signature is computed over the literal characters, and the server computes it over the encoded ones.
Reproduction (tiny-oss a0e9b54, no network):
import { put, setTransport } from 'tiny-oss/aws'
setTransport(async (url, opts) => {
console.log(url) // https://examplebucket.s3.amazonaws.com/photo%20(1).jpg
return { status: 200, headers: {}, data: '', statusText: 'OK' }
})
await put({ accessKeyId: 'AKIDEXAMPLE', accessKeySecret: 'SECRETKEY', region: 'us-east-1', bucket: 'examplebucket' }, 'photo (1).jpg', 'hello')The path is signed as /photo%20(1).jpg. SigV4 wants /photo%20%281%29.jpg.
I checked how a service answers, with signed read-only GET requests to Cloudflare R2 (a bucket that does not exist, so each ends in an error, but the signature is checked first):
| URL path sent | path signed as | R2's answer |
|---|---|---|
/photo%20(1).jpg |
/photo%20%281%29.jpg |
past the signature check (NoSuchBucket) |
/photo%20(1).jpg |
/photo%20(1).jpg (what tiny-oss signs) |
SignatureDoesNotMatch |
/a!b.txt |
/a%21b.txt |
past the signature check |
/a!b.txt |
/a!b.txt (what tiny-oss signs) |
SignatureDoesNotMatch |
I have not tried AWS S3 itself. The same rule is in the AWS SigV4 documentation, and botocore (1.40.72) encodes these characters.
I also ran tiny-oss's signer over 1,097 requests that botocore signed (unsigned payload, no session token, query names unique). 569 differ; 538 of those contain one of these four characters in the key, a query name or a query value. Changing awsUriEscape to
return encodeURIComponent(str).replace(/[!'()*]/g, (c) => '%' + c.charCodeAt(0).toString(16).toUpperCase())removes all 538. (The remaining 31 are a separate query-ordering difference; see the next issue.)
Disclosure: the 3,360 test requests come from a SigV4 library I maintain (sigflint). I'm not suggesting any change of dependency; this is only a bug report.
Activity
github-actions commentedon Oct 30, 2026
Confirmed — thanks, this is a precise report and it reproduces against the repo's own oracle dependency (
aws-sdk@2.1693.0is a devDependency andtest/aws-signature.spec.tsalready uses it as the oracle).Root cause.
awsUriEscape(src/aws/signature.ts:28) implements only the second half of the official aws-sdk v2 rule.AWS.util.uriEscape(lib/util.js:39) is:tiny-oss has the
*step but not the RFC 3986 step, so! ' ( )stay literal — and the comment above the function claims it mirrors the SDK. It is also the only signer with the gap:camSafeUrlEncode(COS) andtosUriEscape(TOS) already replace all five characters.Verified against
AWS.util.uriEscapewith your case list, and end to end against the Authorization header the SDK produces for the sameputObject:The plain, space and non-ASCII cases already agree, which is why nothing caught it: the oracle spec passes 17/17 at HEAD because its case list contains no key or query value with one of the four characters.
Your fix is exactly the SDK rule. I fuzzed
encodeURIComponent(str).replace(/[!'()*]/g, c => '%' + c.charCodeAt(0).toString(16).toUpperCase())againstAWS.util.uriEscapeover every code point U+0001–U+024F and 5,000 random strings: zero mismatches. Applied locally it also makesawsSignUrlbyte-identical tos3.getSignedUrl('getObject')for a key and aresponse-content-dispositionvalue containing these characters, so one helper covers header-signed requests and pre-signed URLs (both go throughawsUriEscapePath/canonicalQueryString).Accepted as a bug and queued for implementation, with oracle cases for
( ) ! 'in the key and in a query value so it cannot regress. The query-ordering difference you mention is out of scope here — it needs its own issue/oracle investigation.Disclosure noted and not a problem; a bug report does not oblige us to take a dependency.
github-actions commentedon Oct 30, 2026
Loop feature — fixed and ready for review. (
fix(aws): percent-encode ! ' ( ) in SigV4 URI escaping, branchloop/85-aws-sigv4-uri-escape)awsUriEscape(src/aws/signature.ts) now performs the step it was missing:which is byte-identical to aws-sdk v2
AWS.util.uriEscape. Your reproduction now emits and signs/photo%20%281%29.jpg:Because both the signature and the request URL are built from the same helper, the path on the wire and the canonical URI stay in lockstep, exactly as in the official SDK.
Pinned against the existing aws-sdk v2 oracle so it cannot regress: a
getAwsSignaturecase with keyphoto (1) 你好's!.txt, twoawsSignUrlcases (that key, and aresponse-content-dispositionvalue containing'and spaces — so the query-name/value half is covered too), a unit case fora!b'c(d)e*f, and one assertion on the URL the transport actually receives. All five fail with the old helper and pass now.Thanks for the precise report, the R2 matrix and the botocore comparison — root cause and fix landed exactly as you described. The remaining 31/1,097 query-ordering differences you mention are still out of scope here and need their own issue/oracle investigation.