Skip to content

tiny-oss/aws: keys containing ( ) ! or ' fail with SignatureDoesNotMatch #85

Open

Description

@bompus

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

  1. github-actions commented on Oct 30, 2026

    @github-actions
    Contributor

    Confirmed — thanks, this is a precise report and it reproduces against the repo's own oracle dependency (aws-sdk@2.1693.0 is a devDependency and test/aws-signature.spec.ts already 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:

    var output = encodeURIComponent(string);
    output = output.replace(/[^A-Za-z0-9_.~\-%]+/g, escape);   // ! ' ( ) and friends
    output = output.replace(/[*]/g, function (ch) { ... });    // %2A

    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) and tosUriEscape (TOS) already replace all five characters.

    Verified against AWS.util.uriEscape with your case list, and end to end against the Authorization header the SDK produces for the same putObject:

    "photo (1).jpg"  AWS: photo%20%281%29.jpg   ours: photo%20(1).jpg
    "a!b.txt"        AWS: a%21b.txt             ours: a!b.txt
    "o'brien.txt"    AWS: o%27brien.txt         ours: o'brien.txt
    "a*b.txt"        AWS: a%2Ab.txt             ours: a%2Ab.txt      (already equal)
    

    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()) against AWS.util.uriEscape over every code point U+0001–U+024F and 5,000 random strings: zero mismatches. Applied locally it also makes awsSignUrl byte-identical to s3.getSignedUrl('getObject') for a key and a response-content-disposition value containing these characters, so one helper covers header-signed requests and pre-signed URLs (both go through awsUriEscapePath / 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.

  2. added
    ready-for-humanRequires a human: implementation, merge of a loop PR, or a release call
    and removed
    ready-for-agentFully specified, an AFK agent may handle it unattended
    on Oct 30, 2026
  3. github-actions commented on Oct 30, 2026

    @github-actions
    Contributor

    Loop feature — fixed and ready for review. (fix(aws): percent-encode ! ' ( ) in SigV4 URI escaping, branch loop/85-aws-sigv4-uri-escape)

    awsUriEscape (src/aws/signature.ts) now performs the step it was missing:

    return encodeURIComponent(str).replace(
      /[!'()*]/g,
      (c) => '%' + c.charCodeAt(0).toString(16).toUpperCase(),
    )

    which is byte-identical to aws-sdk v2 AWS.util.uriEscape. Your reproduction now emits and signs /photo%20%281%29.jpg:

    URL: https://examplebucket.s3.amazonaws.com/photo%20%281%29.jpg
    Authorization: AWS4-HMAC-SHA256 Credential=AKIDEXAMPLE/…/s3/aws4_request,
                   SignedHeaders=content-md5;host;x-amz-content-sha256;x-amz-date, Signature=19803d04…
    

    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 getAwsSignature case with key photo (1) 你好's!.txt, two awsSignUrl cases (that key, and a response-content-disposition value containing ' and spaces — so the query-name/value half is covered too), a unit case for a!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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    ready-for-humanRequires a human: implementation, merge of a loop PR, or a release call

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions