Stop embedding the control token in every page (v0.9.10)

Closes TODO 2 of the security audit in docs/prs/2026-07-08-PR#000.

Every rendered page carried the live control token in a meta tag so that
gittally.js could send it, but no GET is authenticated — so `curl … |
grep gittally-control-token` handed the token to anyone, and read access
was effectively write access.

Reading stays fully public, which is a requirement rather than an
oversight: build states, logs and artifacts must be linkable from Gitea,
chats or tickets without a login. Only the distribution of the token
changed. The meta tag is gone; gittally.js keeps the token in
localStorage and asks for it once per browser, so knowing it requires
shell access to `.git/gittally/control-token` on the host. A token the
server rejects is dropped and asked for once more, so a rotated secret is
not a dead end. As a request header it stays inherently CSRF-safe.

The five branches of that flow (first use, reuse, stale token, cancelled
prompt, wrong token twice) were exercised against the real source with a
throwaway node harness; the UI test now asserts the token does not appear
in the rendered page. `docs/deployment.md` gained a "Control Token"
section on the public-read/token-protected-write split.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
mhoennig
2026-08-11 08:00:09 +02:00
co-authored by Claude
parent ea0b67d331
commit a734d91918
9 changed files with 103 additions and 16 deletions
+61 -6
View File
@@ -95,9 +95,46 @@ function metaContent(name) {
return element ? element.content : "";
}
const controlToken = metaContent("gittally-control-token");
const giteaRepoUrl = metaContent("gittally-gitea-repo-url");
// The control token is deliberately NOT embedded in the pages: reading them is
// unauthenticated, so anyone could have read it out of the HTML. The operator
// pastes it once per browser from `.git/gittally/control-token` on the server;
// it is kept in localStorage and only ever sent as a request header.
const CONTROL_TOKEN_KEY = "gittally.controlToken";
function storedControlToken() {
try {
return window.localStorage.getItem(CONTROL_TOKEN_KEY) || "";
} catch (error) {
return ""; // localStorage unavailable (private mode, blocked cookies)
}
}
function rememberControlToken(token) {
try {
window.localStorage.setItem(CONTROL_TOKEN_KEY, token);
} catch (error) {
// not persistable — the token is asked for again on the next action
}
}
function forgetControlToken() {
try {
window.localStorage.removeItem(CONTROL_TOKEN_KEY);
} catch (error) {
// nothing to clean up when localStorage is unavailable
}
}
function askForControlToken() {
const answer = window.prompt(
"Control token — the content of .git/gittally/control-token on the GitTally host:",
"",
);
return answer ? answer.trim() : "";
}
async function fetchJson(url) {
const response = await fetch(url, { signal: AbortSignal.timeout(FETCH_TIMEOUT_MS) });
if (!response.ok) {
@@ -106,15 +143,33 @@ async function fetchJson(url) {
return response.json();
}
/** A rejected token is dropped and asked for once more, so a stale one is not a dead end. */
async function sendAction(url, method) {
const response = await fetch(url, {
method,
headers: { "X-GitTally-Token": controlToken },
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
});
let token = storedControlToken() || askForControlToken();
if (!token) {
throw new Error("no control token");
}
let response = await sendWithToken(url, method, token);
if (response.status === 403) {
forgetControlToken();
token = askForControlToken();
if (!token) {
throw new Error("wrong control token");
}
response = await sendWithToken(url, method, token);
}
if (!response.ok) {
throw new Error("HTTP " + response.status);
}
rememberControlToken(token);
}
function sendWithToken(url, method, token) {
return fetch(url, {
method,
headers: { "X-GitTally-Token": token },
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
});
}
function setLiveIndicator(ok, detail) {