Skip to content

fix(shared): return a value that fails to percent-decode as-is - #160

Merged
dinwwwh merged 2 commits into
mainfrom
claude/eager-wozniak-os1gun
Oct 13, 2026
Merged

dinwwwh merged 2 commits into
mainfrom
claude/eager-wozniak-os1gun

Conversation

@dinwwwh

@dinwwwh dinwwwh commented Oct 13, 2026

Copy link
Copy Markdown
Member

A hostile Content-Disposition filename*, such as %FFa repeated, made safeDecodeURIComponent throw and catch one URIError for every 4 bytes. After the whole value failed to decode, it retried each run of %XX escapes on its own. Unauthenticated clients could use this to burn CPU:

  • ~14 ms per 16 KB header on fetch, even for a plain application/json request;
  • ~0.8 s for a 1 MB peer message, which has no header size limit.

Fixes

  • safeDecodeURIComponent returns a value that fails to decode as-is, so it throws at most once. decodeURIComponent stops at the first malformed escape, so the cost no longer grows with the number of escapes.
  • resolveStandardBodyHint parses content-disposition only when no other header decides the hint. JSON, form-data, url-search-params and event-stream requests, and requests with a content-length, no longer parse it at all.

Behaviour change

This undoes the per-run fallback from #133. One malformed escape now keeps the whole value encoded:

  • filename*=utf-8''%E4%B8%AD%ZZ.txt gives %E4%B8%AD%ZZ.txt, not 中%ZZ.txt;
  • invalid%20value% stays invalid%20value%.

Well-formed values decode as before. Only senders that don't encode % as %25 are affected. Code outside the repo that uses safeDecodeURIComponent from @standard-server/shared gets the behaviour from before #133 back.

Performance

%FFa repeated in filename* Before After
fetch, application/json, 16 KB header 14 ms 0.24 ms
peer, binary message, 1 MB header 808 ms 2 ms

Not covered here: the Content-Disposition parameter scan is linear but costs ~58 ms for a 1 MiB header made of many short parameters (a;a;…), both before and after this change. It only matters on peer, which has no header size limit, and can be a follow-up.

Testing

  • shared: a value that fails to decode comes back unchanged, including truncated, overlong, surrogate and above-U+10FFFF UTF-8. A ~1 MB %FFa value calls decodeURIComponent once; it was 250,001 times before.
  • core: with a hostile content-disposition, resolveStandardBodyHint never decodes it for json, form-data, url-search-params, event-stream, an explicit hint, none or a content-length. It still returns file when the filename alone decides. The getFilenameFromContentDisposition expectations from fix(shared): keep valid escapes decoded when percent-decoding malformed input #133 now expect the raw value.
  • peer: a binary message with a 1 MiB %FFa filename resolves to a File with that name, with one decode call.
  • The new tests fail on main. vitest run for shared, core, fetch, node, fastify, aws-lambda and peer (800 passed) and pnpm run check are clean.
  • One e2e test, "event stream with error event in parallel", failed once in a full run and passed on re-runs. It doesn't touch this code.

🤖 Generated with Claude Code

https://claude.ai/code/session_01KkyGkYHZGmXB18XUR6fFbF


Generated by Claude Code

A `content-disposition` with a hostile `filename*`, such as `%FFa`
repeated, made `safeDecodeURIComponent` throw and catch one `URIError`
per 4 bytes: after the whole value failed to decode, each run of `%XX`
escapes was retried on its own. That cost ~14 ms per 16 KB header on
fetch, even for a plain `application/json` request, and ~0.8 s for a
1 MB peer message, which has no header size limit.

- `safeDecodeURIComponent` returns a value that fails to decode as-is
  again, so it throws at most once. `decodeURIComponent` stops at the
  first malformed escape, so this is cheap whatever the input.
- `resolveStandardBodyHint` parses `content-disposition` only when no
  other header decides the hint, so json, form-data, url-search-params,
  event-stream and sized bodies never decode the filename.

## Behaviour change

This undoes the per-run fallback from 60d865e. One malformed escape now
keeps the whole filename encoded: `filename*=utf-8''%E4%B8%AD%ZZ.txt`
gives `%E4%B8%AD%ZZ.txt` instead of `中%ZZ.txt`. Well-formed values
decode as before. Only senders that do not encode `%` as `%25` are
affected.

