From a99bacc0c90ca7a7f879cec8a8fb33f4aa14e84f Mon Sep 17 00:00:00 2001 From: tv Date: Sat, 7 Nov 2026 22:35:22 +0100 Subject: auth: the session cookie lets in reads only The cookie lets in GET, HEAD and the session's own requests; anything changing something brings the token itself, which no page of another site or port can make a browser send. The dashboard's writes send it. Auth plan M3 (browsers). --- docs/access.md | 5 ++++- docs/openapi.yaml | 3 ++- src/api/dashboard/dashboard.html | 22 ++++++++++++++-------- src/api/login.rs | 15 +++++++++++++-- 4 files changed, 33 insertions(+), 12 deletions(-) diff --git a/docs/access.md b/docs/access.md index e3dec0b..567cebd 100644 --- a/docs/access.md +++ b/docs/access.md @@ -128,7 +128,10 @@ no keys (`409`). A browser keeps a token as its session: `POST /auth/session`, with a token, answers as `/auth/refresh` does and sets a cookie holding the new one (`HttpOnly`, `SameSite=Strict`: no script reads it, no other site's page sends it), which lets in -requests without `Authorization`; `DELETE /auth/session` forgets it. The login page +requests without `Authorization` that read (`GET`, `HEAD`) or renew or end the session; one +that changes anything brings the token itself, which no other page, of another site or +of another port of the same host, can make a browser send. `DELETE /auth/session` +forgets it. The login page (`/login`, without authentication, with the dashboard) begins one by password or from a token pasted, from a login by key, say; the dashboard renews it while open ([`dashboard.md`](dashboard.md)). diff --git a/docs/openapi.yaml b/docs/openapi.yaml index bb1d34b..7c8bb84 100644 --- a/docs/openapi.yaml +++ b/docs/openapi.yaml @@ -902,7 +902,8 @@ paths: description: | As `POST /auth/refresh`, and the new token set as the session cookie (`continuum-token`, `HttpOnly`, `SameSite=Strict`, for as long as the token is - valid): requests without `Authorization` that bring it are its user's. + valid): requests without `Authorization` that bring it are its user's, if they + read (`GET`, `HEAD`) or renew or end the session; others bring the token themselves. security: - bearer: [] requestBody: diff --git a/src/api/dashboard/dashboard.html b/src/api/dashboard/dashboard.html index 8146f32..601b578 100644 --- a/src/api/dashboard/dashboard.html +++ b/src/api/dashboard/dashboard.html @@ -1592,7 +1592,7 @@ function editor(path, value, done, options = {}) { : null; if (extra) extra.value = options.extra.initial || ""; const send = options.send || ((body, type) => fetch(storeUrl(path, false), - { method: "PUT", body, headers: { "Content-Type": type } })); + withToken({ method: "PUT", body, headers: { "Content-Type": type } }))); const save = el("button", { type: "button" }, options.extra ? "Add" : "Save"); const cancel = el("button", { type: "button" }, "Cancel"); const submit = async () => { @@ -1642,7 +1642,7 @@ function editor(path, value, done, options = {}) { message.className = "note"; message.textContent = "Deleting…"; try { - const response = await fetch(storeUrl(path, false), { method: "DELETE" }); + const response = await fetch(storeUrl(path, false), withToken({ method: "DELETE" })); if (!response.ok) { let why = response.status === 403 ? "not allowed" : ""; try { why = (await response.json()).error || why; } catch { /* no body */ } @@ -1728,7 +1728,7 @@ function addButton(kind, path, known, place) { : (/^(-1|\d+)$/.test(extra.trim()) ? null : "The index: 0 or more, or -1 for the end."); }, send: (body, type, extra) => fetch(storeUrl([...path, map ? extra : extra.trim()], false), - { method: map ? "PUT" : "POST", body, headers: { "Content-Type": type } }), + withToken({ method: map ? "PUT" : "POST", body, headers: { "Content-Type": type } })), }); area.prepend(form); }); @@ -2282,6 +2282,12 @@ function unapplySubscriptions() { // Each refreshed half way through its life. let token = null, narrowToken = null, tokenWhy = "not logged in yet", tokenTimer = null; const READ_MEMBERS = [{ path: ["_continuum", "members"], rights: ["read"] }]; +// A request that changes something, with this page's token: the session's cookie lets +// in only those that read (`login::bearer`). +function withToken(init) { + if (token) init.headers = Object.assign({}, init.headers, { Authorization: "Bearer " + token.token }); + return init; +} async function tokenCall(url, bearer, body) { const headers = { "Content-Type": "application/json" }; if (bearer) headers.Authorization = "Bearer " + bearer; @@ -3320,8 +3326,8 @@ remakeForm.addEventListener("submit", event => { const rules = remakeRules(); if (rules.length) options.set("rules", JSON.stringify(rules)); const query = options.size ? "?" + options : ""; - request = fetch("remakes" + query, { method: "POST", body: file, - headers: { "Content-Type": "application/octet-stream" } }); + request = fetch("remakes" + query, withToken({ method: "POST", body: file, + headers: { "Content-Type": "application/octet-stream" } })); } else { const body = { kind }; if (settle) body.settle = settle; @@ -3338,8 +3344,8 @@ remakeForm.addEventListener("submit", event => { const rules = remakeRules(); if (rules.length) body.spec.rules = rules; } - request = fetch("remakes" + (force ? "?" + force : ""), { method: "POST", body: JSON.stringify(body), - headers: { "Content-Type": "application/json" } }); + request = fetch("remakes" + (force ? "?" + force : ""), withToken({ method: "POST", + body: JSON.stringify(body), headers: { "Content-Type": "application/json" } })); } remakeResult("Proposing…", false); request @@ -3392,7 +3398,7 @@ connection.style.cursor = "pointer"; connection.addEventListener("click", () => { pulse(); if (live) { - fetch("dashboard/refresh", { method: "POST" }).catch(() => {}); + fetch("dashboard/refresh", withToken({ method: "POST" })).catch(() => {}); documentChanged({ all: true }); } else { if (events) events.close(); diff --git a/src/api/login.rs b/src/api/login.rs index de6e390..1907bac 100644 --- a/src/api/login.rs +++ b/src/api/login.rs @@ -54,6 +54,16 @@ pub(crate) struct Token { /// `HttpOnly`, `SameSite=Strict`, so no script and no other site's page sends it. pub(crate) const SESSION_COOKIE: &str = "continuum-token"; +/// Whether the session cookie lets `request` in: one that reads (`GET`, `HEAD`), or +/// renews or ends the session. One that changes anything brings the token itself +/// (`Authorization`), as a page of another site, or of another port of the same host +/// (which a cookie doesn't tell apart), can't make a browser send it. +fn cookie_lets_in(request: &Request) -> bool { + use axum::http::Method; + let method = request.method(); + method == Method::GET || method == Method::HEAD || request.uri().path() == "/auth/session" +} + /// The session cookie's token, if the request brings one. fn session_cookie(request: &Request) -> Option { request @@ -70,7 +80,8 @@ fn session_cookie(request: &Request) -> Option { /// A request with `Authorization: Bearer `: its user is the token's, if this /// node takes it (`token::verify`); `401`, saying why, if not. One without /// `Authorization` but with the session cookie: the cookie's token's, if taken (if not, -/// as if it brought none). Others go on to `auth::basic_auth`. +/// as if it brought none), for reading only (`cookie_lets_in`). Others go on to +/// `auth::basic_auth`. pub(crate) async fn bearer( State(state): State, mut request: Request, @@ -86,7 +97,7 @@ pub(crate) async fn bearer( Some(text) => (text.to_owned(), false), None => return next.run(request).await, }, - None => match session_cookie(&request) { + None => match session_cookie(&request).filter(|_| cookie_lets_in(&request)) { Some(text) => (text, true), None => return next.run(request).await, }, -- cgit v1.3.1