From 971eb88503d0a5a13cfbc1d2fb3e13d3e47005d9 Mon Sep 17 00:00:00 2001 From: Harenome Ranaivoarivony-Razanajato Date: Thu, 12 Nov 2026 18:52:33 +0100 Subject: [PATCH 1/3] Say when a form token is stale, apart from other refusals A page loaded before a restart sends a token the new session does not hold. The refusal now carries X-Nullius-Reason: stale-form-token, so the page can ask for a reload. A missing token, another origin and a remote client are refused as before, without it. --- app/ui_tests/test_out_of_date.py | 46 ++++++++++++++++++++++++++++++++ app/web/shared.py | 16 +++++++++-- 2 files changed, 60 insertions(+), 2 deletions(-) create mode 100644 app/ui_tests/test_out_of_date.py diff --git a/app/ui_tests/test_out_of_date.py b/app/ui_tests/test_out_of_date.py new file mode 100644 index 0000000..6afe04c --- /dev/null +++ b/app/ui_tests/test_out_of_date.py @@ -0,0 +1,46 @@ +"""A page out of date: the form token a restart forgot. The server says so, +apart from its other refusals.""" +from __future__ import annotations + +import unittest +from unittest.mock import patch + +from .test_routes import web + +REASON = "X-Nullius-Reason" + + +class OutOfDate(unittest.TestCase): + def setUp(self): + patcher = patch.dict(web.app.config, TESTING=True); patcher.start(); self.addCleanup(patcher.stop) + self.client = web.app.test_client() + + def token(self): + self.client.get("/settings").close() + with self.client.session_transaction() as s: + return s["csrf"] + + def probe(self, csrf, **kw): + r = self.client.post("/settings/hosts/test", headers={"X-CSRF-Token": csrf, **kw.pop("headers", {})}, + data={"entry": "nowhere"}, **kw) + self.addCleanup(r.close) + return r + + def test_a_token_the_session_does_not_hold_is_stale(self): + self.token() + r = self.probe("from-before-a-restart") + self.assertEqual(r.status_code, 403) + self.assertEqual(r.headers.get(REASON), "stale-form-token") + + def test_other_refusals_are_not_stale(self): + token = self.token() + missing = self.client.post("/settings/hosts/test", data={"entry": "nowhere"}); self.addCleanup(missing.close) + cross = self.probe(token, headers={"Origin": "http://elsewhere.example"}) + remote = self.probe(token, environ_overrides={"REMOTE_ADDR": "192.0.2.1"}) + for r in (missing, cross, remote): + self.assertEqual(r.status_code, 403) + self.assertIsNone(r.headers.get(REASON)) + + +if __name__ == "__main__": + unittest.main() diff --git a/app/web/shared.py b/app/web/shared.py index e07f816..6d3c147 100644 --- a/app/web/shared.py +++ b/app/web/shared.py @@ -9,6 +9,7 @@ from urllib.parse import urlsplit from flask import abort, flash, g, has_request_context, request, session from markupsafe import Markup, escape +from werkzeug.exceptions import Forbidden from nullius import importers as harnesses from nullius import execution from nullius.catalogue import catalog @@ -457,12 +458,23 @@ def _origin_of(url: str) -> str: app.jinja_env.globals["csrf_token"] = lambda: _csrf_token() +class StaleFormToken(Forbidden): + """A token the session does not hold, as from a page loaded before a restart. + Its header tells the page to ask for a reload instead of showing the error.""" + description = "Stale form token; reload the page and try again." + + def get_headers(self, environ=None, scope=None): + return [*super().get_headers(environ, scope), ("X-Nullius-Reason", "stale-form-token")] + + def _require_csrf(): """The one guard every mutation of the environments takes: the session's token, sent back by the form, and a same-origin request.""" sent = request.form.get("csrf") or request.headers.get("X-CSRF-Token") - if not sent or not secrets.compare_digest(sent, session.get("csrf", "")): - abort(403, description="Missing or stale form token; reload the page and try again.") + if not sent: + abort(403, description="Missing form token; reload the page and try again.") + if not secrets.compare_digest(sent, session.get("csrf", "")): + raise StaleFormToken() origin = request.headers.get("Origin") or request.headers.get("Referer") if origin and _origin_of(origin) != request.host_url.rstrip("/").lower(): abort(403, description="Cross-origin requests cannot change environments.") From 21b64194c669313cf1d7f9ec8373fa429de33be2 Mon Sep 17 00:00:00 2001 From: Harenome Ranaivoarivony-Razanajato Date: Thu, 12 Nov 2026 18:52:45 +0100 Subject: [PATCH 2/3] Name the interface a page was rendered with, and the one served A digest of the templates, static files and web routes, taken at startup, goes into every page as a meta tag and into every response as X-Nullius-Interface. A page whose digest differs from the server's runs code that an upgrade or an edit replaced. The package version cannot tell this in a source checkout, where it stays the same while the files change. --- app/templates/base.html | 1 + app/ui_tests/test_out_of_date.py | 20 ++++++++++++++++++-- app/web/shared.py | 27 +++++++++++++++++++++++++++ 3 files changed, 46 insertions(+), 2 deletions(-) diff --git a/app/templates/base.html b/app/templates/base.html index e61f298..91fdf0f 100644 --- a/app/templates/base.html +++ b/app/templates/base.html @@ -9,6 +9,7 @@ + {% block title %}Nullius{% endblock %} {# ---- icons: one symbol each, referenced by . Hand-drawn on a 24-grid, stroke only, so they take the item's colour and weight. ---- #} @@ -463,7 +464,6 @@ }); {% endif %} - {% if not embedded %} diff --git a/app/templates/compare/compare.html b/app/templates/compare/compare.html index 09b8858..7b9b8f4 100644 --- a/app/templates/compare/compare.html +++ b/app/templates/compare/compare.html @@ -288,7 +288,7 @@

