mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-07 12:21:31 +00:00
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:
+18
-3
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user