## Performance

| Hostile input | Before | After |
|---|---|---|
| fetch, `application/json`, 16 KB header | 14 ms | 0.24 ms |
| peer, binary message, 1 MB header | 808 ms | 2 ms |

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01KkyGkYHZGmXB18XUR6fFbF
- Remove comments that restate a test name or the assertion below them,
  including the two source comments the new tests pin.
- Check the content types that skip the filename decode in one loop
  instead of seven near-identical blocks.
- Restore mocks in one top-level `afterEach` in the core utils tests,
  next to the existing `beforeEach`.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01KkyGkYHZGmXB18XUR6fFbF
@pkg-pr-new

pkg-pr-new Bot commented Oct 13, 2026

Copy link
Copy Markdown
@standard-server/aws-lambda

npm i https://pkg.pr.new/@standard-server/aws-lambda@160

@standard-server/core

npm i https://pkg.pr.new/@standard-server/core@160

@standard-server/fastify

npm i https://pkg.pr.new/@standard-server/fastify@160

@standard-server/fetch

npm i https://pkg.pr.new/@standard-server/fetch@160

@standard-server/node

npm i https://pkg.pr.new/@standard-server/node@160

@standard-server/peer

npm i https://pkg.pr.new/@standard-server/peer@160

@standard-server/shared

npm i https://pkg.pr.new/@standard-server/shared@160

commit: 0476690

@codecov

codecov Bot commented Oct 13, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

@codspeed

codspeed Bot commented Oct 13, 2026

Copy link
Copy Markdown
Contributor

Merging this PR will not alter performance

⚠️ 5 benchmarks spent significant time in system calls

System calls cannot be consistently instrumented, so they are not included in the measure, which understates the real cost. Please switch to the Walltime instrument to accurately measure system calls.

Measurement and system calls

ℹ️ 15 benchmarks have no flame graph

The flamegraph could not be generated because too much time was spent in runtime internals, making the visualization not useful for performance analysis.

✅ 26 untouched benchmarks
⏩ 108 skipped benchmarks1


Comparing claude/eager-wozniak-os1gun (0476690) with main (28b8518)

Open in CodSpeed

Footnotes

  1. 108 benchmarks were skipped, so the baseline results were used instead. If they were deleted from the codebase, click here and archive them to remove them from the performance reports. ↩

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ No new issues found.

Reviewed changes

  • safeDecodeURIComponent is now all-or-nothing — it calls decodeURIComponent at most once and returns the raw value on URIError, reverting the per-run partial-decode fallback from #133 and removing PERCENT_ESCAPES_REGEX. This caps a hostile %FFa payload at one decode attempt instead of one throw per escape run.
  • resolveStandardBodyHint parses content-disposition lazily — only when no explicit standard-server hint, none, known media type, or content-length decides the hint first. The reordering is behavior-preserving for the returned hint (content-length still wins for file).
  • Tests — shared/core/peer tests spy on globalThis.decodeURIComponent to assert a single decode on hostile input and zero decodes on json/form-data/url-search-params/event-stream/explicit-hint/none/content-length paths, plus raw passthrough of malformed filename*.

I traced the behavior change through all consumers of getFilenameFromContentDisposition (fetch, node, aws-lambda, peer): each only reads it on the file path, so no non-file body now decodes a hostile header, and malformed names come back percent-encoded rather than partially decoded. The generateContentDisposition → getFilenameFromContentDisposition round-trip is unaffected since generateContentDisposition always emits well-formed %25 output, and there is no header-injection regression because re-encoding percent-escapes % and the ASCII fallback strips CR/LF.

The remaining linear getContentDispositionParam scan (≈58 ms for a 1 MiB header of many short parameters) is pre-existing and explicitly flagged as a follow-up in the PR body.

Pullfrog  | View workflow run | Using deepseek-v4.1-flash (free via Pullfrog for OSS) | 𝕏

@dinwwwh
dinwwwh merged commit 1512690 into main Oct 13, 2026
11 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.

2 participants