diff --git a/src/http/app-plugins.test.ts b/src/http/app-plugins.test.ts index 2105737..1f5309c 100644 --- a/src/http/app-plugins.test.ts +++ b/src/http/app-plugins.test.ts @@ -13,6 +13,7 @@ import { staticJwks } from "../auth/jwks.ts"; import type { Identity } from "../auth/kratos-admin.ts"; import { KratosError, type Session } from "../auth/kratos-public.ts"; import { SESSION_COOKIE } from "../auth/login.ts"; +import { createLogger } from "../logger.ts"; import type { Plugin } from "../plugin-host/plugin.ts"; import { ecJwk, fakeKeto, mintJwt, startApp, stubAdmin, withWhoami } from "../test-tooling/app-harness.ts"; @@ -224,6 +225,48 @@ test("session re-mint: an expired JWT backed by a live Kratos session is silentl assert.equal(outage.headers.get("set-cookie"), null); }); +test("session re-mint runs only for an expired token with all three Ory clients wired; the new cookie is Secure under SECURE_COOKIES", async (t) => { + const identity: Identity = { id: "u1", traits: { email: "a@b.c" } }; + const nowSec = Math.floor(Date.now() / 1000); + const freshJwt = mintJwt({ email: "a@b.c", exp: nowSec + 600, permissions: ["demo:read"], sub: "u1" }); + let whoamiCalls = 0; + const kratos = withWhoami(async (o) => { whoamiCalls++; return (o?.tokenizeAs ? { active: true, identity, tokenized: freshJwt } : { active: true, identity }) as Session; }); + const keto = fakeKeto([{ namespace: "Permission", object: "demo:read", relation: "granted", subject_id: "user:u1" }], { check: async () => true }); + const kratosAdmin = stubAdmin({}); + const logged: string[] = []; + const log = createLogger({ format: "json", level: "info", stderr: (l) => logged.push(l), stdout: (l) => logged.push(l) }); + const valid = `${SESSION_COOKIE}=${freshJwt}`; + const expired = `${SESSION_COOKIE}=${mintJwt({ email: "a@b.c", exp: nowSec - 600, permissions: ["demo:read"], sub: "u1" })}`; + const get = async (options: Parameters[0], cookie: string): Promise => { + const app = createApp({ jwks: staticJwks([ecJwk]), log, plugins: [demoPlugin], ...options }); + await new Promise((r) => app.listen(0, r)); + t.after(() => app.close()); + return fetch(`http://localhost:${(app.address() as AddressInfo).port}/demo/secret`, { headers: { cookie }, redirect: "manual" }); + }; + const sessionCookieOf = (res: Response): string | undefined => res.headers.getSetCookie().find((c) => c.startsWith(`${SESSION_COOKIE}=`)); + + // A valid token, or one that is invalid but not expired, never reaches Ory. + const ok = await get({ keto, kratos, kratosAdmin }, valid); + assert.equal(ok.status, 200); + assert.equal(sessionCookieOf(ok), undefined); + assert.equal((await get({ keto, kratos, kratosAdmin }, `${SESSION_COOKIE}=not-a-jwt`)).status, 303); + assert.equal(whoamiCalls, 0); + + // An expired token with any one of the three clients missing stays anonymous, without trying. + for (const wired of [{ kratos, kratosAdmin }, { keto, kratosAdmin }, { keto, kratos }]) { + const res = await get(wired, expired); + assert.equal(res.status, 303, Object.keys(wired).join("+")); + assert.equal(sessionCookieOf(res), undefined); + } + assert.equal(whoamiCalls, 0); + assert.ok(!logged.some((l) => l.includes("session re-mint failed")), "no re-mint was attempted"); + + // All three wired on an https deployment: the re-minted cookie carries Secure. + const reminted = await get({ keto, kratos, kratosAdmin, secureCookies: true }, expired); + assert.equal(reminted.status, 200); + assert.match(sessionCookieOf(reminted) ?? "", /; Secure(;|$)/); +}); + test("guards map to responses: requireSession → /login, a failed can/check → 403, success runs the handler", async (t) => { const keto = { check: async (tuple: { object: string }) => tuple.object === "open" } as unknown as Parameters[0]; const guarded: Plugin = { @@ -285,16 +328,22 @@ test("plugin hooks: onRequest can short-circuit a request and onResponse observe onResponse: (c, r) => void seen.push(`${c.url.pathname}:${r && "html" in r ? r.html : "?"}`), }, id: "hooked", - routes: [{ handler: () => ({ html: "handler ran" }), method: "GET", path: "/ok" }], + routes: [ + { handler: () => ({ html: "handler ran" }), method: "GET", path: "/ok" }, + { handler: () => { seen.push("blocked handler ran"); return { html: "behind the hook" }; }, method: "GET", path: "/blocked" }, + ], }; const url = await startApp(t, [hooked]); - // onRequest short-circuits before routing — handler never runs; a fresh CSRF cookie still rides the - // response so a form the hook renders has its matching double-submit cookie. + // onRequest short-circuits before routing — the route behind it never runs; a fresh CSRF cookie + // still rides the response so a form the hook renders has its matching double-submit cookie. const blocked = await fetch(url + "/hooked/blocked"); assert.equal(blocked.status, 403); assert.match(await blocked.text(), /blocked by hook/); assert.match(blocked.headers.get("set-cookie") ?? "", /plainpages_csrf=/); + // Again with that cookie, so no Set-Cookie is due once the response is sent. + await (await fetch(url + "/hooked/blocked", { headers: { cookie: blocked.headers.get("set-cookie")?.split(";")[0] ?? "" } })).text(); + assert.ok(!seen.includes("blocked handler ran")); // A normal route runs the handler; onResponse observed its result. assert.match(await (await fetch(url + "/hooked/ok")).text(), /handler ran/); diff --git a/src/http/app-request-log.test.ts b/src/http/app-request-log.test.ts index 869111e..8b16905 100644 --- a/src/http/app-request-log.test.ts +++ b/src/http/app-request-log.test.ts @@ -15,13 +15,15 @@ async function requestLineOf(lines: string[]): Promise> throw new Error("no request line was logged"); } -async function serve(t: TestContext, trustedProxies: string[], plugins: Plugin[] = []): Promise<{ lines: string[]; url: string }> { +// `lines` is stdout (info); `problems` is stderr (warn and error). +async function serve(t: TestContext, trustedProxies: string[], plugins: Plugin[] = []): Promise<{ lines: string[]; problems: string[]; url: string }> { const lines: string[] = []; - const log = createLogger({ format: "json", level: "info", stderr: () => {}, stdout: (l: string) => lines.push(l) }); + const problems: string[] = []; + const log = createLogger({ format: "json", level: "info", stderr: (l: string) => problems.push(l), stdout: (l: string) => lines.push(l) }); const app = createApp({ log, plugins, trustedProxies }); await new Promise((r) => app.listen(0, r)); t.after(() => app.close()); - return { lines, url: `http://localhost:${(app.address() as AddressInfo).port}` }; + return { lines, problems, url: `http://localhost:${(app.address() as AddressInfo).port}` }; } test("the request line names the caller, the site and the matched route in OpenTelemetry's terms", async (t) => { @@ -64,3 +66,25 @@ test("a listed proxy lets X-Forwarded-For name the caller; an unmatched path car assert.equal(line["http.response.status_code"], 404); assert.equal(line["http.route"], undefined); }); + +test("an answered request goes no further: a refused visitor never reaches the handler, and nothing logs a problem", async (t) => { + const ran: string[] = []; + const plugin: Plugin = { + apiVersion: "1.0.0", + id: "demo", + routes: [ + { handler: () => ({ html: "open" }), method: "GET", path: "/open", public: true }, + { handler: () => { ran.push("gated"); return { html: "gated" }; }, method: "GET", path: "/gated", permission: "demo:read" }, + ], + }; + const { lines, problems, url } = await serve(t, [], [plugin]); + // A plugin route, a refused plugin route, a built-in route, a refused built-in route, an asset. + const paths = ["/demo/open", "/demo/gated", "/", "/dashboard", "/public/css/styles.css"]; + for (const path of paths) await (await fetch(url + path, { redirect: "manual" })).text(); + // A request that fell through to a later stage would log its error after its response closed. + while (lines.filter((l) => l.includes('"request"')).length < paths.length) await new Promise((r) => setTimeout(r, 10)); + await new Promise((r) => setTimeout(r, 100)); + + assert.deepEqual(ran, []); + assert.deepEqual(problems, []); +}); diff --git a/src/http/app.test.ts b/src/http/app.test.ts index 4b89d0f..9ec296f 100644 --- a/src/http/app.test.ts +++ b/src/http/app.test.ts @@ -2,7 +2,7 @@ import assert from "node:assert/strict"; import { randomUUID } from "node:crypto"; import { cpSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { request as httpRequest } from "node:http"; -import type { AddressInfo } from "node:net"; +import { type AddressInfo, connect } from "node:net"; import { tmpdir } from "node:os"; import { dirname, join } from "node:path"; import { after, before, test } from "node:test"; @@ -214,6 +214,9 @@ test("static serving: GET sends body + content-type, HEAD headers only, unsafe p // Encoded traversal and a NUL byte are refused before touching the filesystem. assert.equal((await fetch(base + "/public/..%2f..%2fapp.ts")).status, 403); assert.equal((await fetch(base + "/public/%00")).status, 403); + + // Only GET/HEAD read an asset; any other method is routed like a page, and nothing answers it. + assert.equal((await fetch(base + "/public/css/styles.css", { method: "POST" })).status, 404); }); test("every response carries the security headers; HSTS follows SECURE_COOKIES", async (t) => { @@ -235,6 +238,20 @@ test("every response carries the security headers; HSTS follows SECURE_COOKIES", assert.match(res.headers.get("strict-transport-security") ?? "", /max-age=\d+/); }); +test("the CSRF cookie is set only when the request carries no valid token, and is Secure under SECURE_COOKIES", async (t) => { + const first = await fetch(base + "/"); + const csrf = first.headers.getSetCookie().find((c) => c.startsWith(`${CSRF_COOKIE}=`)) ?? ""; + assert.ok(csrf, "a first visit mints the token"); + assert.doesNotMatch(csrf, /; Secure/); // http dev + assert.deepEqual((await fetch(base + "/", { headers: { cookie: csrf.split(";")[0] ?? "" } })).headers.getSetCookie(), []); + + const secure = createApp({ secureCookies: true }); + await new Promise((r) => secure.listen(0, r)); + t.after(() => secure.close()); + const res = await fetch(`http://localhost:${(secure.address() as AddressInfo).port}/`); + assert.match(res.headers.getSetCookie().find((c) => c.startsWith(`${CSRF_COOKIE}=`)) ?? "", /; Secure(;|$)/); +}); + test("Cache-Control: pages and JSON are private, no-store unless the route sets its own", async (t) => { const NO_STORE = "private, no-store"; const csrf = (await fetch(base + "/")).headers.get("set-cookie")?.split(";")[0] ?? ""; @@ -305,6 +322,18 @@ function rawGet(port: number, path: string, host: string, method = "GET"): Promi }); } +// An HTTP/1.0 request with no Host header (Node refuses a Host-less HTTP/1.1 one with a 400); +// resolves to the raw response head. +function rawHttp10(port: number, path: string): Promise { + return new Promise((resolve, reject) => { + let raw = ""; + const socket = connect(port, "127.0.0.1", () => socket.write(`GET ${path} HTTP/1.0\r\n\r\n`)); + socket.on("data", (c) => (raw += c)); + socket.on("end", () => resolve(raw)); + socket.on("error", reject); + }); +} + test("APP_URL canonical-host redirect: an off-host visitor is 308'd to the configured origin (path+query kept)", async (t) => { // Reach the app on any host and it sends you to APP_URL's, so the browser, the themed form and the // cross-origin Kratos POST share ONE cookie host. Same-host requests pass straight through. @@ -329,6 +358,22 @@ test("APP_URL canonical-host redirect: an off-host visitor is 308'd to the confi // A 308 must not replay a cross-host POST — non-GET/HEAD is left alone (not canonicalised). const post = await rawGet(port, "/dashboard", `127.0.0.1:${port}`, "POST"); assert.notEqual(post.status, 308); + + // HEAD is canonicalised like GET. + const head = await rawGet(port, "/dashboard", `127.0.0.1:${port}`, "HEAD"); + assert.equal(head.status, 308); + assert.equal(head.location, "http://canonical.example:3000/dashboard"); + + // An HTTP/1.0 request may omit Host; with no host to compare it is served as-is. + assert.match(await rawHttp10(port, "/missing"), /^HTTP\/1\.[01] 404 /); +}); + +test("an APP_URL with no host (file:///srv) ⇒ no canonical redirect", async (t) => { + const app = createApp({ appUrl: "file:///srv" }); + await new Promise((r) => app.listen(0, r)); + t.after(() => app.close()); + const port = (app.address() as AddressInfo).port; + assert.equal((await rawGet(port, "/missing", `127.0.0.1:${port}`)).status, 404); }); test("no APP_URL configured ⇒ no canonical redirect (unit-test apps and host-agnostic deploys unaffected)", async () => {