mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-12 02:49:01 +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:
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user