Skip to content

fix(shared): keep valid escapes decoded when percent-decoding malformed input - #133

Merged
dinwwwh merged 4 commits into
mainfrom
claude/fervent-dijkstra-sx43sq
Sep 29, 2026
Merged

dinwwwh merged 4 commits into
mainfrom
claude/fervent-dijkstra-sx43sq

Conversation

@dinwwwh

@dinwwwh dinwwwh commented Sep 29, 2026 •

Copy link
Copy Markdown
Member

getFilenameFromContentDisposition now decodes the valid escapes in a malformed filename*. Before, safeDecodeURIComponent returned the whole input unchanged as soon as decodeURIComponent threw. So one bad escape left every other escape encoded: filename*=utf-8''%E4%B8%AD%ZZ.txt came back as %E4%B8%AD%ZZ.txt instead of 中%ZZ.txt.

Fixes

  • When decoding the whole value throws, each run of %XX escapes is decoded separately. Runs that still fail are kept as-is. This is the same fallback as Hono's tryDecode.
  • A run that is not valid UTF-8 as a whole stays encoded: %E4%B8%AD%FF is unchanged.
  • safeDecodeURIComponent no longer returns malformed input unchanged. Its only caller in this repo is getFilenameFromContentDisposition. Code outside the repo that uses @standard-server/shared and relies on the old behaviour will now get partly decoded output.

Performance

  • Valid input still goes through a single decodeURIComponent call, so there is no extra cost.
  • Malformed input pays for the fallback, with one caught error per run that fails to decode. %E4%B8%AD%ZZ.txt takes about 3.8 µs. A crafted 16 KB Content-Disposition header made of thousands of invalid runs takes about 13 ms, compared with about 2 µs before.

Testing

  • The safeDecodeURIComponent tests cover:
    • a trailing %, a truncated sequence and a non-hex escape;
    • lowercase hex, and an invalid run next to a valid one;
    • a run that is not valid UTF-8 as a whole;
    • no double decoding: %2541%ZZ gives %41%ZZ.
  • The getFilenameFromContentDisposition tests cover a malformed filename* with and without a charset prefix.
  • The new tests fail against main. vitest run (1311 passed), eslint and the repo-wide type check are clean.

🤖 Generated with Claude Code

https://claude.ai/code/session_014w3GkqAVAgbiC1X6JcXr4e

…ed input

`safeDecodeURIComponent` returned the whole input unchanged as soon as
`decodeURIComponent` threw, so a single bad escape left every other escape
encoded, e.g. `filename*=utf-8''%E4%B8%AD%ZZ.txt` parsed as `%E4%B8%AD%ZZ.txt`.

Fall back to decoding each run of `%XX` escapes on its own and keep the runs
that still fail as-is, like Hono's `tryDecode`. The filename above now parses
as `中%ZZ.txt`.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014w3GkqAVAgbiC1X6JcXr4e
…ode errors

`safeDecodeURIComponent` tried the whole string, then on failure decoded
each `%XX` run in its own try/catch. Every run that is not valid UTF-8
threw a `URIError`, so a crafted 16 KB Content-Disposition header such as
`'%FF-'.repeat(4096)` took ~13 ms to parse (vs ~2 µs before the fallback).

Match only RFC 3629 UTF-8 sequences spelled as escapes and decode each
match. `decodeURIComponent` never throws on a match, so both try/catches
go away: that input now takes ~12 µs and a malformed filename ~0.3 µs
(was ~3 µs), well-formed input stays within ~1 µs.

Valid sequences inside a run that is invalid as a whole are now decoded
too: `%E4%B8%AD%FF` gives `中%FF` instead of staying encoded.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014w3GkqAVAgbiC1X6JcXr4e
…-8 regex

Go back to catching `decodeURIComponent` errors per `%XX` run instead of
matching an RFC 3629 grammar, which is harder to read and maintain.

The whole-string attempt before the per-run fallback is dropped too: a
valid UTF-8 sequence cannot span a non-escape character, so decoding run
by run gives the same output, leaving one decode path and one try/catch.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014w3GkqAVAgbiC1X6JcXr4e
Most values are valid, so one native `decodeURIComponent` call is about
twice as fast as scanning and decoding run by run. Only malformed input
pays for the throw and the per-run fallback, which keeps the valid runs
decoded.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014w3GkqAVAgbiC1X6JcXr4e
@pkg-pr-new

pkg-pr-new Bot commented Sep 29, 2026

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

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

@standard-server/core

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

@standard-server/fastify

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

@standard-server/fetch

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

@standard-server/node

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

@standard-server/peer

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

@standard-server/shared

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

commit: 466e4ce

@codecov

codecov Bot commented Sep 29, 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 Sep 29, 2026

Copy link
Copy Markdown
Contributor

Merging this PR will not alter performance

✅ 26 untouched benchmarks
⏩ 108 skipped benchmarks1


Comparing claude/fervent-dijkstra-sx43sq (466e4ce) with main (75cffb5)

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 — the partial-decode rework of safeDecodeURIComponent and its test coverage.

  • Partial-decode fallback (packages/shared/src/uri.ts) — when the whole-value decodeURIComponent throws, maximal contiguous runs of %XX escapes are decoded independently, and a run that still throws is kept verbatim; the whole-value fast path keeps valid input byte-identical to before.
  • Unit coverage (packages/shared/src/uri.test.ts) — headline case changed from "unchanged" to the decoded result, plus mixed valid/invalid, invalid-UTF-8, poisoned-run, and no-double-decode (%2541%ZZ → %41%ZZ) cases.
  • Content-Disposition coverage (packages/core/src/utils.test.ts) — two filename* cases pin the new behavior at the only downstream caller.

Verified locally: pnpm exec vitest run packages/shared packages/core passes (197 tests), eslint is clean on the changed files, and the new uri.test.ts case fails against the base uri.ts (so the regression test is discriminating). The run-level (non-greedy) decode choice — an invalid byte poisons its whole hex run — is conservative and intentional, and partial decoding exposes no bytes (%00, %2f, CRLF) that a fully valid encoding could not already produce.

Pullfrog  | View workflow run | Using DeepSeek Flash (default — pick a model for stronger reviews) | 𝕏

@dinwwwh dinwwwh changed the title Improve safeDecodeURIComponent to partially decode malformed input fix(shared): keep valid escapes decoded when percent-decoding malformed input Sep 29, 2026
@dinwwwh
dinwwwh merged commit 60d865e into main Sep 29, 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