fix(agent,service): SEC-002 — validate certificate_id shape + contain key path

Sprint 1 unified-master-audit closure. Pre-fix the agent built its
on-disk key path via:

  keyPath := filepath.Join(a.config.KeyDir, job.CertificateID+".key")

migrations/000001_initial_schema.up.sql declares managed_certificates.id
as TEXT PRIMARY KEY with no shape constraint, so a compromised control
plane (or a poisoned database row) could deliver a job whose
certificate_id is '../../etc/passwd', '/absolute/path', a NUL-byte
payload, or a Windows-separator-laden string — driving arbitrary
file write or read on the agent host.

Fix (two ends; both load-bearing):

Server side:
  - New internal/validation/certificate_id.go: ValidateCertificateID
    pins the canonical TEXT-PK shape (^[A-Za-z0-9._-]{1,128}$, plus
    explicit '.'/'..' rejection).
  - CertificateService.Create now invokes ValidateCertificateID after
    the existing required-fields check; malformed IDs are refused
    before persistence or downstream job creation.

Agent side:
  - cmd/agent/keymem.go: validateAgentCertID mirrors the server-side
    shape regex. safeAgentKeyPath additionally asserts the joined
    path is contained within KeyDir via filepath.Rel — even if a
    future refactor bypasses the shape check, a path that escapes
    KeyDir fails closed.
  - poll.go + deploy.go: both filepath.Join call sites routed
    through safeAgentKeyPath; rejection surfaces via reportJobStatus
    so the control plane sees the failure.

Regression coverage:
  - internal/validation/certificate_id_test.go: production shapes
    accepted; explicit rejection table for empty, overlong, posix
    traversal, absolute, Windows traversal, Windows separator, NUL
    byte, newline/tab injection, drive prefix, space, unicode dots.
  - cmd/agent/keymem_test.go: validateAgentCertID acceptance +
    rejection tables; safeAgentKeyPath happy path + the 8 audit
    vectors plus empty-keyDir refusal.

