Harden secret-file creation, token comparison and git refname args

Works off the security audit in docs/prs/2026-07-08-PR#000: TODO 1, 8, 9
and 10, the four items that need no design decision.

New `SecretFiles` creates files holding secrets with mode 0600 and their
directories with 0700 *at creation*, as a file attribute, instead of
writing at the umask default and chmod-ing afterwards — that left a
window in which the Gitea token was world-readable, which matters on a
multi-tenant host. It is used by `init` for .git/gittally/.gittally.yml
and by `ControlTokenService` for the control token; the shell setup
script now writes its YAML in a `umask 077` subshell for the same reason.

`ControlTokenService.matches` hashes both sides with SHA-256 before
`MessageDigest.isEqual`, so the comparison always runs over two 32-byte
buffers and cannot return early on a length mismatch.

`GitService.checkout` and `fetchBranch` pass `--` before the refname, so
a branch named like an option cannot be read as one. `resetHardToOrigin`
keeps its plain form: `git reset --hard -- <commit>` is rejected outright
and its argument is already `origin/`-prefixed.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
mhoennig
2026-08-11 07:09:34 +02:00
co-authored by Claude
parent 3eb41d4c66
commit dea6770998
8 changed files with 115 additions and 30 deletions
@@ -1,5 +1,6 @@
package de.hoennig.gittally.commands
import de.hoennig.gittally.SecretFiles
import de.hoennig.gittally.git.GitService
import org.springframework.stereotype.Component
import picocli.CommandLine.Command
@@ -93,7 +94,7 @@ class InitCommand(
println("${file.toFile().relativeTo(normalizedWorkingDir.toFile())} already exists — not overwritten")
return
}
file.parent.toFile().mkdirs()
SecretFiles.createDirectoriesOwnerOnly(file.parent)
val content =
"""
# Machine- or user-specific overrides and secrets. Keys here win over .gittally.yml.
@@ -101,7 +102,9 @@ class InitCommand(
account: "${detected.account}" # technical username for git HTTPS authentication
token: "" # Gitea API token — never commit this
""".trimIndent()
file.toFile().writeText(content + "\n")
// this is where the operator pastes the Gitea token, so it must never exist
// world-readable — on a shared host that would hand out git push access
SecretFiles.writeOwnerOnly(file, content + "\n")
println("created ${file.toFile().relativeTo(normalizedWorkingDir.toFile())}")
}
@@ -214,7 +217,7 @@ class InitCommand(
return
}
val gittallyDir = root.resolve(".git/gittally")
gittallyDir.toFile().mkdirs()
SecretFiles.createDirectoriesOwnerOnly(gittallyDir)
val unitName = SystemdServiceFiles.unitName(root)
val unitFile = gittallyDir.resolve(unitName)
val envFile = gittallyDir.resolve(SystemdServiceFiles.ENV_FILE_NAME)