Skip to content

Commit f475344

Browse files
committed
fix(security): prevent CDN caching of session responses by validating __session Set-Cookie
1 parent 31c2ce2 commit f475344

File tree

3 files changed

+102
-3
lines changed

3 files changed

+102
-3
lines changed

src/server/auth-client.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ import {
3232
removeTrailingSlash
3333
} from "../utils/pathUtils";
3434
import { toSafeRedirect } from "../utils/url-helpers";
35+
import { addCacheControlHeadersForSession } from "./cookies";
3536
import { AbstractSessionStore } from "./session/abstract-session-store";
3637
import { TransactionState, TransactionStore } from "./transaction-store";
3738
import { filterClaims } from "./user";
@@ -296,6 +297,7 @@ export class AuthClient {
296297
await this.sessionStore.set(req.cookies, res.cookies, {
297298
...session
298299
});
300+
addCacheControlHeadersForSession(res);
299301
}
300302

301303
return res;
@@ -441,6 +443,7 @@ export class AuthClient {
441443

442444
const res = NextResponse.redirect(url);
443445
await this.sessionStore.delete(req.cookies, res.cookies);
446+
addCacheControlHeadersForSession(res);
444447

445448
// Clear any orphaned transaction cookies
446449
await this.transactionStore.deleteAll(req.cookies, res.cookies);
@@ -567,6 +570,7 @@ export class AuthClient {
567570
}
568571

569572
await this.sessionStore.set(req.cookies, res.cookies, session, true);
573+
addCacheControlHeadersForSession(res);
570574
await this.transactionStore.delete(res.cookies, state);
571575

572576
return res;
@@ -580,8 +584,9 @@ export class AuthClient {
580584
status: 401
581585
});
582586
}
583-
584-
return NextResponse.json(session?.user);
587+
const res = NextResponse.json(session?.user);
588+
addCacheControlHeadersForSession(res);
589+
return res;
585590
}
586591

587592
async handleAccessToken(req: NextRequest): Promise<NextResponse> {
@@ -631,6 +636,7 @@ export class AuthClient {
631636
...session,
632637
tokenSet: updatedTokenSet
633638
});
639+
addCacheControlHeadersForSession(res);
634640
}
635641

636642
return res;

src/server/cookies.test.ts

Lines changed: 57 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
1+
import { NextResponse } from "next/server";
12
import { describe, expect, it } from "vitest";
23

34
import { generateSecret } from "../test/utils";
4-
import { decrypt, encrypt } from "./cookies";
5+
import { addCacheControlHeadersForSession, decrypt, encrypt } from "./cookies";
56

67
describe("encrypt/decrypt", async () => {
78
const secret = await generateSecret(32);
@@ -53,3 +54,58 @@ describe("encrypt/decrypt", async () => {
5354
await expect(() => decrypt(encrypted, "")).rejects.toThrowError();
5455
});
5556
});
57+
58+
describe("addCacheControlHeadersForSession", () => {
59+
it("adds cache headers if __session cookie has a future Date expiry", () => {
60+
const res = NextResponse.next();
61+
const futureDate = new Date(Date.now() + 60_000); // 1 minute in the future
62+
63+
res.cookies.set("__session", "dummy", { expires: futureDate });
64+
addCacheControlHeadersForSession(res);
65+
66+
expect(res.headers.get("Cache-Control")).toBe(
67+
"private, no-cache, no-store, must-revalidate, max-age=0"
68+
);
69+
expect(res.headers.get("Pragma")).toBe("no-cache");
70+
expect(res.headers.get("Expires")).toBe("0");
71+
});
72+
73+
it("does NOT add headers if __session cookie is missing", () => {
74+
const res = NextResponse.next();
75+
76+
addCacheControlHeadersForSession(res);
77+
expect(res.headers.get("Cache-Control")).toBeNull();
78+
expect(res.headers.get("Pragma")).toBeNull();
79+
expect(res.headers.get("Expires")).toBeNull();
80+
});
81+
82+
it("does NOT add headers if __session cookie is expired", () => {
83+
const res = NextResponse.next();
84+
const pastDate = new Date(Date.now() - 60_000); // 1 minute in the past
85+
86+
res.cookies.set("__session", "dummy", { expires: pastDate });
87+
addCacheControlHeadersForSession(res);
88+
89+
expect(res.headers.get("Cache-Control")).toBeNull();
90+
});
91+
92+
it("does NOT add headers if __session cookie has no value", () => {
93+
const res = NextResponse.next();
94+
const futureDate = new Date(Date.now() + 60_000);
95+
96+
// setting an empty value simulates a session cookie being cleared
97+
res.cookies.set("__session", "", { expires: futureDate });
98+
addCacheControlHeadersForSession(res);
99+
100+
expect(res.headers.get("Cache-Control")).toBeNull();
101+
});
102+
103+
it("does NOT add headers if __session cookie has no expires field", () => {
104+
const res = NextResponse.next();
105+
106+
res.cookies.set("__session", "dummy"); // no `expires`
107+
addCacheControlHeadersForSession(res);
108+
109+
expect(res.headers.get("Cache-Control")).toBeNull();
110+
});
111+
});

src/server/cookies.ts

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { NextResponse } from "next/server";
12
import {
23
RequestCookie,
34
RequestCookies,
@@ -329,3 +330,39 @@ export function deleteChunkedCookie(
329330
resCookies.delete(cookie.name); // Delete each filtered cookie
330331
});
331332
}
333+
334+
/**
335+
* Returns true if the cookie's `expires` field represents a future time.
336+
* Handles both Date objects and numeric UNIX timestamps.
337+
*/
338+
function isValidFutureExpiry(expires: Date | number): boolean {
339+
const now = Date.now();
340+
return typeof expires === "number"
341+
? expires * 1000 > now
342+
: expires instanceof Date && expires.getTime() > now;
343+
}
344+
345+
/**
346+
* Adds strict cache-control headers to prevent shared CDN caching of
347+
* responses that contain a valid `Set-Cookie: __session`.
348+
*
349+
* Only applies headers if a future-expiring `__session` cookie is present
350+
* in the response — avoiding overly aggressive cache disabling.
351+
*/
352+
export function addCacheControlHeadersForSession(res: NextResponse): void {
353+
const sessionCookie = res.cookies.get("__session");
354+
355+
const isFreshCookie =
356+
sessionCookie?.value &&
357+
sessionCookie?.expires &&
358+
isValidFutureExpiry(sessionCookie.expires);
359+
360+
if (isFreshCookie) {
361+
res.headers.set(
362+
"Cache-Control",
363+
"private, no-cache, no-store, must-revalidate, max-age=0"
364+
);
365+
res.headers.set("Pragma", "no-cache");
366+
res.headers.set("Expires", "0");
367+
}
368+
}

0 commit comments

Comments
 (0)