aboutsummaryrefslogtreecommitdiffstats
path: root/src/api
diff options
context:
space:
mode:
authortv <tv@krebsco.de>2026-11-07 22:35:22 +0100
committertv <tv@krebsco.de>2026-11-07 22:35:22 +0100
commita99bacc0c90ca7a7f879cec8a8fb33f4aa14e84f (patch)
tree2de7dfad5ac866df8e90560a06e93a47a8f7f173 /src/api
parent91d1bb60cb7a8d92e4e1966dabbcb50a2406c75e (diff)
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).
Diffstat (limited to 'src/api')
-rw-r--r--src/api/dashboard/dashboard.html22
-rw-r--r--src/api/login.rs15
2 files changed, 27 insertions, 10 deletions
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<Body>) -> 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<Body>) -> Option<String> {
request
@@ -70,7 +80,8 @@ fn session_cookie(request: &Request<Body>) -> Option<String> {
/// A request with `Authorization: Bearer <token>`: 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<AppState>,
mut request: Request<Body>,
@@ -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,
},