96 lines
4.0 KiB
TypeScript
96 lines
4.0 KiB
TypeScript
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
|
|
// The page's unload path (apps/web/src/ui/client.ts `beacon`): a call made as the page goes away is
|
|
// a same-origin keep-alive POST to the procedure's mount with the page's CSRF token, and the
|
|
// server's real CSRF wall admits exactly that request for this session and refuses it for another.
|
|
// The browser half (keep-alive after a navigation and a closed tab) is proven in a real browser by
|
|
// @conusai-kit/guides' Playwright suite (test-browser/beacon.spec.ts).
|
|
|
|
import type { Principal } from "@conusai-kit/auth"
|
|
import { afterEach, beforeAll, describe, expect, it } from "vitest"
|
|
|
|
const ORIGIN = "https://app.example.test"
|
|
const session = (sessionId: string): Principal => ({
|
|
actor: { userId: "11111111-1111-1111-1111-111111111111" },
|
|
method: "session",
|
|
sessionId,
|
|
})
|
|
|
|
let csrfToken: (sessionId: string) => string
|
|
let requireCsrf: (request: Request, principal: Principal | null) => void
|
|
let beacon: (procedure: string, input: unknown) => boolean
|
|
|
|
beforeAll(async () => {
|
|
process.env.AUTH_SESSION_SECRET = "0123456789abcdef0123456789abcdef"
|
|
process.env.CONUSAI_PUBLIC_URL = ORIGIN
|
|
process.env.OIDC_ISSUER = "https://id.example.test"
|
|
process.env.OIDC_WEB_CLIENT_ID = "web"
|
|
process.env.OIDC_AUDIENCE = "api"
|
|
const module = await import("../apps/web/src/server/csrf")
|
|
csrfToken = module.csrfToken
|
|
requireCsrf = module.requireCsrf
|
|
// Imported before any page global is stubbed: the beacon reads the page at call time, and the UI
|
|
// kit's dependencies set up browser listeners on import when they see a window.
|
|
beacon = (await import("../apps/web/src/ui/client")).beacon
|
|
})
|
|
|
|
const globals = globalThis as unknown as { window?: unknown; document?: unknown; fetch: typeof fetch }
|
|
const realFetch = globals.fetch
|
|
afterEach(() => {
|
|
globals.fetch = realFetch
|
|
delete globals.window
|
|
delete globals.document
|
|
})
|
|
|
|
/** A page of this app holding `token` in its CSRF meta tag; returns what the page's fetch was handed. */
|
|
function page(token: string): Array<[URL, RequestInit]> {
|
|
const sent: Array<[URL, RequestInit]> = []
|
|
globals.window = { location: { href: `${ORIGIN}/acme/main/notes/notes`, origin: ORIGIN } }
|
|
globals.document = {
|
|
querySelector: (selector: string) =>
|
|
selector === 'meta[name="csrf-token"]'
|
|
? { getAttribute: (name: string) => (name === "content" ? token : null) }
|
|
: null,
|
|
}
|
|
globals.fetch = ((url: URL, init: RequestInit) => {
|
|
sent.push([url, init])
|
|
return Promise.resolve(new Response(null, { status: 200 }))
|
|
}) as typeof fetch
|
|
return sent
|
|
}
|
|
|
|
describe("the page's beacon", () => {
|
|
it("is a same-origin keep-alive POST to the procedure that the CSRF wall admits for this session only", () => {
|
|
const sent = page(csrfToken("session-1"))
|
|
const input = {
|
|
org: "acme",
|
|
idempotencyKey: "guides:1",
|
|
events: [{ schema_version: 1, event_id: "e1", type: "track" }],
|
|
}
|
|
expect(beacon("guides.runtime.record", input)).toBe(true)
|
|
expect(sent).toHaveLength(1)
|
|
const [url, init] = sent[0] as [URL, RequestInit]
|
|
expect(url.href).toBe(`${ORIGIN}/api/v1/guides.runtime.record`)
|
|
expect(init).toMatchObject({
|
|
method: "POST",
|
|
keepalive: true,
|
|
credentials: "same-origin",
|
|
body: JSON.stringify(input),
|
|
})
|
|
// What the server receives: the page's headers, plus the fetch metadata the browser adds to a
|
|
// request of this page's own origin.
|
|
const request = new Request(url, {
|
|
method: "POST",
|
|
headers: { ...(init.headers as Record<string, string>), "sec-fetch-site": "same-origin" },
|
|
body: init.body ?? null,
|
|
})
|
|
expect(() => requireCsrf(request, session("session-1"))).not.toThrow()
|
|
expect(() => requireCsrf(request, session("session-2"))).toThrow(/x-csrf-token/)
|
|
})
|
|
|
|
it("is declined when the body would not fit the keep-alive budget, so the engine keeps the batch", () => {
|
|
const sent = page(csrfToken("session-1"))
|
|
expect(beacon("guides.runtime.record", { padding: "x".repeat(70_000) })).toBe(false)
|
|
expect(sent).toHaveLength(0)
|
|
})
|
|
})
|