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>
51 lines
1.8 KiB
Kotlin
51 lines
1.8 KiB
Kotlin
package de.hoennig.gittally.server
|
|
|
|
import de.hoennig.gittally.SecretFiles
|
|
import java.nio.file.Files
|
|
import java.nio.file.Path
|
|
import java.security.MessageDigest
|
|
import java.security.SecureRandom
|
|
|
|
/**
|
|
* Guards the mutating build endpoints with a shared secret, like the legacy cancel
|
|
* token. The token is generated once and persisted (mode 600) so operators — and
|
|
* the step-08 UI, server-side — can read it; delete the file to rotate it.
|
|
*/
|
|
class ControlTokenService(
|
|
private val tokenFile: Path,
|
|
) {
|
|
@Synchronized
|
|
fun token(): String {
|
|
if (Files.isRegularFile(tokenFile)) {
|
|
Files
|
|
.readAllLines(tokenFile)
|
|
.firstOrNull()
|
|
?.trim()
|
|
?.takeIf { it.isNotEmpty() }
|
|
?.let { return it }
|
|
}
|
|
val token = generateToken()
|
|
SecretFiles.createDirectoriesOwnerOnly(tokenFile.parent)
|
|
SecretFiles.writeOwnerOnly(tokenFile, token + "\n")
|
|
return token
|
|
}
|
|
|
|
/**
|
|
* 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(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 {
|
|
val bytes = ByteArray(24)
|
|
SecureRandom().nextBytes(bytes)
|
|
return bytes.joinToString("") { "%02x".format(it) }
|
|
}
|
|
}
|