Answer stability round 1: 0 🔴, 4 🟡 (0 misread); test the guards the split rewrote
CI / full-gate (push) Successful in 3m26s
Mirror / github-mirror (push) Successful in 3s

This commit was merged in pull request #222.
This commit is contained in:
2026-11-08 20:33:19 +01:00
parent ea6c32a7db
commit 9ded502be3
3 changed files with 125 additions and 7 deletions
+52 -3
View File
@@ -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<typeof createApp>[0], cookie: string): Promise<Response> => {
const app = createApp({ jwks: staticJwks([ecJwk]), log, plugins: [demoPlugin], ...options });
await new Promise<void>((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<typeof check>[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/);
+27 -3
View File
@@ -15,13 +15,15 @@ async function requestLineOf(lines: string[]): Promise<Record<string, unknown>>
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<void>((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, []);
});
+46 -1
View File
@@ -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<void>((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<string> {
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<void>((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 () => {