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:
@@ -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) {
|
||||
|
||||
@@ -6,7 +6,6 @@
|
||||
<title th:text="${#strings.isEmpty(repoName)} ? ${title} + ' — GitTally' : ${title} + ' — ' + ${repoName} + ' — GitTally'">GitTally</title>
|
||||
<link rel="icon" href="/favicon.svg" type="image/svg+xml">
|
||||
<link rel="stylesheet" href="/gittally.css">
|
||||
<meta name="gittally-control-token" th:content="${controlToken}">
|
||||
<meta name="gittally-gitea-repo-url" th:content="${giteaRepoUrl}">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
@@ -7,6 +7,14 @@
|
||||
<div th:replace="~{fragments :: nav(${view})}"></div>
|
||||
<div class="panel release-notes">
|
||||
|
||||
<h2>v0.9.10 <span class="muted">— 2026-08-11</span></h2>
|
||||
<ul>
|
||||
<li>The control token is no longer embedded in the pages — reading them is unauthenticated,
|
||||
so anyone could have picked it out of the HTML. Viewing stays public; the first click on
|
||||
a restart/cancel/delete button asks for the token once (it is in
|
||||
<code>.git/gittally/control-token</code> on the host) and keeps it in the browser.</li>
|
||||
</ul>
|
||||
|
||||
<h2>v0.9.9 <span class="muted">— 2026-08-11</span></h2>
|
||||
<ul>
|
||||
<li><strong>Changed default:</strong> <code>server.bindAddress</code> is now <code>127.0.0.1</code>
|
||||
|
||||
Reference in New Issue
Block a user