mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-13 09:28:52 +00:00
security(signer): bound FileDriver paths with SafeRoot + reject .. (CodeQL #27, CWE-22)
CodeQL alert #27 (go/path-injection, CWE-22 / CWE-23 / CWE-36) flagged the os.WriteFile sink at internal/crypto/signer/file_driver.go:194 because the outPath flowed from operator-supplied config (CAKeyPath in the local issuer's encrypted config blob -> GenerateOutPath closure -> os.WriteFile) without a containment check. Threat model: Production wiring (cmd/server/main.go) constructs &signer.FileDriver{} and the local-issuer NewConnector wires GenerateOutPath off Config.CAKeyPath. CAKeyPath ships from the encrypted issuer config in PostgreSQL — settable only by an authenticated admin via the API. So the realistic exploit is: (a) Admin compromise -> CAKeyPath set to /etc/passwd -> FileDriver.Generate overwrites system files. (b) Future code path concatenates attacker-controlled fragments into the output path -> classic ../../etc/passwd traversal. Defense in depth: bound the write surface so admin-key-rotation errors and future regressions can't escape into arbitrary filesystem writes. Fix: internal/crypto/signer/file_driver.go gains: - SafeRoot string field on FileDriver. When set, every Load + Generate path MUST resolve under SafeRoot via filepath.Abs + strings.HasPrefix on cleaned paths. - validateSafePath helper that: * rejects empty paths * filepath.Clean()s the input * rejects paths whose cleaned form still contains a literal ".." segment (catches relative paths that escape above their start; absolute paths get collapsed by Clean) * resolves to filepath.Abs and (when SafeRoot non-empty) verifies containment via filepath.Separator-suffixed HasPrefix (the bare-prefix bug — SafeRoot=/var/lib/foo erroneously accepting /var/lib/foobar — has its own regression test below) - Load + Generate now call validateSafePath before any os.ReadFile / os.WriteFile. The validator is in the same function as the sink so CodeQL recognizes it as a guard. Tests (internal/crypto/signer/signer_test.go): TestFileDriver_Load_RejectsParentTraversal — relative path "../../etc/passwd" rejected with parent-directory error. TestFileDriver_Load_RejectsEmptyPath — empty path rejected. TestFileDriver_Generate_RejectsParentTraversal — write side, same pattern. TestFileDriver_SafeRoot_AcceptsContainedPath — happy path: a key file under SafeRoot succeeds. TestFileDriver_SafeRoot_RejectsEscape — absolute path outside SafeRoot rejected (the load-bearing CodeQL pin). TestFileDriver_SafeRoot_RejectsSiblingPrefix — pins the HasPrefix-with-separator subtlety: SafeRoot=/tmp/X must NOT accept /tmp/X-sibling. Verified locally: gofmt: clean. go vet ./...: exit 0. go test -short -count=1 ./internal/crypto/signer/...: ok 1.605s go test -short -count=1 ./internal/connector/issuer/local/...: ok 4.908s (downstream FileDriver consumer) go test -short -count=1 ./internal/service/...: ok 4.029s Backwards-compat: when SafeRoot is unset, only the structural .. + empty-path checks fire — the existing FileDriver call sites in cmd/server/main.go and the existing unit tests pass unchanged. Production wiring SHOULD set SafeRoot via cmd/server/main.go in a follow-up commit (env-var-supplied CERTCTL_CA_KEY_DIR or similar). Reference: https://github.com/certctl-io/certctl/security/code-scanning/27 Closes CodeQL alert #27 (go/path-injection).
This commit is contained in:
@@ -13,6 +13,7 @@ import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// FileDriver materializes a Signer from a PEM-encoded private key on
|
||||
@@ -64,11 +65,97 @@ type FileDriver struct {
|
||||
// production. The local package's NewConnector wires this to
|
||||
// return the configured CAKeyPath.
|
||||
GenerateOutPath func(alg Algorithm) (string, error)
|
||||
|
||||
// SafeRoot, if non-empty, restricts every Load + Generate path to
|
||||
// the absolute filesystem subtree rooted at SafeRoot. Closes CodeQL
|
||||
// go/path-injection (CWE-22 / CWE-23 / CWE-36): even though the
|
||||
// driver's path inputs flow from operator-authenticated config
|
||||
// (admin-only API surface), an admin compromise could otherwise
|
||||
// write `/etc/passwd` or read `/root/.ssh/id_rsa` via the driver.
|
||||
// SafeRoot bounds the blast radius.
|
||||
//
|
||||
// Validation semantics (validateSafePath):
|
||||
//
|
||||
// 1. The supplied path is cleaned (filepath.Clean) to collapse
|
||||
// ./ and ../ sequences in their literal form.
|
||||
// 2. If the cleaned path is relative, it's resolved against the
|
||||
// current working directory via filepath.Abs.
|
||||
// 3. If SafeRoot is set, the absolute path MUST be SafeRoot or
|
||||
// a descendant. We use filepath.Rel + strings.HasPrefix on
|
||||
// the cleaned absolute paths so symlink games (../ disguised
|
||||
// as a symlink target) inside SafeRoot are bounded by
|
||||
// SafeRoot's parent permissions, not by the validator.
|
||||
//
|
||||
// When SafeRoot is empty, the path is still cleaned + checked for
|
||||
// the literal ".." element as a baseline defense-in-depth measure;
|
||||
// callers that don't constrain to a root still get path-traversal
|
||||
// rejection.
|
||||
//
|
||||
// Production wiring SHOULD set SafeRoot. The local-issuer config
|
||||
// surface accepts CAKeyPath as an absolute path; cmd/server/main.go
|
||||
// can derive SafeRoot from CERTCTL_CA_KEY_DIR (operator-trusted env
|
||||
// var, never user-supplied) or from the parent of the configured
|
||||
// path at issuer-registration time.
|
||||
SafeRoot string
|
||||
}
|
||||
|
||||
// Name implements Driver.
|
||||
func (d *FileDriver) Name() string { return "file" }
|
||||
|
||||
// validateSafePath enforces the CWE-22 / CWE-23 / CWE-36 path-traversal
|
||||
// defense documented on FileDriver.SafeRoot. Returns the cleaned
|
||||
// absolute path on success; an explicit error on rejection. Rejects:
|
||||
//
|
||||
// - empty paths
|
||||
// - paths whose cleaned form contains a literal ".." segment (defense
|
||||
// against attacker-controlled fragments concatenated upstream — the
|
||||
// filepath.Clean() before this check collapses any "..", so a
|
||||
// remaining ".." is structural)
|
||||
// - when SafeRoot is non-empty: any path whose cleaned absolute form
|
||||
// is not SafeRoot or a descendant
|
||||
//
|
||||
// Apply in every Load + Generate path before any os.ReadFile /
|
||||
// os.WriteFile call. CodeQL's taint tracker recognizes the validator
|
||||
// in the same function as the sink and closes the alert.
|
||||
func (d *FileDriver) validateSafePath(path string) (string, error) {
|
||||
if path == "" {
|
||||
return "", errors.New("path is empty")
|
||||
}
|
||||
cleaned := filepath.Clean(path)
|
||||
// Reject any path whose cleaned form still contains a `..` element.
|
||||
// filepath.Clean collapses `./` and `../` sequences relative to the
|
||||
// path's structure, so a remaining `..` after Clean means the path
|
||||
// is rooted (or attempts to escape) above whatever the caller
|
||||
// intended.
|
||||
for _, segment := range strings.Split(filepath.ToSlash(cleaned), "/") {
|
||||
if segment == ".." {
|
||||
return "", fmt.Errorf("path %q contains parent-directory segment", path)
|
||||
}
|
||||
}
|
||||
abs, err := filepath.Abs(cleaned)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("resolve absolute path %q: %w", path, err)
|
||||
}
|
||||
if d.SafeRoot != "" {
|
||||
safeRoot, err := filepath.Abs(filepath.Clean(d.SafeRoot))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("resolve SafeRoot %q: %w", d.SafeRoot, err)
|
||||
}
|
||||
// Require the cleaned absolute path to be safeRoot itself or a
|
||||
// strict descendant. The += string.Separator on safeRoot is
|
||||
// load-bearing — without it a SafeRoot of "/var/lib/foo" would
|
||||
// erroneously accept "/var/lib/foobar" as a prefix match.
|
||||
safeRootSlash := safeRoot
|
||||
if !strings.HasSuffix(safeRootSlash, string(filepath.Separator)) {
|
||||
safeRootSlash += string(filepath.Separator)
|
||||
}
|
||||
if abs != safeRoot && !strings.HasPrefix(abs, safeRootSlash) {
|
||||
return "", fmt.Errorf("path %q resolves outside SafeRoot %q", path, d.SafeRoot)
|
||||
}
|
||||
}
|
||||
return abs, nil
|
||||
}
|
||||
|
||||
// Load implements Driver. It reads the PEM file at path, decodes the
|
||||
// first PEM block, parses it via the package's parsePrivateKey
|
||||
// (which handles PKCS#1 / SEC 1 / PKCS#8), and wraps the resulting
|
||||
@@ -78,28 +165,33 @@ func (d *FileDriver) Name() string { return "file" }
|
||||
// No key bytes are logged — only the path and (on success) the
|
||||
// inferred Algorithm.
|
||||
func (d *FileDriver) Load(ctx context.Context, path string) (Signer, error) {
|
||||
if path == "" {
|
||||
return nil, errors.New("signer.FileDriver.Load: empty path")
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, fmt.Errorf("signer.FileDriver.Load: %w", err)
|
||||
}
|
||||
|
||||
pemBytes, err := os.ReadFile(path)
|
||||
// CWE-22 path-traversal defense — reject paths that escape SafeRoot
|
||||
// (when set) OR contain literal ".." segments. The validator is in
|
||||
// the same function as the os.ReadFile sink so CodeQL recognizes
|
||||
// the sanitizer in-scope.
|
||||
safePath, err := d.validateSafePath(path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("signer.FileDriver.Load: read %q: %w", path, err)
|
||||
return nil, fmt.Errorf("signer.FileDriver.Load: %w", err)
|
||||
}
|
||||
|
||||
pemBytes, err := os.ReadFile(safePath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("signer.FileDriver.Load: read %q: %w", safePath, err)
|
||||
}
|
||||
block, _ := pem.Decode(pemBytes)
|
||||
if block == nil {
|
||||
return nil, fmt.Errorf("signer.FileDriver.Load: %q is not PEM", path)
|
||||
return nil, fmt.Errorf("signer.FileDriver.Load: %q is not PEM", safePath)
|
||||
}
|
||||
key, err := parsePrivateKey(block)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("signer.FileDriver.Load: parse %q: %w", path, err)
|
||||
return nil, fmt.Errorf("signer.FileDriver.Load: parse %q: %w", safePath, err)
|
||||
}
|
||||
wrapped, err := Wrap(key)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("signer.FileDriver.Load: wrap %q: %w", path, err)
|
||||
return nil, fmt.Errorf("signer.FileDriver.Load: wrap %q: %w", safePath, err)
|
||||
}
|
||||
return wrapped, nil
|
||||
}
|
||||
@@ -133,10 +225,19 @@ func (d *FileDriver) Generate(ctx context.Context, alg Algorithm) (Signer, strin
|
||||
return nil, "", fmt.Errorf("signer.FileDriver.Generate: resolve out path: %w", err)
|
||||
}
|
||||
|
||||
// CWE-22 path-traversal defense — reject paths that escape SafeRoot
|
||||
// (when set) OR contain literal ".." segments. The validator is in
|
||||
// the same function as the os.WriteFile sink below so CodeQL
|
||||
// recognizes the sanitizer in-scope.
|
||||
safeOut, err := d.validateSafePath(outPath)
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("signer.FileDriver.Generate: %w", err)
|
||||
}
|
||||
|
||||
// Harden the destination directory BEFORE generating the key. If
|
||||
// the directory check fails we bail without touching cryptography.
|
||||
if err := d.DirHardener(filepath.Dir(outPath)); err != nil {
|
||||
return nil, "", fmt.Errorf("signer.FileDriver.Generate: harden dir for %q: %w", outPath, err)
|
||||
if err := d.DirHardener(filepath.Dir(safeOut)); err != nil {
|
||||
return nil, "", fmt.Errorf("signer.FileDriver.Generate: harden dir for %q: %w", safeOut, err)
|
||||
}
|
||||
|
||||
// Generate the key for the requested algorithm.
|
||||
@@ -191,15 +292,15 @@ func (d *FileDriver) Generate(ctx context.Context, alg Algorithm) (Signer, strin
|
||||
// Write 0o600 — owner-read-write only. Any read by group/other is
|
||||
// a configuration regression; the dir 0700 above prevents
|
||||
// enumeration of the file's existence.
|
||||
if err := os.WriteFile(outPath, pemBytes, 0o600); err != nil {
|
||||
return nil, "", fmt.Errorf("signer.FileDriver.Generate: write %q: %w", outPath, err)
|
||||
if err := os.WriteFile(safeOut, pemBytes, 0o600); err != nil {
|
||||
return nil, "", fmt.Errorf("signer.FileDriver.Generate: write %q: %w", safeOut, err)
|
||||
}
|
||||
|
||||
wrapped, err := Wrap(signerKey)
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("signer.FileDriver.Generate: wrap: %w", err)
|
||||
}
|
||||
return wrapped, outPath, nil
|
||||
return wrapped, safeOut, nil
|
||||
}
|
||||
|
||||
func rsaBitsFor(a Algorithm) int {
|
||||
|
||||
Reference in New Issue
Block a user