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 -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 });
}