Closes SEC-002.
This commit is contained in:
shankar0123
2026-05-16 03:31:59 +00:00
parent e6cfd756ac
commit 037dab7b6f
7 changed files with 381 additions and 4 deletions
+18 -3
View File
@@ -11,7 +11,6 @@ import (
"io"
"net/http"
"os"
"path/filepath"
"strings"
"github.com/certctl-io/certctl/internal/connector/target"
@@ -105,8 +104,24 @@ func (a *Agent) executeDeploymentJob(ctx context.Context, job JobItem) {
// Split PEM into cert and chain (separated by double newline between PEM blocks)
certOnly, chainPEM := splitPEMChain(certPEM)
// Check for locally-stored private key (agent keygen mode)
keyPath := filepath.Join(a.config.KeyDir, job.CertificateID+".key")
// Check for locally-stored private key (agent keygen mode).
//
// SEC-002 closure (Sprint 1, 2026-05-16): safeAgentKeyPath validates
// the certificate_id shape AND asserts the joined path is contained
// within a.config.KeyDir. A crafted certificate_id (path traversal,
// absolute path, NUL byte, Windows separators) fails closed before
// any disk I/O. See cmd/agent/keymem.go for the helper.
keyPath, kerr := safeAgentKeyPath(a.config.KeyDir, job.CertificateID)
if kerr != nil {
a.logger.Error("agent key path validation failed for deployment",
"job_id", job.ID,
"certificate_id", job.CertificateID,
"error", kerr)
if reportErr := a.reportJobStatus(ctx, job.ID, "Failed", fmt.Sprintf("key path validation failed: %v", kerr)); reportErr != nil {
a.logger.Error("failed to report job status to server", "job_id", job.ID, "error", reportErr)
}
return
}
var keyPEM string
keyData, err := os.ReadFile(keyPath)
if err != nil {
+83
View File
@@ -9,6 +9,8 @@ import (
"fmt"
"os"
"path/filepath"
"regexp"
"strings"
)
// Bundle-9 / Audit L-002 + L-003 (agent edition).
@@ -41,6 +43,87 @@ func marshalAgentKeyAndZeroize(priv *ecdsa.PrivateKey, onDER func([]byte) error)
return onDER(der)
}
// SEC-002 closure (Sprint 1, 2026-05-16). The agent derives an on-disk
// key path from job.CertificateID via filepath.Join. Pre-fix, a
// crafted certificate_id ("../../etc/passwd", "/absolute/path",
// "abc\x00d", "..\\Windows\\path") would drive arbitrary file
// write/read on the agent host. The shape regex below mirrors the
// server-side internal/validation.ValidateCertificateID gate — both
// ends MUST hold for the load-bearing defense (the server can't be
// trusted in isolation; a compromised control plane could deliver a
// crafted job).
//
// agentCertIDPattern accepts ASCII letters, digits, ".", "_", "-",
// bounded to 128 chars. Existing prefixed IDs (mc-..., cert-..., etc.)
// satisfy this trivially. Deliberately rejects path separators (POSIX
// and Windows), NUL byte, whitespace, control characters, and the
// bare relative-path tokens "." and "..".
var agentCertIDPattern = regexp.MustCompile(`^[A-Za-z0-9._-]{1,128}$`)
// validateAgentCertID returns an error if id is not a well-formed
// certificate identifier. Mirrors internal/validation.ValidateCertificateID
// — the duplication is deliberate per the package-level comment
// ("cmd/agent is a separate binary; copy-paste cheaper than lifting
// a shared internal/keystore for a single shape check").
func validateAgentCertID(id string) error {
if id == "" {
return fmt.Errorf("certificate_id is required")
}
if len(id) > 128 {
return fmt.Errorf("certificate_id length %d exceeds 128", len(id))
}
if !agentCertIDPattern.MatchString(id) {
return fmt.Errorf("certificate_id %q contains disallowed characters", id)
}
if id == "." || id == ".." {
return fmt.Errorf("certificate_id %q is a relative-path token", id)
}
return nil
}
// safeAgentKeyPath returns the on-disk key path for the given
// certificateID, after validating the ID shape AND asserting the
// joined path is contained within keyDir. Containment is the
// authoritative guard — even if validateAgentCertID is bypassed (e.g.
// a future refactor removes it), the post-Clean rel-path check below
// rejects any path that escapes keyDir.
//
// The two-leg defense:
//
// leg 1: shape check (validateAgentCertID) → cheap up-front fail
// leg 2: containment check (filepath.Rel) → load-bearing guard
//
// Returns the joined path on success, or a non-nil error describing
// the rejected vector.
func safeAgentKeyPath(keyDir, certificateID string) (string, error) {
if err := validateAgentCertID(certificateID); err != nil {
return "", err
}
if keyDir == "" {
return "", fmt.Errorf("safeAgentKeyPath: empty keyDir")
}
cleanDir, err := filepath.Abs(filepath.Clean(keyDir))
if err != nil {
return "", fmt.Errorf("safeAgentKeyPath: resolve keyDir: %w", err)
}
joined := filepath.Join(cleanDir, certificateID+".key")
cleanJoined := filepath.Clean(joined)
rel, err := filepath.Rel(cleanDir, cleanJoined)
if err != nil {
return "", fmt.Errorf("safeAgentKeyPath: rel(%q,%q): %w", cleanDir, cleanJoined, err)
}
// Reject any path that escapes the directory: a leading ".." in the
// relative form means the joined path resolved outside keyDir.
if rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) {
return "", fmt.Errorf("safeAgentKeyPath: %q escapes keyDir %q (rel=%q)", certificateID, cleanDir, rel)
}
// Belt-and-suspenders: the rel form must also not contain a NUL.
if strings.ContainsRune(rel, 0) {
return "", fmt.Errorf("safeAgentKeyPath: NUL byte in computed path")
}
return cleanJoined, nil
}
// ensureAgentKeyDirSecure creates dir (and ancestors) with mode 0700 or
// asserts an existing dir is owner-only. If a pre-existing dir is more
// permissive than 0700 we tighten it to 0700 (logging-free; this is a
+110
View File
@@ -716,3 +716,113 @@ func TestKeymem_AgentMainFlowSmoke(t *testing.T) {
}
}
}
// =============================================================================
// SEC-002 closure (Sprint 1, 2026-05-16) — safeAgentKeyPath path-traversal
// regression coverage.
//
// Pre-fix the agent built the on-disk key path via:
//
// keyPath := filepath.Join(a.config.KeyDir, job.CertificateID+".key")
//
// migrations/000001_initial_schema.up.sql declares
// managed_certificates.id as TEXT PRIMARY KEY with no shape constraint, so
// a crafted certificate_id from a compromised control plane (or a poisoned
// DB row) could land outside KeyDir. The fix:
//
// - validateAgentCertID rejects shape violations up-front
// - safeAgentKeyPath additionally asserts the joined path is contained
// within KeyDir via filepath.Rel; even a future refactor that drops
// the shape regex would still fail closed on escape.
//
// These tests pin both legs against the four vectors called out in the
// audit (../../etc/passwd, /absolute/path, NUL byte, Windows separators).
// =============================================================================
func TestValidateAgentCertID_AcceptsCanonicalShapes(t *testing.T) {
for _, id := range []string{
"mc-cdn-edge",
"mc-cdn-edge-2026.q1",
"cert-1",
"abc123",
"MC-UPPER",
} {
t.Run(id, func(t *testing.T) {
if err := validateAgentCertID(id); err != nil {
t.Errorf("validateAgentCertID(%q): unexpected error %v", id, err)
}
})
}
}
func TestValidateAgentCertID_RejectsTraversalVectors(t *testing.T) {
cases := []struct {
name string
id string
}{
{"empty", ""},
{"parent_token", ".."},
{"current_token", "."},
{"posix_traversal", "../../etc/passwd"},
{"absolute_posix", "/absolute/path"},
{"windows_traversal", `..\..\evil`},
{"windows_separator", `bad\path`},
{"nul_byte", "abc\x00def"},
{"newline", "abc\ndef"},
{"space", "id with spaces"},
{"overlong", strings.Repeat("a", 129)},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if err := validateAgentCertID(tc.id); err == nil {
t.Errorf("id=%q: expected rejection, got nil", tc.id)
}
})
}
}
func TestSafeAgentKeyPath_HappyPath_ProducesContainedPath(t *testing.T) {
keyDir := t.TempDir()
got, err := safeAgentKeyPath(keyDir, "mc-good")
if err != nil {
t.Fatalf("safeAgentKeyPath: %v", err)
}
want := filepath.Join(keyDir, "mc-good.key")
// filepath.Clean normalisation may strip a trailing separator, etc.;
// compare canonical forms.
if filepath.Clean(got) != filepath.Clean(want) {
t.Errorf("safeAgentKeyPath = %q; want %q", got, want)
}
}
func TestSafeAgentKeyPath_RejectsTraversalVectors(t *testing.T) {
keyDir := t.TempDir()
cases := []struct {
name string
id string
}{
{"posix_traversal", "../../etc/passwd"},
{"absolute_posix", "/etc/passwd"},
{"parent_token", ".."},
{"current_token", "."},
{"windows_traversal", `..\..\evil`},
{"windows_separator", `bad\path`},
{"nul_byte", "abc\x00def"},
{"empty", ""},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
_, err := safeAgentKeyPath(keyDir, tc.id)
if err == nil {
t.Errorf("id=%q: expected rejection, got nil", tc.id)
}
})
}
}
func TestSafeAgentKeyPath_RejectsEmptyKeyDir(t *testing.T) {
_, err := safeAgentKeyPath("", "mc-good")
if err == nil {
t.Errorf("empty keyDir: expected rejection, got nil")
}
}
+14 -1
View File
@@ -151,7 +151,20 @@ func (a *Agent) executeCSRJob(ctx context.Context, job JobItem) {
// before any write touches disk. Also defer-clear the PEM buffer for
// the same reason — the encoded key isn't sensitive in transit (it's
// going to disk) but lingers on the heap if we don't.
keyPath := filepath.Join(a.config.KeyDir, job.CertificateID+".key")
//
// SEC-002 closure (Sprint 1, 2026-05-16): safeAgentKeyPath validates
// the certificate_id shape AND asserts the joined path is contained
// within a.config.KeyDir. A crafted certificate_id like
// "../../etc/passwd" or "/abs/path" now fails closed before any
// disk I/O. See cmd/agent/keymem.go for the helper.
keyPath, kerr := safeAgentKeyPath(a.config.KeyDir, job.CertificateID)
if kerr != nil {
a.logger.Error("agent key path validation failed", "job_id", job.ID, "certificate_id", job.CertificateID, "error", kerr)
if reportErr := a.reportJobStatus(ctx, job.ID, "Failed", fmt.Sprintf("key path validation failed: %v", kerr)); reportErr != nil {
a.logger.Error("failed to report job status to server", "job_id", job.ID, "status", "Failed", "error", reportErr)
}
return
}
if err := ensureAgentKeyDirSecure(filepath.Dir(keyPath)); err != nil {
a.logger.Error("agent key dir hardening failed", "job_id", job.ID, "error", err)
if reportErr := a.reportJobStatus(ctx, job.ID, "Failed", fmt.Sprintf("key dir hardening failed: %v", err)); reportErr != nil {