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:
@@ -0,0 +1,49 @@
|
||||
package de.hoennig.gittally
|
||||
|
||||
import java.nio.ByteBuffer
|
||||
import java.nio.file.Files
|
||||
import java.nio.file.Path
|
||||
import java.nio.file.StandardOpenOption
|
||||
import java.nio.file.attribute.PosixFilePermissions
|
||||
|
||||
/**
|
||||
* Creation of files and directories that hold secrets — the Gitea token in
|
||||
* `.git/gittally/.gittally.yml` and the control token.
|
||||
*
|
||||
* The permissions are set *at creation*, never with a `chmod` after the write:
|
||||
* writing at the umask default first (typically `0644`) would leave a window in
|
||||
* which the secret is world-readable, which matters on multi-tenant hosts.
|
||||
* On non-POSIX filesystems the permissions are silently skipped.
|
||||
*/
|
||||
object SecretFiles {
|
||||
private val OWNER_ONLY_FILE = PosixFilePermissions.asFileAttribute(PosixFilePermissions.fromString("rw-------"))
|
||||
private val OWNER_ONLY_DIRECTORY = PosixFilePermissions.asFileAttribute(PosixFilePermissions.fromString("rwx------"))
|
||||
|
||||
/** Writes [content] as a `0600` file, replacing an existing file. */
|
||||
fun writeOwnerOnly(
|
||||
file: Path,
|
||||
content: String,
|
||||
) {
|
||||
val bytes = content.toByteArray()
|
||||
Files.deleteIfExists(file)
|
||||
try {
|
||||
Files
|
||||
.newByteChannel(file, setOf(StandardOpenOption.CREATE_NEW, StandardOpenOption.WRITE), OWNER_ONLY_FILE)
|
||||
.use { it.write(ByteBuffer.wrap(bytes)) }
|
||||
} catch (_: UnsupportedOperationException) {
|
||||
Files.write(file, bytes)
|
||||
}
|
||||
}
|
||||
|
||||
/** Creates [directory] and its parents; a directory created here gets mode `0700`. */
|
||||
fun createDirectoriesOwnerOnly(directory: Path) {
|
||||
val missing = generateSequence(directory) { it.parent }.takeWhile { !Files.exists(it) }.toList().asReversed()
|
||||
missing.forEach { path ->
|
||||
try {
|
||||
Files.createDirectory(path, OWNER_ONLY_DIRECTORY)
|
||||
} catch (_: UnsupportedOperationException) {
|
||||
Files.createDirectory(path)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -37,7 +37,8 @@ class GitService(
|
||||
workingDir: Path = Paths.get("."),
|
||||
) {
|
||||
authenticated(workingDir) { environment ->
|
||||
runner.runOrThrow(listOf("git", "fetch", "origin", branch), workingDir, environment)
|
||||
// `--` guards against refnames starting with `-` being read as git options
|
||||
runner.runOrThrow(listOf("git", "fetch", "origin", "--", branch), workingDir, environment)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -142,7 +143,7 @@ class GitService(
|
||||
workingDir: Path = Paths.get("."),
|
||||
) {
|
||||
if (refExists("refs/heads/$branch", workingDir)) {
|
||||
runner.runOrThrow(listOf("git", "switch", branch), workingDir)
|
||||
runner.runOrThrow(listOf("git", "switch", "--", branch), workingDir)
|
||||
} else {
|
||||
runner.runOrThrow(listOf("git", "switch", "--track", "-c", branch, "refs/remotes/origin/$branch"), workingDir)
|
||||
}
|
||||
@@ -152,6 +153,8 @@ class GitService(
|
||||
branch: String,
|
||||
workingDir: Path = Paths.get("."),
|
||||
) {
|
||||
// no `--` here: with paths `git reset --hard` refuses to run; the `origin/` prefix
|
||||
// already keeps the argument from looking like an option
|
||||
runner.runOrThrow(listOf("git", "reset", "--hard", "origin/$branch"), workingDir)
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user