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:
+4
-3
@@ -8,9 +8,10 @@ at boot. Entries start at 0.3.0.
|
|||||||
|
|
||||||
### A verification or recovery code pasted with whitespace around it is accepted
|
### 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
|
The code field on the verification and recovery pages takes a code with spaces or tabs around it.
|
||||||
a paste from an email often brings. Before, the browser refused it. Those two forms now post to
|
Pasting a code from an email often adds them, and before, the browser refused the code. Those two
|
||||||
Plainpages, which trims the code and passes the form on to Kratos at `KRATOS_PUBLIC_URL`.
|
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()`
|
### A filter shows its selected and focused choice in browsers without `:has()`
|
||||||
|
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ export interface UiNode {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface FlowUi {
|
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[];
|
messages?: UiText[];
|
||||||
method: string;
|
method: string;
|
||||||
nodes: UiNode[];
|
nodes: UiNode[];
|
||||||
@@ -117,9 +117,9 @@ export function createKratosPublic(config: { baseUrl: string; fetchImpl?: typeof
|
|||||||
async submitFlow(type, flowId, opts) {
|
async submitFlow(type, flowId, opts) {
|
||||||
const url = new URL(`${base}/self-service/${type}`);
|
const url = new URL(`${base}/self-service/${type}`);
|
||||||
url.searchParams.set("flow", flowId);
|
url.searchParams.set("flow", flowId);
|
||||||
// Asked for HTML, Kratos answers with the redirects a browser gets, so every outcome stays Kratos'.
|
// Asked for HTML, Kratos answers with the redirect a browser posting to it would get. Its CSRF
|
||||||
// Its nosurf wants a Referer, absent here, only over https: valid while KRATOS_PUBLIC_URL is http.
|
// 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" };
|
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" });
|
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());
|
if (res.status >= 500) throw new KratosError(`Kratos submit ${type} flow failed (${res.status})`, res.status, await res.text());
|
||||||
await res.body?.cancel();
|
await res.body?.cancel();
|
||||||
|
|||||||
+10
-6
@@ -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
|
// A code flow's POST (flow-view.ts → CODE_FLOWS). Trims the pasted one-time code, since Kratos
|
||||||
// refuse with its surrounding whitespace, and relay the rest to Kratos and its redirect back. Kratos'
|
// refuses one with whitespace around it, then passes the form on to Kratos and sends the browser
|
||||||
// own csrf_token field guards the POST, so the host checks no token of its own.
|
// where Kratos redirects. Kratos' csrf_token field guards the POST, so the host's own CSRF token
|
||||||
function codeFlowSubmit(kratos: KratosPublic, flowType: FlowType): BuiltinRoute["handler"] {
|
// isn't checked here.
|
||||||
|
function codeFlowSubmit(kratos: KratosPublic, flowType: FlowType, secureCookies: boolean): BuiltinRoute["handler"] {
|
||||||
return async (ctx: RequestContext): Promise<RouteResult> => {
|
return async (ctx: RequestContext): Promise<RouteResult> => {
|
||||||
const pathname = ctx.url.pathname;
|
const pathname = ctx.url.pathname;
|
||||||
const flowId = ctx.url.searchParams.get("flow");
|
const flowId = ctx.url.searchParams.get("flow");
|
||||||
@@ -133,7 +134,10 @@ function codeFlowSubmit(kratos: KratosPublic, flowType: FlowType): BuiltinRoute[
|
|||||||
return flowFailure(err, ctx, flowType);
|
return flowFailure(err, ctx, flowType);
|
||||||
}
|
}
|
||||||
if (submitted.setCookie.length) ctx.res.appendHeader("set-cookie", submitted.setCookie);
|
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) {
|
if (kratos) {
|
||||||
for (const [path, flowType] of Object.entries(AUTH_FLOWS)) {
|
for (const [path, flowType] of Object.entries(AUTH_FLOWS)) {
|
||||||
routes.push({ handler: flowPage(kratos, flowType, secureCookies), method: "GET", path, public: true });
|
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 });
|
routes.push({ handler: logout(kratos, secureCookies), method: "POST", path: "/logout", public: true });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) => {
|
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 }[] = [];
|
const calls: { body: string; cookie: string | undefined; flowId: string; type: FlowType }[] = [];
|
||||||
let answer: () => Promise<FlowSubmission> = async () =>
|
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(noFlow.headers.get("location"), "/recovery");
|
||||||
assert.equal(calls.length, 2);
|
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.
|
// Kratos answered without a redirect → back to the flow, whose page shows its state.
|
||||||
answer = async () => ({ location: null, setCookie: [], status: 400 });
|
answer = async () => ({ location: null, setCookie: [], status: 400 });
|
||||||
assert.equal((await post("/recovery?flow=f1", "code=1")).headers.get("location"), "/recovery?flow=f1");
|
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"); };
|
answer = async () => { throw new TypeError("fetch failed"); };
|
||||||
assert.equal((await post("/recovery?flow=f1", "code=1")).status, 503);
|
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);
|
assert.notEqual((await post("/login?flow=f1", "x=1")).status, 303);
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user