Task by task

const body = row.querySelector('[data-detail-url]'); if (open && body && !body.dataset.loaded) { body.dataset.loaded = '1'; - fetch(body.dataset.detailUrl).then(r => r.ok ? r.text() : Promise.reject(r.status)) + nulliusFetch(body.dataset.detailUrl).then(r => r.ok ? r.text() : Promise.reject(r.status)) .then(html => { body.innerHTML = html; }) .catch(() => { body.dataset.loaded = ''; body.innerHTML = '

The detail could not be read; open the row again.

'; }); } diff --git a/app/templates/runs/_execution.html b/app/templates/runs/_execution.html index c364ec4..738589e 100644 --- a/app/templates/runs/_execution.html +++ b/app/templates/runs/_execution.html @@ -81,7 +81,7 @@ const tick = async () => { let retry = true; try { - const r = await fetch(box.dataset.run + '?since=' + since, {cache: 'no-store'}); if (!r.ok) return; + const r = await nulliusFetch(box.dataset.run + '?since=' + since, {cache: 'no-store'}); if (!r.ok) return; const j = await r.json(); if (j.log.length) { pre.textContent += j.log.join('\n') + '\n'; pre.scrollTop = pre.scrollHeight; since = j.lines; } const bar = document.getElementById('execution-progress'), p = j.progress; @@ -105,7 +105,7 @@ const ask = document.getElementById('execution-ask'); ask.hidden = j.state !== 'paused'; document.getElementById('execution-question').textContent = j.question || ''; if (!alive && live) { live = false; document.getElementById('execution-stop')?.remove(); if (j.error && !own) { const e = document.createElement('span'); e.className = 'small bad'; e.textContent = j.error; state.after(e); } } // Check once more at completion, but do not wait forever for a job that never started. - if (box.dataset.waiting === 'yes') { const h = await fetch(location.pathname, {method: 'HEAD', cache: 'no-store'}); if (h.ok) location.reload(); } + if (box.dataset.waiting === 'yes') { const h = await nulliusFetch(location.pathname, {method: 'HEAD', cache: 'no-store'}); if (h.ok) location.reload(); } if (alive) setTimeout(tick, 1000); } catch { if (retry) setTimeout(tick, 5000); } }; @@ -115,7 +115,7 @@ document.getElementById('execution-stop')?.addEventListener('click', async e => { e.target.disabled = true; try { - const r = await fetch(box.dataset.stop, {method: 'POST', headers: {'X-CSRF-Token': box.dataset.csrf}}); + const r = await nulliusFetch(box.dataset.stop, {method: 'POST', headers: {'X-CSRF-Token': box.dataset.csrf}}); if (!r.ok) throw new Error('stop refused: HTTP ' + r.status); const j = await r.json(); state.textContent = j.state; e.target.remove(); } catch (err) { @@ -124,7 +124,7 @@ } }); document.getElementById('execution-continue')?.addEventListener('click', async e => { - e.target.disabled = true; try { await fetch(box.dataset.continue, {method: 'POST', headers: {'X-CSRF-Token': box.dataset.csrf}}); } catch {} e.target.disabled = false; + e.target.disabled = true; try { await nulliusFetch(box.dataset.continue, {method: 'POST', headers: {'X-CSRF-Token': box.dataset.csrf}}); } catch {} e.target.disabled = false; }); })(); diff --git a/app/templates/runs/index.html b/app/templates/runs/index.html index 218e973..abcd45f 100644 --- a/app/templates/runs/index.html +++ b/app/templates/runs/index.html @@ -589,10 +589,10 @@

{{ d.info.title }}

if (asking || document.hidden || !getSelection().isCollapsed) return; asking = true; try { - const pulse = await fetch({{ url_for('runs_pulse') | tojson }}, {cache: 'no-store'}); + const pulse = await nulliusFetch({{ url_for('runs_pulse') | tojson }}, {cache: 'no-store'}); const {signature} = pulse.ok ? await pulse.json() : {signature: seen}; if (signature !== seen) { - const page = await fetch(location.href, {cache: 'no-store'}); + const page = await nulliusFetch(location.href, {cache: 'no-store'}); const fresh = new DOMParser().parseFromString(await page.text(), 'text/html'); if (!page.ok || !fresh.getElementById('view-launch')) return location.reload(); // no longer the list: the workspace emptied refreshLaunches(fresh); diff --git a/app/templates/runs/job.html b/app/templates/runs/job.html index 2806b35..448b40d 100644 --- a/app/templates/runs/job.html +++ b/app/templates/runs/job.html @@ -116,7 +116,7 @@ // the runner reports an end; the settled page no longer starts this poll. (() => { const url = document.getElementById('preparing').dataset.live; - const tick = async () => { if (document.hidden) return; try { const r = await fetch(url, {cache: 'no-store'}); if (r.ok) location.reload(); } catch {} }; + const tick = async () => { if (document.hidden) return; try { const r = await nulliusFetch(url, {cache: 'no-store'}); if (r.ok) location.reload(); } catch {} }; setInterval(tick, 1000); })(); diff --git a/app/templates/runs/setup.html b/app/templates/runs/setup.html index 40b1f16..cffbd60 100644 --- a/app/templates/runs/setup.html +++ b/app/templates/runs/setup.html @@ -263,7 +263,7 @@

Import runs

fd.append('csrf', '{{ csrf_token() }}'); parseBtn.disabled = true; status.textContent = `Uploading ${total} files…`; - const res = await fetch('{{ url_for("upload") }}', { method: 'POST', body: fd }); + const res = await nulliusFetch('{{ url_for("upload") }}', { method: 'POST', body: fd }); if (res.redirected) location.href = res.url; else document.open(), document.write(await res.text()), document.close(); }); diff --git a/app/templates/settings/settings.html b/app/templates/settings/settings.html index 0579183..16c3d02 100644 --- a/app/templates/settings/settings.html +++ b/app/templates/settings/settings.html @@ -415,7 +415,7 @@

General

// One ssh from the application host, nothing stored: whether the host answers, and what it has. const testHost = async entry => { try { - const r = await fetch({{ url_for('settings_host_test')|tojson }}, {method: 'POST', headers: {'X-CSRF-Token': csrf}, body: new URLSearchParams({entry})}); + const r = await nulliusFetch({{ url_for('settings_host_test')|tojson }}, {method: 'POST', headers: {'X-CSRF-Token': csrf}, body: new URLSearchParams({entry})}); const j = await r.json(); if (!j.ok) return {ok: false, error: j.error || ('HTTP ' + r.status)}; const has = k => k + (j[k] ? '' : ' missing'); @@ -514,7 +514,7 @@

General

else { if (!f.get('password')) { say('type the password', 'bad'); return null; } body.set('password', f.get('password')); url = {{ url_for('settings_host_enrol')|tojson }}; } say(auth === 'file' ? 'saving the key…' : 'putting this machine\'s key on the host…'); try { - const r = await fetch(url, {method: 'POST', headers: {'X-CSRF-Token': csrf}, body}); + const r = await nulliusFetch(url, {method: 'POST', headers: {'X-CSRF-Token': csrf}, body}); const j = await r.json(); if (!j.ok) { say('✕ ' + (j.error || ('HTTP ' + r.status)), 'bad'); return null; } resolved = {target, auth, entry: j.entry}; return named(j.entry); diff --git a/app/templates/traces/trial.html b/app/templates/traces/trial.html index ed3c5ab..0967b4c 100644 --- a/app/templates/traces/trial.html +++ b/app/templates/traces/trial.html @@ -685,7 +685,7 @@

Waterfall

async function showView(url, push = true) { let fresh = null; try { - const r = await fetch(url, {cache: 'no-store'}); + const r = await nulliusFetch(url, {cache: 'no-store'}); if (r.ok) fresh = new DOMParser().parseFromString(await r.text(), 'text/html').getElementById('trial-trace'); } catch {} if (!fresh) { location.href = url; return; } diff --git a/app/ui_tests/test_out_of_date.py b/app/ui_tests/test_out_of_date.py index 824e1ed..2e3f6d6 100644 --- a/app/ui_tests/test_out_of_date.py +++ b/app/ui_tests/test_out_of_date.py @@ -1,13 +1,17 @@ """A page out of date: the form token a restart forgot, and the interface an -upgrade replaced. The server says which, apart from its other refusals.""" +upgrade replaced. The server says which, and every request of a page goes +through the one helper that reads it.""" from __future__ import annotations import os +import re import unittest +from pathlib import Path from unittest.mock import patch from .test_routes import web +APP = Path(__file__).resolve().parents[1] REASON = "X-Nullius-Reason" @@ -57,6 +61,12 @@ def test_the_digest_covers_templates_static_files_and_web_routes(self): self.assertEqual([os.path.basename(root) for root in walked], ["templates", "static", "web"]) self.assertEqual(first, web.shared._interface_id()) + def test_every_request_of_a_page_goes_through_the_helper(self): + bare = [f"{path.relative_to(APP)}:{n}" for path in [*APP.glob("static/**/*.js"), *APP.glob("templates/**/*.html")] + if path.name != "notifications.js" + for n, line in enumerate(path.read_text().splitlines(), 1) if re.search(r"(? timers.push(delay), - fetch: async (url, options) => { + nulliusFetch: async (url, options) => { requests.push(options.method || 'GET'); if (options.method === 'HEAD') { if (headFails) throw Error('network'); diff --git a/docs/settings/README.md b/docs/settings/README.md index 558de6d..0bdd49b 100644 --- a/docs/settings/README.md +++ b/docs/settings/README.md @@ -63,6 +63,7 @@ and job roots also have command-line options. The app does not load `app/.env`. | Keys | No provider-specific length check; values must be single-line and contain no spaces. Agent key names use environment-variable syntax, without a length limit | | Secrets | Provider values are masked; custom-key rows show their saved status, never the value | | Guards | Its own address in the Host header, loopback browser, same origin, form token | +| Out of date | A page loaded before a restart, or rendered by other templates, static files or web routes, says so once: reload it | | Validation | One bad value, nothing written | | Code | `app/nullius/settings.py`; `app/.env.example` documents variable names for scripts and migration |