Header-only control token, masked secrets, loopback default (v0.9.9)

Finishes the small items of the security audit in
docs/prs/2026-07-08-PR#000: TODO 3, 4 and 7.

The three mutating endpoints of BuildsApiController no longer accept the
control token as a `token` query parameter — only the X-GitTally-Token
header, which the bundled UI has always used. URLs end up in access logs,
proxy logs, browser history and Referer headers, and the token never
expires, so a historical log capture would yield a valid credential.

`config:print` masks git.token as `***` on both the raw and the --full
path and names the new --show-secrets flag in a leading YAML comment, so
the output stays parseable when piped. The setup script points at
--show-secrets where it used to steer the operator to the plain token.

`server.bindAddress` now defaults to 127.0.0.1: neither the UI nor the
API authenticates read access, so reaching GitTally should require the
host's reverse proxy. Existing .gittally.yml files keep their explicit
value; the managed nginx container needs `0.0.0.0` set deliberately,
which is noted in the release notes, docs/configuration.md and
docs/deployment.md.

Released as v0.9.9, which also carries the previous two commits.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
mhoennig
2026-08-11 07:38:33 +02:00
co-authored by Claude
parent dea6770998
commit a4c995592f
12 changed files with 180 additions and 29 deletions
@@ -17,16 +17,41 @@ class ConfigPrintCommand(
@Option(names = ["--full"], description = ["Include all defaults"])
var full: Boolean = false
@Option(names = ["--show-secrets"], description = ["Print secrets (git.token) in clear text instead of masked"])
var showSecrets: Boolean = false
override fun run() {
if (full) {
print(configLoader.toYaml(configLoader.load()))
val config = configLoader.load()
printMaskingNote(config.git.token)
print(configLoader.toYaml(if (showSecrets) config else config.copy(git = config.git.copy(token = MASK))))
} else {
val raw = configLoader.loadRaw()
if (raw.isEmpty()) {
println("(no configuration files found)")
} else {
print(configLoader.toYaml(raw))
printMaskingNote(rawToken(raw))
print(configLoader.toYaml(if (showSecrets) raw else maskToken(raw)))
}
}
}
/** A YAML comment, so the output stays parseable when piped into a file. */
private fun printMaskingNote(token: String?) {
if (!showSecrets && !token.isNullOrEmpty()) {
println("# git.token is masked — pass --show-secrets to print it")
}
}
private fun rawToken(raw: Map<String, Any?>): String? = (raw["git"] as? Map<*, *>)?.get("token") as? String
private fun maskToken(raw: Map<String, Any?>): Map<String, Any?> {
val git = raw["git"] as? Map<*, *> ?: return raw
if (rawToken(raw).isNullOrEmpty()) return raw
return raw + ("git" to git.entries.associate { (key, value) -> key to if (key == "token") MASK else value })
}
companion object {
private const val MASK = "***"
}
}
@@ -125,8 +125,10 @@ class InitCommand(
publicBaseUrl: ""
# HTTP port of the `server` subcommand
port: 18080
# bind address of the `server` subcommand
bindAddress: 0.0.0.0
# bind address of the `server` subcommand; loopback only, because the UI and the
# API are unauthenticated — use 0.0.0.0 only without a reverse proxy in front
# (and with the managed nginx below, which reaches GitTally from its container)
bindAddress: 127.0.0.1
# optional Impressum (legal disclosure) link in the web UI footer; empty hides the link
impressumUrl: ""
# Opt-in managed nginx+certbot Docker container for HTTPS, for hosts without
@@ -18,7 +18,12 @@ data class ServerConfig(
val publicBaseUrl: String = "",
/** HTTP port of the `server` subcommand; 18080 like the legacy artifact server. */
val port: Int = 18080,
val bindAddress: String = "0.0.0.0",
/**
* Loopback by default: the UI and the API are unauthenticated, so reaching them should
* require the host's reverse proxy. Set `0.0.0.0` explicitly to expose all interfaces —
* which the managed nginx container needs (see `docs/deployment.md`).
*/
val bindAddress: String = "127.0.0.1",
/** Optional Impressum (legal disclosure) link shown in the web UI footer; empty hides the link. */
val impressumUrl: String = "",
val nginx: NginxConfig = NginxConfig(),
@@ -24,8 +24,10 @@ import java.nio.file.StandardOpenOption
/**
* JSON API over build results and running builds, replacing the legacy
* `/control/…` endpoints. Mutating endpoints are guarded by the control token
* (header [TOKEN_HEADER] or parameter `token`), like the legacy cancel token.
* `/control/…` endpoints. Mutating endpoints are guarded by the control token,
* like the legacy cancel token — in the header [TOKEN_HEADER] only: a token in
* the query string would end up in access logs, browser history and `Referer`
* headers, and it never expires.
*/
@RestController
class BuildsApiController(
@@ -91,9 +93,8 @@ class BuildsApiController(
fun restart(
@RequestParam branch: String,
@RequestHeader(name = TOKEN_HEADER, required = false) headerToken: String?,
@RequestParam(name = "token", required = false) paramToken: String?,
): ResponseEntity<Any> {
rejectBadToken(headerToken ?: paramToken)?.let { return it }
rejectBadToken(headerToken)?.let { return it }
val commit =
repository.latestFor(branch)?.commit
?: gitService.originHeadCommit(branch, workingDir)
@@ -116,9 +117,8 @@ class BuildsApiController(
fun cancel(
@PathVariable artifactKey: String,
@RequestHeader(name = TOKEN_HEADER, required = false) headerToken: String?,
@RequestParam(name = "token", required = false) paramToken: String?,
): ResponseEntity<Any> {
rejectBadToken(headerToken ?: paramToken)?.let { return it }
rejectBadToken(headerToken)?.let { return it }
if (!buildExecutor.cancel(artifactKey)) {
return notFound("no queued or running build with artifact key '$artifactKey'")
}
@@ -130,9 +130,8 @@ class BuildsApiController(
fun delete(
@PathVariable artifactKey: String,
@RequestHeader(name = TOKEN_HEADER, required = false) headerToken: String?,
@RequestParam(name = "token", required = false) paramToken: String?,
): ResponseEntity<Any> {
rejectBadToken(headerToken ?: paramToken)?.let { return it }
rejectBadToken(headerToken)?.let { return it }
if (!repository.delete(artifactKey)) {
return notFound("no build with artifact key '$artifactKey'")
}
@@ -7,6 +7,22 @@
<div th:replace="~{fragments :: nav(${view})}"></div>
<div class="panel release-notes">
<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>
instead of <code>0.0.0.0</code>, because neither the UI nor the API authenticates read access.
Existing <code>.gittally.yml</code> files keep whatever they set; with the managed nginx
container, <code>0.0.0.0</code> has to be set explicitly.</li>
<li>The control token is accepted in the <code>X-GitTally-Token</code> header only — the
<code>token</code> query parameter is gone, as URLs end up in access logs and browser history.</li>
<li><code>config:print</code> masks <code>git.token</code>; <code>--show-secrets</code> prints it.</li>
<li>Files holding secrets (the Gitea token written by <code>init</code>, the control token) are
created with mode <code>0600</code> right away instead of being <code>chmod</code>-ed afterwards,
the control token is compared in constant time, and git calls pass <code>--</code> before
branch names.</li>
<li>On narrow screens the page title and the repository name are stacked instead of wrapping.</li>
</ul>
<h2>v0.9.8 <span class="muted">— 2026-08-10</span></h2>
<ul>
<li>The artifact index also links report pages of directories without an <code>index.html</code>.
@@ -0,0 +1,74 @@
package de.hoennig.gittally.commands
import de.hoennig.gittally.config.ConfigLoader
import de.hoennig.gittally.config.GitConfig
import de.hoennig.gittally.config.GitTallyConfig
import io.kotest.core.spec.style.FunSpec
import io.kotest.matchers.string.shouldContain
import io.kotest.matchers.string.shouldNotContain
import io.mockk.clearMocks
import io.mockk.every
import io.mockk.mockk
class ConfigPrintCommandTest : FunSpec() {
private val yamlWriter = ConfigLoader()
private val configLoader = mockk<ConfigLoader>()
private val command = ConfigPrintCommand(configLoader)
private val rawConfig =
mapOf<String, Any?>(
"git" to mapOf("account" to "ci-user", "token" to "s3cr3t-token"),
"server" to mapOf("port" to 18080),
)
init {
beforeEach {
clearMocks(configLoader)
every { configLoader.toYaml(any()) } answers { yamlWriter.toYaml(firstArg()) }
every { configLoader.loadRaw() } returns rawConfig
every { configLoader.load() } returns GitTallyConfig(git = GitConfig(account = "ci-user", token = "s3cr3t-token"))
command.full = false
command.showSecrets = false
}
test("masks the git token by default") {
val output = captureConsole { command.run() }.stdout
output shouldNotContain "s3cr3t-token"
output shouldContain "***"
output shouldContain "# git.token is masked"
output shouldContain "account: \"ci-user\""
}
test("masks the git token with --full as well") {
command.full = true
val output = captureConsole { command.run() }.stdout
output shouldNotContain "s3cr3t-token"
output shouldContain "***"
}
test("prints the git token with --show-secrets") {
command.showSecrets = true
val output = captureConsole { command.run() }.stdout
output shouldContain "s3cr3t-token"
output shouldNotContain "masked"
}
test("prints the git token with --full --show-secrets") {
command.full = true
command.showSecrets = true
captureConsole { command.run() }.stdout shouldContain "s3cr3t-token"
}
test("says nothing about masking when no token is configured") {
every { configLoader.loadRaw() } returns mapOf("server" to mapOf("port" to 18080))
captureConsole { command.run() }.stdout shouldNotContain "masked"
}
}
}
@@ -175,7 +175,7 @@ class BuildsApiControllerTest : FunSpec() {
runningBuild(liveLogFile).copy(branch = "fresh")
mockMvc
.perform(post("/api/builds/restart").param("branch", "fresh").param("token", "secret"))
.perform(post("/api/builds/restart").param("branch", "fresh").header(BuildsApiController.TOKEN_HEADER, "secret"))
.andExpect(status().isAccepted)
.andExpect(jsonPath("$.status").value("pending"))
@@ -187,7 +187,7 @@ class BuildsApiControllerTest : FunSpec() {
every { gitService.originHeadCommit("gone", any()) } returns null
mockMvc
.perform(post("/api/builds/restart").param("branch", "gone").param("token", "secret"))
.perform(post("/api/builds/restart").param("branch", "gone").header(BuildsApiController.TOKEN_HEADER, "secret"))
.andExpect(status().isNotFound)
}
@@ -227,14 +227,30 @@ class BuildsApiControllerTest : FunSpec() {
every { buildExecutor.cancel("unknown-key") } returns false
mockMvc
.perform(post("/api/builds/known-key/cancel").param("token", "secret"))
.perform(post("/api/builds/known-key/cancel").header(BuildsApiController.TOKEN_HEADER, "secret"))
.andExpect(status().isAccepted)
.andExpect(jsonPath("$.cancelled").value("known-key"))
mockMvc
.perform(post("/api/builds/unknown-key/cancel").param("token", "secret"))
.perform(post("/api/builds/unknown-key/cancel").header(BuildsApiController.TOKEN_HEADER, "secret"))
.andExpect(status().isNotFound)
}
test("a token in the query string is not accepted — the header is the only way") {
mockMvc
.perform(post("/api/builds/restart").param("branch", "main").param("token", "secret"))
.andExpect(status().isForbidden)
mockMvc
.perform(post("/api/builds/some-key/cancel").param("token", "secret"))
.andExpect(status().isForbidden)
mockMvc
.perform(delete("/api/builds/some-key").param("token", "secret"))
.andExpect(status().isForbidden)
verify(exactly = 0) { buildExecutor.startBuild(any(), any(), any()) }
verify(exactly = 0) { buildExecutor.cancel(any()) }
verify(exactly = 0) { repository.delete(any()) }
}
test("cancel without token answers 403") {
mockMvc
.perform(post("/api/builds/some-key/cancel"))
@@ -260,7 +276,7 @@ class BuildsApiControllerTest : FunSpec() {
every { repository.delete("unknown-key") } returns false
mockMvc
.perform(delete("/api/builds/unknown-key").param("token", "secret"))
.perform(delete("/api/builds/unknown-key").header(BuildsApiController.TOKEN_HEADER, "secret"))
.andExpect(status().isNotFound)
verify(exactly = 0) { artifactStore.prune(any()) }