Answer product-owner round 1: 0 🔴, 2 🟡 (0 misread); the submit sends a same-origin Referer, Kratos' redirect to this host keeps the chosen locale

This commit is contained in:
2026-10-31 12:59:28 +01:00
parent 29d496a02e
commit 053438f968
4 changed files with 23 additions and 17 deletions
+4 -3
View File
@@ -8,9 +8,10 @@ at boot. Entries start at 0.3.0.
### A verification or recovery code pasted with whitespace around it is accepted
The code field on the verification and recovery pages takes a code with spaces or tabs around it, as
a paste from an email often brings. Before, the browser refused it. Those two forms now post to
Plainpages, which trims the code and passes the form on to Kratos at `KRATOS_PUBLIC_URL`.
The code field on the verification and recovery pages takes a code with spaces or tabs around it.
Pasting a code from an email often adds them, and before, the browser refused the code. Those two
forms now post to Plainpages, which trims the code and passes the form on to Kratos at
`KRATOS_PUBLIC_URL`. A visitor who picked a language now keeps it through both flows.
### A filter shows its selected and focused choice in browsers without `:has()`
+4 -4
View File
@@ -21,7 +21,7 @@ export interface UiNode {
}
export interface FlowUi {
action: string; // absolute Kratos URL the browser POSTs the form to (Kratos owns its CSRF)
action: string; // absolute Kratos URL the form submits to
messages?: UiText[];
method: string;
nodes: UiNode[];
@@ -117,9 +117,9 @@ export function createKratosPublic(config: { baseUrl: string; fetchImpl?: typeof
async submitFlow(type, flowId, opts) {
const url = new URL(`${base}/self-service/${type}`);
url.searchParams.set("flow", flowId);
// Asked for HTML, Kratos answers with the redirects a browser gets, so every outcome stays Kratos'.
// Its nosurf wants a Referer, absent here, only over https: valid while KRATOS_PUBLIC_URL is http.
const h = { ...headers(opts.cookie), accept: "text/html", "content-type": "application/x-www-form-urlencoded" };
// Asked for HTML, Kratos answers with the redirect a browser posting to it would get. Its CSRF
// check demands a same-origin Referer on an https request, and this server-side POST has none.
const h = { ...headers(opts.cookie), accept: "text/html", "content-type": "application/x-www-form-urlencoded", referer: `${base}/` };
const res = await http(url, { body: opts.body, headers: h, method: "POST", redirect: "manual" });
if (res.status >= 500) throw new KratosError(`Kratos submit ${type} flow failed (${res.status})`, res.status, await res.text());
await res.body?.cancel();
+10 -6
View File
@@ -114,10 +114,11 @@ function flowPage(kratos: KratosPublic, flowType: FlowType, secureCookies: boole
};
}
// A code flow's POST (flow-view.ts → CODE_FLOWS): trim the pasted one-time code, which Kratos would
// refuse with its surrounding whitespace, and relay the rest to Kratos and its redirect back. Kratos'
// own csrf_token field guards the POST, so the host checks no token of its own.
function codeFlowSubmit(kratos: KratosPublic, flowType: FlowType): BuiltinRoute["handler"] {
// A code flow's POST (flow-view.ts → CODE_FLOWS). Trims the pasted one-time code, since Kratos
// refuses one with whitespace around it, then passes the form on to Kratos and sends the browser
// where Kratos redirects. Kratos' csrf_token field guards the POST, so the host's own CSRF token
// isn't checked here.
function codeFlowSubmit(kratos: KratosPublic, flowType: FlowType, secureCookies: boolean): BuiltinRoute["handler"] {
return async (ctx: RequestContext): Promise<RouteResult> => {
const pathname = ctx.url.pathname;
const flowId = ctx.url.searchParams.get("flow");
@@ -133,7 +134,10 @@ function codeFlowSubmit(kratos: KratosPublic, flowType: FlowType): BuiltinRoute[
return flowFailure(err, ctx, flowType);
}
if (submitted.setCookie.length) ctx.res.appendHeader("set-cookie", submitted.setCookie);
return { redirect: submitted.location ?? `${pathname}?flow=${encodeURIComponent(flowId)}` };
// Kratos names our own pages by absolute URL; made host-relative, the redirect keeps a chosen locale.
const own = selfOrigin(ctx, secureCookies);
const location = submitted.location?.startsWith(`${own}/`) ? submitted.location.slice(own.length) : submitted.location;
return { redirect: location ?? `${pathname}?flow=${encodeURIComponent(flowId)}` };
};
}
@@ -273,7 +277,7 @@ export function buildAuthRoutes({ hydra, keto, kratos, kratosAdmin, menu, secure
if (kratos) {
for (const [path, flowType] of Object.entries(AUTH_FLOWS)) {
routes.push({ handler: flowPage(kratos, flowType, secureCookies), method: "GET", path, public: true });
if (CODE_FLOWS.has(flowType)) routes.push({ handler: codeFlowSubmit(kratos, flowType), method: "POST", path, public: true });
if (CODE_FLOWS.has(flowType)) routes.push({ handler: codeFlowSubmit(kratos, flowType, secureCookies), method: "POST", path, public: true });
}
routes.push({ handler: logout(kratos, secureCookies), method: "POST", path: "/logout", public: true });
}
+5 -4
View File
@@ -389,9 +389,6 @@ test("OAuth2 challenge endpoints degrade identically: stale Hydra 4xx → 400, o
}
});
// The code flows post to the host, which trims the code (a pasted one often carries whitespace that
// Kratos would refuse) and relays the rest of the browser's POST to Kratos verbatim.
test("code-flow POST: the host trims the code, relays the POST to Kratos and Kratos' redirect + cookies back", async (t) => {
const calls: { body: string; cookie: string | undefined; flowId: string; type: FlowType }[] = [];
let answer: () => Promise<FlowSubmission> = async () =>
@@ -423,6 +420,10 @@ test("code-flow POST: the host trims the code, relays the POST to Kratos and Kra
assert.equal(noFlow.headers.get("location"), "/recovery");
assert.equal(calls.length, 2);
// Kratos' absolute redirect to this host becomes host-relative, so it keeps the chosen locale.
answer = async () => ({ location: `${url}/settings?flow=s1`, setCookie: [], status: 303 });
assert.equal((await post("/recovery?flow=f1&locale=en-US", "code=1")).headers.get("location"), "/settings?flow=s1&locale=en-US");
// Kratos answered without a redirect → back to the flow, whose page shows its state.
answer = async () => ({ location: null, setCookie: [], status: 400 });
assert.equal((await post("/recovery?flow=f1", "code=1")).headers.get("location"), "/recovery?flow=f1");
@@ -433,6 +434,6 @@ test("code-flow POST: the host trims the code, relays the POST to Kratos and Kra
answer = async () => { throw new TypeError("fetch failed"); };
assert.equal((await post("/recovery?flow=f1", "code=1")).status, 503);
// Sign-in still posts straight to Kratos: the host takes no POST there.
// Sign-in posts straight to Kratos, so the host has no POST /login.
assert.notEqual((await post("/login?flow=f1", "x=1")).status, 303);
});