Repository navigation
fix(keys): encode names and values that could be read as key structure - #251
Merged
Merged
Conversation
A cache key joins query params and headers as `name=value` pairs with "," and the parts with "&", and escaped none of those inside a value. So `?locale=x,populate=articles` keyed exactly like `?locale=x&populate=articles`, and an anonymous caller who sent the first decided what the second URL served until maxAge. A header value such as `fr,x-b=1` could pose as a second configured header the same way, and an "&" in a query value or header could move the boundary between parts. Escaping everything would move the key of nearly every real request - commas are ordinary in populate and sort lists and in Accept-Encoding, "=" in a q-weighted Accept-Language - which would orphan every existing entry and start every route cold after an upgrade. So only what is ambiguous is escaped: "%" and "&" always, a name character outside the token set, and a "," in a value only where it is followed by a name and "=". That is enough for a key to decode one way only, and an ordinary value comes out byte-identical; the new spec pins exact keys for such requests.
The first version escaped a "," only where it was followed by a name and
"=". That is correct, but its uniqueness argument was subtle enough that a
gap would be a poisoning hole, and it left a JSON-looking string keyed like
the object or array qs builds from bracket syntax: `?q={"a":"b"}` and
`?q[a]=b`, `?q=["1","2"]` and `?q=1&q=2`, shared a key.
Replace it with a rule that is easy to check. A string goes in unchanged
unless it contains "%", "&" or "=" (or "," for a name) or starts with "{",
"[" or '"'; otherwise the whole string goes through encodeURIComponent.
Values then never contain "=" or "&", names never contain "," or "=", and
raw, encoded and JSON values are told apart by "%" and by the leading
bracket - four invariants, written down in escapeKeyPart.ts.
Values keep their commas, which is what keeps `populate=a,b`,
`sort=title:asc,id` and `Accept-Encoding: gzip, deflate` byte-identical to
before. A value containing "=" does get a new key, which in practice means
a q-weighted header listed in useHeaders - no header is keyed by default -
and costs one cold entry: old entries are still purged, since purge
patterns are anchored on the path.
7 of 8 tasks
Strapi's query middleware parses with qs and strictNullHandling, so a bare `?q` arrives as null. typeof null is "object", which sent it down the JSON branch and out as `q=null` - the key of the literal string `?q=null`. Whichever arrived first was served to the other. A top-level null now keys as a lone "%". No raw value contains "%", and in an encoded value every "%" starts a %XX escape, so nothing else can produce it and the invariants in escapeKeyPart.ts still hold. Giving null its own form, rather than force-encoding the string "null", keeps the string rule uniform: a string is raw unless it carries structure. The one key that moves is a bare parameter's (`q=null` to `q=%`); `?q=null` keeps its key. qs yields only strings, arrays, objects and null, so strings such as "true", "1" or "null" cannot meet a non-string top-level value. A null nested in an object or array stays JSON `null` against a quoted "null". Both are now pinned. The two guards that already passed (nested null, and `?q[]` against the string "[null]") were each seen failing under a deliberate mutation before being trusted.
The guide said the only realistic moved keys were q-weighted headers. Query values move too, and they are common enough to matter: a padded base64 cursor, a percentage, "Tom & Jerry", "[draft]", a URL with its own query string. Readers planning an upgrade need the rule itself, not one example of it, plus the bare-parameter change and the reason the old entries are still purged.
derrickmehaffy
added a commit
that referenced
this pull request
Oct 13, 2026
Keeps both category probe handlers and their public permissions: echo (purge-pattern fixtures) and vary (#251's key-separator fixtures).
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What changed and why
The failure mode
A cache key is built as
where
querySuffixandheadersSuffixarename=valuepairs joined with,. Nothing inside a name or a value was escaped, so one request could spell out another request's key. In every case below, whichever request arrives first is stored and then served to the other untilmaxAge. Under the defaulthitpass, any anonymous caller can do this.A query value forging a second parameter.
The first request has one parameter and populates nothing. Its response poisons the second URL.
A string forging a nested value. Nested query values go into the key as
JSON.stringifyoutput, so a string that looks like that JSON collides with it:?q={"a":"b"}collides with?q[a]=b.?q=["1","2"]collides with?q=1&q=2.A bare parameter colliding with the string
"null". Strapi'sstrapi::querymiddleware parses with qs andstrictNullHandling: true, so a bare?qarrives asnull.typeof nullis"object", which sent it down the JSON branch and out asq=null. That is the key of the literal?q=null. Verified end to end:?qcached, then?q=nullwas a HIT serving{"q":null}.A header value forging a second header. With
useHeaders: ["x-a", "x-b"], a request sendingx-a: fr,x-b=1collides with one sendingx-a: frandx-b: 1.An
&moving the boundary between the query and header parts.?q=1%26x-a%3D2withx-a: 3, and?q=1withx-a: 2&x-a=3, both key as...?q=1&x-a=2&x-a=3&up:public.Auth was not spoofable. The auth component is always last, and a caller's own identity is always appended after anything they control. After this change it no longer depends on that ordering argument either: the two
&around the header part are now the only bare&in any key.The fix
New
server/src/utils/keys/escapeKeyPart.ts, used bygenerateQueryParamsKeyandgenerateHeadersKey. The rule is all-or-nothing per string:Nested values are still
JSON.stringify'd, as before. The rule is applied first to every string inside them, keys included.A top-level
null(a bare?q) keys as a lone%(KEY_NULL). It is tested before the JSON branch.This gives four invariants. They are written in the file's header comment, and each can be checked by reading the rule:
%,&,,or=.%, and an encoded value always does, so the two can never be equal.encodeURIComponentis injective. The one exception isKEY_NULL, a lone%. No raw value holds a%, and in an encoded value every%starts a%XXescape, soKEY_NULLequals neither.&or=. Together with (1):=in a part ends a name;,before its=;&in a key is a separator.{,[or". Nested JSON always starts with{or[, so a scalar and a nested value can never be equal. Inside the JSON every string obeys (2) and (3), and JSON adds no=,&or%of its own. A nestednullis the bare wordnullwhile a nested string is quoted, so{"a":null}and{"a":"null"}stay distinct.What qs can produce. With Strapi's options, qs yields only strings, arrays, objects and
null. I checked this directly against qs 6.16 withstrictNullHandling: true:?q=true,?q=1,?q=[]and?q={}are all strings, and?q,?q[],?q[a]and?q&q=bgivenull,[null],{"a":null}and[null,"b"]. So a string that reads as a JSON literal ("true","1","null") can never meet a non-string top-level value of the same spelling. The only such value was top-levelnull, and it now has its own form. Strings starting with[or{are encoded under (4).encodeURIComponentthrows on a lone surrogate. Lone surrogates are replaced before encoding, so a request cannot turn that into a 500. In practice none arrive, because qs leaves undecodable input as a literal%XX.Breaking change? Which keys move
The rule in AGENTS.md is that keys must not move for existing configurations. Values keep their commas. That is safe because of invariant (3), and it means comma lists are untouched. These keys are byte-identical to main, and the spec pins each one:
The exact rule for which keys move. A key moves when any of these is true:
useHeadersvalue contains%,&or=;useHeadersvalue starts with{,[or";filters[...]) meets either of the two conditions above;,;?q), which moves fromq=nulltoq=%.That is wider than "q-weighted headers". Real query values hit it too:
cursor=eyJpZCI6MX0=;50%;Tom & Jerry;[draft];";=.On the header side, a q-weighted
Accept-Language: en-US,en;q=0.9now keys asen-US%2Cen%3Bq%3D0.9. No header is part of a key by default (useHeaders: []).For the
nullcollision I gavenulla distinct encoding rather than force-encoding the string"null". That keeps the string rule uniform: a string is raw unless it carries structure. It moves one rare key, a bare parameter's.?q=nullkeeps its key.Each moved value costs one cold entry after the upgrade. The old entries are not orphaned: purge patterns are anchored on the path (
^/api/...\?), so they still match and are purged as before, and otherwise they expire atmaxAge.Keeping
=raw would require the context-sensitive comma escape this PR first shipped with. That escape fixed the separator collisions, but its proof of uniqueness was much harder to check, and it still left the string-vs-JSON collisions open. For a security fix, I think the simpler invariant is worth the cold entries.Fixtures
The tests use one new fixture route,
/api/categories/probe/vary. It is keyed onuseQueryParams: ["q"],useHeaders: ["x-a", "x-b"]anduseAuth: true. Its handler echoes all three, so a response served under the wrong key shows up in the body. It is public-readable, like the other category probes.Before / after
pnpm run test:e2e:memory -- cache-key-separatorsSeparator and JSON collisions, before (main's
generateQueryParamsKey/generateHeadersKey, same spec and fixtures as the first two commits):All five collision tests are red on main. The last two failures are the expected key-format change. The "literal escape" test cannot fail on main, because main does no escaping. It guards the encoding itself, so I made it fail on purpose: with
%dropped from the value rule, it goes red (Expected: "MISS",Received: "HIT").nullcollision, before (this branch without theKEY_NULLchange, new tests added):The two guards already pass without the fix, so a green run proves nothing about them. I made each fail on purpose:
escapeKeyJsonturning a nestednullinto"null", the nested-null test goes red (HIT);[dropped from the leading-character rule, the[null]test goes red (HIT).After:
Full memory suite:
Test Suites: 24 passed, 24 total/Tests: 157 passed, 157 total.Checklist
shared/tests/that fails before the change.pnpm run test:e2e:memorypasses.pnpm run test:lintpasses.admin/? Not touched.pnpm run test:esmpasses.docs/guide/caching/keys.mddocuments the encoding rule, the bare-parameter change, and exactly which keys move.main.Notes for the reviewer
"null". The fourth corrects the docs on which keys move. Squash on merge.test:e2e:rediswas not run. Key generation is provider-independent, and the redis provider only prefixes keys.strapi.ai.admin.isEnabled is not a function, from a duplicated Strapi copy. I ran the specs against fix(deps): keep a single Strapi in the workspace #260's lockfile locally. That lockfile is not part of this PR.