fix(ui): keep private and no-store responses out of offline cache

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Devin FoleyandPaperclip committed 2026-09-19 08:57:49 -07:00
1 parent be9c775d9e
commit 46321e2421
2 files changed
+48 -3

No files matched your search

+5 -3
View File
@@ -24,8 +24,9 @@ self.addEventListener("fetch", (event) => {
const { request } = event;
const url = new URL(request.url);
// Skip non-GET requests and API calls
if (request.method !== "GET" || url.pathname.startsWith("/api")) {
// Explicitly private requests must bypass BOTH cache writes and offline
// fallback, including extension endpoints outside the host /api namespace.
if (request.method !== "GET" || url.pathname.startsWith("/api") || request.cache === "no-store") {
return;
}
@@ -33,7 +34,8 @@ self.addEventListener("fetch", (event) => {
event.respondWith(
fetch(request)
.then((response) => {
if (response.ok && url.origin === self.location.origin) {
const cacheControl = response.headers.get("cache-control") ?? "";
if (response.ok && url.origin === self.location.origin && !/(?:^|,)\s*(?:no-store|private)(?:\s*(?:,|=)|\s*$)/i.test(cacheControl)) {
const clone = response.clone();
caches.open(CACHE_NAME).then((cache) => cache.put(request, clone));
}
@@ -0,0 +1,43 @@
// @vitest-environment node
import { readFileSync } from "node:fs";
import vm from "node:vm";
import { describe, expect, it, vi } from "vitest";
function worker() {
const handlers = new Map<string, (event: unknown) => void>();
const put = vi.fn();
const match = vi.fn();
const fetch = vi.fn().mockResolvedValue(new Response("public asset"));
vm.runInNewContext(readFileSync(new URL("../../public/sw.js", import.meta.url), "utf8"), {
self: { location: { origin: "https://example.test" }, addEventListener: (type: string, fn: (event: unknown) => void) => handlers.set(type, fn) },
URL, Response, fetch, caches: { open: async () => ({ put }), match },
});
const request = (cache: RequestCache = "default") => {
const respondWith = vi.fn();
handlers.get("fetch")!({ request: new Request("https://example.test/extension/history", { cache }), respondWith });
return respondWith;
};
return { request, fetch, put, match };
}
describe("service worker privacy boundaries", () => {
it("bypasses both caching and offline fallback for a no-store request outside /api", () => {
const w = worker();
expect(w.request("no-store")).not.toHaveBeenCalled();
expect(w.fetch).not.toHaveBeenCalled();
expect(w.match).not.toHaveBeenCalled();
expect(w.put).not.toHaveBeenCalled();
});
it.each(["no-store", "max-age=0, no-store", "private", 'private="Set-Cookie"', "PRIVATE, max-age=60"])("never caches a response marked %s", async directive => {
const w = worker();
w.fetch.mockResolvedValue(new Response("personal content", { headers: { "cache-control": directive } }));
const response = await w.request().mock.calls[0]![0];
expect(await response.text()).toBe("personal content");
expect(w.put).not.toHaveBeenCalled();
});
it("keeps public asset offline caching", async () => {
const w = worker();
await w.request().mock.calls[0]![0];
expect(w.put).toHaveBeenCalledOnce();
});
});