Repository navigation
fix(shared): return a value that fails to percent-decode as-is - #160
Conversation
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
@standard-server/aws-lambda
@standard-server/core
@standard-server/fastify
@standard-server/fetch
@standard-server/node
@standard-server/peer
@standard-server/shared
commit: |
Codecov Report✅ All modified and coverable lines are covered by tests. 📢 Thoughts on this report? Let us know! |
Merging this PR will not alter performance
|
There was a problem hiding this comment.
✅ No new issues found.
Reviewed changes
safeDecodeURIComponentis now all-or-nothing — it callsdecodeURIComponentat most once and returns the raw value onURIError, reverting the per-run partial-decode fallback from #133 and removingPERCENT_ESCAPES_REGEX. This caps a hostile%FFapayload at one decode attempt instead of one throw per escape run.resolveStandardBodyHintparsescontent-dispositionlazily — only when no explicitstandard-serverhint,none, known media type, orcontent-lengthdecides the hint first. The reordering is behavior-preserving for the returned hint (content-lengthstill wins forfile).- Tests — shared/core/peer tests spy on
globalThis.decodeURIComponentto assert a single decode on hostile input and zero decodes on json/form-data/url-search-params/event-stream/explicit-hint/none/content-lengthpaths, plus raw passthrough of malformedfilename*.
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.
deepseek-v4.1-flash (free via Pullfrog for OSS) | 𝕏

A hostile
Content-Dispositionfilename*, such as%FFarepeated, madesafeDecodeURIComponentthrow and catch oneURIErrorfor every 4 bytes. After the whole value failed to decode, it retried each run of%XXescapes on its own. Unauthenticated clients could use this to burn CPU:application/jsonrequest;Fixes
safeDecodeURIComponentreturns a value that fails to decode as-is, so it throws at most once.decodeURIComponentstops at the first malformed escape, so the cost no longer grows with the number of escapes.resolveStandardBodyHintparsescontent-dispositiononly when no other header decides the hint. JSON, form-data, url-search-params and event-stream requests, and requests with acontent-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.txtgives%E4%B8%AD%ZZ.txt, not中%ZZ.txt;invalid%20value%staysinvalid%20value%.Well-formed values decode as before. Only senders that don't encode
%as%25are affected. Code outside the repo that usessafeDecodeURIComponentfrom@standard-server/sharedgets the behaviour from before #133 back.Performance
%FFarepeated infilename*application/json, 16 KB headerNot covered here: the
Content-Dispositionparameter 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
%FFavalue callsdecodeURIComponentonce; it was 250,001 times before.content-disposition,resolveStandardBodyHintnever decodes it for json, form-data, url-search-params, event-stream, an explicit hint,noneor acontent-length. It still returnsfilewhen the filename alone decides. ThegetFilenameFromContentDispositionexpectations from fix(shared): keep valid escapes decoded when percent-decoding malformed input #133 now expect the raw value.%FFafilename resolves to aFilewith that name, with one decode call.main.vitest runfor shared, core, fetch, node, fastify, aws-lambda and peer (800 passed) andpnpm run checkare clean.🤖 Generated with Claude Code
https://claude.ai/code/session_01KkyGkYHZGmXB18XUR6fFbF
Generated by Claude Code