Repository navigation
Description
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
usualoma commentedon Oct 29, 2026
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: privateorno-store, or configurevary: 'Cookie'or an appropriatekeyGeneratorfor these routes.For responses with
Set-Cookie, the middleware currently applies the configuredcacheControldirectives even though it skips storing the response. While we do not consider this a vulnerability, I’ll consider improving this behavior.ducanhnguyen223 commentedon Oct 30, 2026
Following your note that the middleware adds configured
cacheControldirectives to responses withSet-Cookieeven though it skips storing them, I prepared a narrow change for consideration.The regression fails on unmodified
main: withcacheControl: 'max-age=10', a route that setsSet-Cookiestill gets a middleware-addedCache-Controlheader, whilecache.putis not called. This branch suppresses only that middleware-added header forSet-Cookieresponses and leavesVaryhandling 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 --checkpass. 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
yusukebe commentedon Oct 30, 2026
As @usualoma said, this is expected behavior.
And I added the doc for the behavior recently:
https://hono.dev/docs/middleware/builtin/cache#caching-authenticated-responses
Closing.
Show the fixed hono and pnpm issues on the profile