diff --git a/src/middleware.js b/src/middleware.js index 56ba751f9..1b1d66f58 100644 --- a/src/middleware.js +++ b/src/middleware.js @@ -6,6 +6,16 @@ import { isAuthEnabled } from "utils/env"; const authEnabled = isAuthEnabled(); const authSecret = process.env.NEXTAUTH_SECRET || process.env.HOMEPAGE_AUTH_SECRET; +// Prerendered pages carry `s-maxage`, and the dashboard HTML embeds the service and +// bookmark inventory. Without this, a CDN or caching reverse proxy in front of Homepage +// would store an authenticated response and serve it to anonymous visitors. +function withPrivateCache(res) { + if (authEnabled) { + res.headers.set("Cache-Control", "private, no-store"); + } + return res; +} + export async function middleware(req) { // Check the Host header, if HOMEPAGE_ALLOWED_HOSTS is set const host = req.headers.get("host"); @@ -26,18 +36,18 @@ export async function middleware(req) { if (authEnabled && !pathname.startsWith("/api/healthcheck")) { // The MCP API handler authorizes both bearer tokens and Homepage sessions. if (pathname === "/api/mcp") { - return NextResponse.next(); + return withPrivateCache(NextResponse.next()); } const token = await getToken({ req, secret: authSecret }); if (!token) { const signInUrl = new URL("/auth/signin", req.url); signInUrl.searchParams.set("callbackUrl", "/"); - return NextResponse.redirect(signInUrl); + return withPrivateCache(NextResponse.redirect(signInUrl)); } } - return NextResponse.next(); + return withPrivateCache(NextResponse.next()); } export const config = { diff --git a/src/middleware.test.js b/src/middleware.test.js index d3dc8815b..aab97821a 100644 --- a/src/middleware.test.js +++ b/src/middleware.test.js @@ -2,9 +2,9 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; const { NextResponse, getToken } = vi.hoisted(() => ({ NextResponse: { - json: vi.fn((body, init) => ({ type: "json", body, init })), - next: vi.fn(() => ({ type: "next" })), - redirect: vi.fn((url) => ({ type: "redirect", url })), + json: vi.fn((body, init) => ({ type: "json", body, init, headers: new Headers() })), + next: vi.fn(() => ({ type: "next", headers: new Headers() })), + redirect: vi.fn((url) => ({ type: "redirect", url, headers: new Headers() })), }, getToken: vi.fn(), })); @@ -47,7 +47,7 @@ describe("middleware", () => { const res = await middleware(createReq("localhost:3000")); expect(NextResponse.next).toHaveBeenCalled(); - expect(res).toEqual({ type: "next" }); + expect(res.type).toBe("next"); }); it("blocks requests when host is not allowed", async () => { @@ -74,7 +74,7 @@ describe("middleware", () => { const res = await middleware(createReq("anything.example")); expect(NextResponse.next).toHaveBeenCalled(); - expect(res).toEqual({ type: "next" }); + expect(res.type).toBe("next"); }); it("allows requests when host is included in HOMEPAGE_ALLOWED_HOSTS", async () => { @@ -85,7 +85,7 @@ describe("middleware", () => { const res = await middleware(createReq("example.com:3000", "http://example.com:3000/")); expect(NextResponse.next).toHaveBeenCalled(); - expect(res).toEqual({ type: "next" }); + expect(res.type).toBe("next"); }); it("allows healthcheck requests without auth when host is allowed", async () => { @@ -97,7 +97,7 @@ describe("middleware", () => { expect(getToken).not.toHaveBeenCalled(); expect(NextResponse.next).toHaveBeenCalled(); - expect(res).toEqual({ type: "next" }); + expect(res.type).toBe("next"); }); it.each(["false", "0", "no", "off", ""])("treats HOMEPAGE_AUTH_ENABLED=%j as disabled", async (value) => { @@ -107,7 +107,7 @@ describe("middleware", () => { const res = await middleware(createReq("localhost:3000", "http://localhost:3000/some")); expect(getToken).not.toHaveBeenCalled(); - expect(res).toEqual({ type: "next" }); + expect(res.type).toBe("next"); }); it("redirects to signin when auth is enabled and no token is present", async () => { @@ -138,7 +138,39 @@ describe("middleware", () => { const res = await middleware(createReq("localhost:3000", "http://localhost:3000/")); expect(NextResponse.next).toHaveBeenCalled(); - expect(res).toEqual({ type: "next" }); + expect(res.type).toBe("next"); + }); + + it("marks responses private so shared caches cannot store them when auth is enabled", async () => { + process.env.HOMEPAGE_AUTH_ENABLED = "true"; + process.env.HOMEPAGE_AUTH_SECRET = "secret"; + + getToken.mockResolvedValueOnce({ sub: "user" }); + + const middleware = await loadMiddleware(); + const res = await middleware(createReq("localhost:3000", "http://localhost:3000/")); + + expect(res.headers.get("Cache-Control")).toBe("private, no-store"); + }); + + it("marks the signin redirect private as well", async () => { + process.env.HOMEPAGE_AUTH_ENABLED = "true"; + process.env.HOMEPAGE_AUTH_SECRET = "secret"; + + getToken.mockResolvedValueOnce(null); + + const middleware = await loadMiddleware(); + const res = await middleware(createReq("localhost:3000", "http://localhost:3000/some")); + + expect(res.type).toBe("redirect"); + expect(res.headers.get("Cache-Control")).toBe("private, no-store"); + }); + + it("leaves cache headers alone when auth is disabled", async () => { + const middleware = await loadMiddleware(); + const res = await middleware(createReq("localhost:3000", "http://localhost:3000/")); + + expect(res.headers.get("Cache-Control")).toBeNull(); }); it("delegates MCP authorization to the API handler", async () => { @@ -150,6 +182,6 @@ describe("middleware", () => { expect(getToken).not.toHaveBeenCalled(); expect(NextResponse.next).toHaveBeenCalled(); - expect(res).toEqual({ type: "next" }); + expect(res.type).toBe("next"); }); });