Answer stability round 1: 0 🔴, 4 🟡 (0 misread); test the guards the split rewrote
This commit was merged in pull request #222.
This commit is contained in:
@@ -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/);
|
||||
|
||||
@@ -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
@@ -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 () => {
|
||||
|
||||
Reference in New Issue
Block a user