fix(http)!: strip non-allowlisted Set-Cookie instead of blocking storage (#61) - #68
Conversation
…age (#61) Concurrent same-key requests are coalesced onto a single resolution, so a handler that mints a per-request `Set-Cookie` (e.g. a session id) had its leader's cookie replayed to every deduplicated peer — a cross-user session leak. The previous `_blockSetCookie` guard only refused *storage*; it ran after coalescing had already shared the leader's serialized response with its peers, so it never closed the in-flight leak. Instead, strip every non-allowlisted `Set-Cookie` in `serialize`, before the response headers are serialized. Stripping happens once for the shared resolution, so no coalesced peer (nor a future cache hit) can observe another caller's cookie, and the rest of the response is cached normally — mirroring how CDNs / Varnish drop `Set-Cookie` on cacheable responses and the existing Cookie-request-header stripping on the way in. `allowCookies` still opts specific names back in (kept on the response, others stripped). On runtimes without `getSetCookie`, all cookies are stripped (fail safe). BREAKING CHANGE: a `Set-Cookie` is no longer returned to its direct caller by default — handlers minting per-request cookies must allowlist them via `allowCookies` or serve from a bypassed (non-GET/HEAD) route. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
📝 WalkthroughWalkthroughChanges replace the prior ChangesCookie caching behavior update
Estimated code review effort: 3 (Moderate) | ~25 minutes Possibly related issues
Suggested reviewers: 🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
…ng responsibility The prior README/JSDoc still described the old "refuse storage but return Set-Cookie to the caller" behavior, which the strip-based fix replaced. Update both to the new behavior (non-allowlisted Set-Cookie stripped, rest cached) and document the responsibility that comes with `allowCookies`: an allowlisted cookie participates in caching and is shared across every caller that resolves to the same key (coalescing + cache replay), so only cookies safe to share (e.g. theme/locale that are part of the key) should be allowlisted — never a per-user secret like a session id. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
There was a problem hiding this comment.
Actionable comments posted: 1
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (1)
src/http.ts (1)
40-44: 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick winUpdate stale comment: "Set-Cookie responses are refused storage" no longer matches behavior.
The comment at lines 40-44 says
Set-Cookie responses are refused storage, but the new behavior implemented at lines 185-207 strips disallowedSet-Cookieheaders and caches the rest of the response. This comment should be updated to reflect the stripping behavior, e.g. "Set-Cookie headers are stripped before storage."📝 Proposed fix for stale comment
// Allowlist of cookie names that may participate in caching. `undefined` means // "no cookies allowed": the Cookie request header is stripped before the handler - // runs, cookies never vary the key, and Set-Cookie responses are refused storage. + // runs, cookies never vary the key, and Set-Cookie headers are stripped before + // storage so the rest of the response is cached. // Names are trimmed/deduped; an empty (or whitespace-only) list normalizes to the // "no cookies allowed" default.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@src/http.ts` around lines 40 - 44, Update the stale documentation comment in the HTTP caching allowlist section so it matches the behavior in the response storage path. The comment near the allowlist definition currently says “Set-Cookie responses are refused storage,” but the logic in the response handling flow now strips disallowed Set-Cookie headers and still caches the remaining response. Adjust the wording in that comment to reflect the stripping-before-storage behavior, using the relevant caching/response storage symbols in src/http.ts as the reference point.
🧹 Nitpick comments (1)
test/index.test.ts (1)
2345-2367: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winAdd
callCountassertion to verify coalescing actually occurred.The test claims "Two concurrent requests collapse onto one resolution" but doesn't verify it. Without
expect(callCount).toBe(1), the test passes even if both requests invoke the handler independently — both would still haveset-cookiestripped. Asserting coalescing ensures the test genuinely exercises the issue#61scenario.✅ Proposed addition
expect(a.headers.get("set-cookie")).toBeNull(); expect(b.headers.get("set-cookie")).toBeNull(); + expect(callCount).toBe(1); });🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@test/index.test.ts` around lines 2345 - 2367, The coalescing test in defineCachedHandler only checks that both responses drop set-cookie, but it never verifies the requests were actually collapsed into one execution. After the Promise.all in the test block, add an assertion on callCount to confirm the handler ran once, so the issue `#61` case is genuinely exercised rather than passing if both requests execute independently.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@src/http.ts`:
- Around line 185-207: The cookie filtering in the response handling code
preserves allowed Set-Cookie values in memory, but the cache serialization via
Object.fromEntries(res.headers.entries()) collapses duplicate set-cookie headers
to one. Update the header capture/reconstruction path in src/http.ts (the logic
around res.headers.getSetCookie and the response headers serialization) so
set-cookie is stored separately or handled as a multi-value header, then restore
all allowed cookies when rebuilding the cached response.
---
Outside diff comments:
In `@src/http.ts`:
- Around line 40-44: Update the stale documentation comment in the HTTP caching
allowlist section so it matches the behavior in the response storage path. The
comment near the allowlist definition currently says “Set-Cookie responses are
refused storage,” but the logic in the response handling flow now strips
disallowed Set-Cookie headers and still caches the remaining response. Adjust
the wording in that comment to reflect the stripping-before-storage behavior,
using the relevant caching/response storage symbols in src/http.ts as the
reference point.
---
Nitpick comments:
In `@test/index.test.ts`:
- Around line 2345-2367: The coalescing test in defineCachedHandler only checks
that both responses drop set-cookie, but it never verifies the requests were
actually collapsed into one execution. After the Promise.all in the test block,
add an assertion on callCount to confirm the handler ran once, so the issue `#61`
case is genuinely exercised rather than passing if both requests execute
independently.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro
Run ID: 1b07740d-4b3d-4fad-b92c-1ce5d9baf50b
📒 Files selected for processing (5)
AGENTS.mdREADME.mdsrc/http.tssrc/types.tstest/index.test.ts
| // Strip every Set-Cookie the allowlist doesn't cover BEFORE the headers are | ||
| // serialized, so a per-request cookie (e.g. a session id) can never reach a caller | ||
| // other than the one it was minted for — neither a future cache hit nor a | ||
| // concurrent, coalesced peer that shares this single resolution (issue #61). By | ||
| // default (no `allowCookies`) that drops every Set-Cookie: a shared cache must not | ||
| // carry per-client cookies, mirroring both the Cookie-request-header stripping on | ||
| // the way in and how CDNs / Varnish treat cacheable responses. The rest of the | ||
| // response is still cached. Prefer `getSetCookie()` so each cookie is inspected | ||
| // individually — `Object.fromEntries(headers.entries())` below collapses multiples | ||
| // to one. On runtimes without it we can't tell which cookies are present, so strip | ||
| // all of them (fail safe) rather than risk replaying one. | ||
| if (typeof res.headers.getSetCookie === "function") { | ||
| const setCookies = res.headers.getSetCookie(); | ||
| const kept = setCookies.filter((c) => allowedCookieNames?.includes(_cookieName(c))); | ||
| if (kept.length !== setCookies.length) { | ||
| res.headers.delete("set-cookie"); | ||
| for (const c of kept) { | ||
| res.headers.append("set-cookie", c); | ||
| } | ||
| } | ||
| } else if (res.headers.has("set-cookie")) { | ||
| res.headers.delete("set-cookie"); | ||
| } |
There was a problem hiding this comment.
🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
# Search for test cases that set multiple Set-Cookie headers with allowCookies
rg -nC5 'allowCookies' test/index.test.ts | head -80Repository: unjs/ocache
Length of output: 3259
🏁 Script executed:
#!/bin/bash
set -euo pipefail
# Inspect the serialization code around the reported lines.
sed -n '190,240p' src/http.ts
# Inspect the nearby tests that cover allowCookies + Set-Cookie handling.
sed -n '2360,2425p' test/index.test.ts
# Look for any direct tests that exercise multiple allowlisted Set-Cookie headers.
rg -nC3 'set-cookie|getSetCookie|allowCookies' test/index.test.tsRepository: unjs/ocache
Length of output: 11731
🏁 Script executed:
#!/bin/bash
set -euo pipefail
# Inspect the cache entry type and the read-side reconstruction.
rg -nC4 'type ResponseCacheEntry|interface ResponseCacheEntry|transform:|createResponse|new Response\(' src/http.ts
# Read the relevant section around the cache entry serialization/replay path.
sed -n '150,290p' src/http.tsRepository: unjs/ocache
Length of output: 9154
Preserve multiple allowlisted Set-Cookie headers in the cache entry. Object.fromEntries(res.headers.entries()) collapses duplicate set-cookie values to the last one, so a response that sets two allowed cookies will replay only one. Store set-cookie separately or special-case it during serialization and reconstruction.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@src/http.ts` around lines 185 - 207, The cookie filtering in the response
handling code preserves allowed Set-Cookie values in memory, but the cache
serialization via Object.fromEntries(res.headers.entries()) collapses duplicate
set-cookie headers to one. Update the header capture/reconstruction path in
src/http.ts (the logic around res.headers.getSetCookie and the response headers
serialization) so set-cookie is stored separately or handled as a multi-value
header, then restore all allowed cookies when rebuilding the cached response.
Fixes #61.
Problem
HTTP request coalescing deduplicates concurrent same-key requests onto a single handler resolution. When a handler mints a per-request
Set-Cookie(e.g. a session id), the leader's cookie was replayed to every deduplicated peer — users 2..N received someone else's session identifier. A cross-user session leak.The prior
_blockSetCookieguard only refused storage. It ran after coalescing had already handed every peer the leader's serialized response (cookie included), so it prevented the entry being cached for future requests but did nothing about the concurrent in-flight peers already sharing the leader's response.Reproduction (was failing, now passing):
Fix
Strip every non-allowlisted
Set-Cookiein theserializehook, before the response headers are serialized.serializeruns once for the shared resolution, so:This mirrors how CDNs / Varnish drop
Set-Cookieon cacheable responses, and the existingCookie-request-header stripping on the way in — making the secure default symmetric in both directions.allowCookies: string[]still opts specific names back in: those survive on the response (and vary the key), others are stripped and the rest is still cached.getSetCookie(can't enumerate individual cookies), allSet-Cookies are stripped — fail safe.validatekeeps its read-side check as defense-in-depth for pre-existing/foreign stored entries written before the strip existed.Breaking change
A
Set-Cookieis no longer returned to its direct caller by default. Handlers that mint per-request cookies must either allowlist them viaallowCookies, or serve from a bypassed (non-GET/HEAD) route (which passes through untouched).Tests
Set-Cookie.allowCookieskeeps allowed, strips the rest; no-getSetCookieruntime strips conservatively).🤖 Generated with Claude Code
Summary by CodeRabbit
New Features
Bug Fixes
Set-Cookieheaders from cached responses.Documentation