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,8 +1,8 @@
package de.hoennig.gittally.server
import de.hoennig.gittally.SecretFiles
import java.nio.file.Files
import java.nio.file.Path
import java.nio.file.attribute.PosixFilePermissions
import java.security.MessageDigest
import java.security.SecureRandom
@@ -25,16 +25,21 @@ class ControlTokenService(
?.let { return it }
}
val token = generateToken()
Files.createDirectories(tokenFile.parent)
Files.writeString(tokenFile, token + "\n")
restrictToOwner(tokenFile)
SecretFiles.createDirectoriesOwnerOnly(tokenFile.parent)
SecretFiles.writeOwnerOnly(tokenFile, token + "\n")
return token
}
/** Constant-time comparison; null or blank never matches. */
/**
* Constant-time comparison; null or blank never matches. Both sides are hashed first
* so the comparison always runs over two 32-byte buffers and cannot return early on a
* length mismatch — which would leak the token length.
*/
fun matches(submittedToken: String?): Boolean =
!submittedToken.isNullOrBlank() &&
MessageDigest.isEqual(submittedToken.toByteArray(), token().toByteArray())
MessageDigest.isEqual(sha256(submittedToken), sha256(token()))
private fun sha256(value: String): ByteArray = MessageDigest.getInstance("SHA-256").digest(value.toByteArray())
/** 24 random bytes as hex, like legacy `openssl rand -hex 24`. */
private fun generateToken(): String {
@@ -42,12 +47,4 @@ class ControlTokenService(
SecureRandom().nextBytes(bytes)
return bytes.joinToString("") { "%02x".format(it) }
}
private fun restrictToOwner(file: Path) {
try {
Files.setPosixFilePermissions(file, PosixFilePermissions.fromString("rw-------"))
} catch (_: UnsupportedOperationException) {
// non-POSIX filesystem; the file stays with default permissions
}
}
}