Skip to content

cache middleware caches cookie-authenticated responses per URL, disclosing one user's data to another #5592

Closed

Description

@vinzabe

What version of Hono are you using?

4.13.14

What runtime/platform is your app running on? (with version if possible)

ubuntu

What steps can reproduce the bug?

#!/bin/sh

Hono 4.13.14 cache() cross-account PII disclosure.

Cold start: installs [email protected], serves a real HTTP app with two owned users,

and shows Bob receiving Alice's account JSON (email, phone last4, API key).

set -eu
NODE_BIN="${NODE_BIN:-node}"; NPM_BIN="${NPM_BIN:-npm}"
"$NODE_BIN" -e 'process.exit(process.versions.node.split(".")[0] >= 18 ? 0 : 1)' || { echo "Node >= 18 required"; exit 1; }
WORK=$(mktemp -d); cd "$WORK"
"$NPM_BIN" init -y >/dev/null 2>&1
"$NPM_BIN" install --no-audit --no-fund --ignore-scripts [email protected] >/dev/null 2>&1
"$NODE_BIN" -e 'const fs=require("fs");const v=JSON.parse(fs.readFileSync("node_modules/hono/package.json")).version;if(v!=="4.13.14"){process.exit(1)};console.log("hono",v)'

cat > app.js <<'JS'
const http=require("http");
const store=new Map();
const mkCache=()=>({async match(k){const r=store.get(String(k));return r?r.clone():undefined},async put(k,v){store.set(String(k),v.clone())}});
globalThis.caches={async open(){return mkCache()}};
const { Hono }=require("hono"); const { cache }=require("hono/cache");
const users={
alice:{name:"Alice Anderson",email:"[email protected]",phoneLast4:"4417",apiKey:"sk_live_ALICE_7f3c9a",plan:"enterprise"},
bob:{name:"Bob Brown",email:"[email protected]",phoneLast4:"9921",apiKey:"sk_live_BOB_1b8d2e",plan:"starter"}
};
const app=new Hono();
app.use("", cache({cacheName:"pii", cacheControl:"max-age=60"}));
app.use("/vary/
", cache({cacheName:"pii2", cacheControl:"max-age=60", vary:["Cookie"]}));
const sid=c=>{const raw=c.req.header("cookie")||"";const m=raw.match(/(?:^|;\s*)sid=([^;]+)/);return m?m[1]:null};
const acct=u=>({user:{name:u.name,email:u.email,phoneLast4:u.phoneLast4,apiKey:u.apiKey,plan:u.plan}});
app.get("/login", c=>{const u=c.req.query("u");if(!users[u])return c.text("no user",404);c.header("Set-Cookie","sid="+u+"; Path=/; HttpOnly; SameSite=Lax");return c.text("logged in "+u)});
app.get("/account", c=>{const u=users[sid(c)];return u?c.json(acct(u)):c.text("unauthorized",401)});
app.get("/vary/account", c=>{const u=users[sid(c)];return u?c.json(acct(u)):c.text("unauthorized",401)});
const ctx={waitUntil(){},passThroughOnException(){}};
const port=Number(process.env.PORT||19330);
http.createServer(async(req,res)=>{
const chunks=[];for await(const c of req)chunks.push(c);
const body=chunks.length?Buffer.concat(chunks):undefined;
const r=new Request("http://127.0.0.1:"+port+req.url,{method:req.method,headers:req.headers,body,duplex:body?"half":undefined});
const out=await app.fetch(r,{},ctx);const h={};out.headers.forEach((v,k)=>h[k]=v);
res.writeHead(out.status,h);res.end(Buffer.from(await out.arrayBuffer()));
}).listen(port,"127.0.0.1",()=>console.log("PII_UP "+port));
JS

PORT=19330 node app.js > server.log 2>&1 & SP=$!
for i in 1 2 3 4 5 6 7 8 9 10; do grep -q PII_UP server.log 2>/dev/null && break; sleep 0.2; done
B="http://127.0.0.1:19330"; A="$WORK/a.jar"; BOB="$WORK/b.jar"

curl -s -c "$A" -D "$WORK/login.headers" "$B/login?u=alice" >/dev/null
ALICE=$(curl -s -b "$A" "$B/account")
curl -s -c "$BOB" "$B/login?u=bob" >/dev/null
BOB_BODY=$(curl -s -b "$BOB" "$B/account")
VARY_BOB=$(curl -s -b "$BOB" "$B/vary/account")
AUTH_BOB=$(curl -s -b "$BOB" -H 'Authorization: Bearer x' "$B/account")
kill "$SP" 2>/dev/null || true; wait "$SP" 2>/dev/null || true

echo "login response headers:"; grep -i -E 'cache-control|set-cookie' "$WORK/login.headers" || true
echo "alice /account -> $ALICE"
echo "bob /account -> $BOB_BODY (cross-account if it equals Alice)"
echo "bob /vary/account -> $VARY_BOB"
echo "bob /account + Authorization -> $AUTH_BOB"

echo "$BOB_BODY" | grep -q '[email protected]' || { echo "FAIL: bob did not receive alice's email"; exit 1; }
echo "$BOB_BODY" | grep -q 'sk_live_ALICE_7f3c9a' || { echo "FAIL: bob did not receive alice's API key"; exit 1; }
echo "$VARY_BOB" | grep -q '[email protected]' || { echo "FAIL: vary control did not separate"; exit 1; }
echo "$AUTH_BOB" | grep -q '[email protected]' || { echo "FAIL: Authorization control did not bypass"; exit 1; }
echo "PASS: Bob received Alice's name, email, phone last4 and API key from the cache; Vary:Cookie and Authorization controls returned Bob's own data."

What is the expected behavior?

Control request

Three requests on the same live Hono server, after Bob has his own valid session:

/vary/account uses cache({..., vary:["Cookie"]}); request with Bob's cookie.
/account with Bob's cookie plus Authorization: Bearer x (Authorization requests
are skipped by the middleware).
A /login response that carries Set-Cookie is not stored by the cache.
Control response (expected: Bob's own data / not stored)

bob /vary/account -> {"user":{"name":"Bob Brown","email":"[email protected]","phoneLast4":"9921","apiKey":"sk_live_BOB_1b8d2e",...}}
bob /account + Authorization -> {"user":{"name":"Bob Brown","email":"[email protected]","phoneLast4":"9921","apiKey":"sk_live_BOB_1b8d2e",...}}
login response headers: cache-control: max-age=60; set-cookie: sid=alice; ...

What do you see instead?

A real HTTP server running Hono 4.13.14 mounted the built-in
cache({cacheName:"pii", cacheControl:"max-age=60"}) middleware globally (the
documented app.use('*', cache(...)) form) and served /account from a session
cookie. Two owned accounts existed: [email protected] (enterprise) and
[email protected] (starter).

Alice logged in and requested /account; her response contained her name, email,
phone last four digits, API key sk_live_ALICE_7f3c9a, and plan.
Bob logged in with his own cookie and requested the same /account. He received
Alice's full account object, including 1 email address, 1 phone last4, and 1
live API key/credential, not his own data.
Repeating Bob's request continued to return Alice's cached response.
The middleware key is the URL plus only explicitly configured Vary headers; it
skips caching only when the request has Authorization or the response has
Set-Cookie / private / no-store / an unconfigured Vary. Cookie identity is not
part of the key, so a session-cookie-authenticated response is cached and served to
the next identity.

Additional observed hazard: the /login response (which sets a session cookie) is
emitted with cache-control: max-age=60 by the same middleware even though the
middleware declines to store it because of Set-Cookie. A downstream shared cache
that honors Cache-Control can therefore cache a response the Hono cache itself
refused to store.

Three controls on the same server: /vary/account with vary:["Cookie"] returned
Bob's own data; a request with Authorization bypassed the cache and returned Bob's
own data; a Set-Cookie response was not stored. An independent validator repeated the
cross-account delivery with its own identities and ports.

Affected objects: per-account JSON containing PII and a live API credential. One
cross-account disclosure was observed per cached URL; every later identity on that
URL receives the first identity's data for the cache lifetime. Precondition, stated in
full: the application mounts the built-in cache() middleware on a route whose
response is personalized by a session cookie and does not mark the response private or
set a cookie. Hono's documented usage example mounts cache() on *, so this is one
middleware line away for a cookie-authenticated app. Node lacks the Workers Cache API,
so the reproduction supplies an in-memory caches.open().match/put implementation;
Hono's key construction, Authorization check, response-skip logic, and header emission
are the shipped code.

Additional information

No response

Activity

  1. usualoma commented on Oct 29, 2026

    @usualoma
    Member

    Hi @vinzabe

    Thank you for the report.

    Reusing a cached response across users is expected behavior when shared caching is enabled for personalized responses without appropriate cache controls or user-specific keys. Applications should use Cache-Control: private or no-store, or configure vary: 'Cookie' or an appropriate keyGenerator for these routes.

    For responses with Set-Cookie, the middleware currently applies the configured cacheControl directives even though it skips storing the response. While we do not consider this a vulnerability, I’ll consider improving this behavior.

  2. ducanhnguyen223 commented on Oct 30, 2026

    @ducanhnguyen223

    Following your note that the middleware adds configured cacheControl directives to responses with Set-Cookie even though it skips storing them, I prepared a narrow change for consideration.

    The regression fails on unmodified main: with cacheControl: 'max-age=10', a route that sets Set-Cookie still gets a middleware-added Cache-Control header, while cache.put is not called. This branch suppresses only that middleware-added header for Set-Cookie responses and leaves Vary handling unchanged. It does not address the separate cross-user caching behavior for personalized routes.

    Regression: the focused cache test passes (153/153); targeted lint, format, and git diff --check pass. I used Codex assistance to prepare the change and verify it.

    Per the contribution guide, I’m sharing the fork branch rather than opening a PR. If maintainers find it useful, please feel free to open a PR from it or adjust it:
    https://github.com/ducanhnguyen223/hono/tree/fix/cache-control-set-cookie-5592

  3. yusukebe commented on Oct 30, 2026

    @yusukebe
    Member
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions