Files
certctl/internal/validation/certificate_id_test.go
T
shankar0123 037dab7b6f 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.
2026-05-16 03:31:59 +00:00

80 lines
2.2 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Copyright 2026 certctl LLC. All rights reserved.
// SPDX-License-Identifier: BUSL-1.1
package validation
import (
"strings"
"testing"
)
// SEC-002 closure (Sprint 1, 2026-05-16). Pin the server-side
// certificate_id shape gate. Companion to the agent-side
// safeAgentKeyPath containment check in cmd/agent/keymem.go.
func TestValidateCertificateID_AcceptsProductionShapes(t *testing.T) {
cases := []string{
"mc-cdn-edge",
"mc-cdn-edge-2026.q1",
"mc_internal_api",
"abc123",
"MC-UPPER-CASE",
strings.Repeat("a", 128), // exact-length boundary
}
for _, id := range cases {
t.Run(id, func(t *testing.T) {
if err := ValidateCertificateID(id); err != nil {
t.Errorf("ValidateCertificateID(%q): unexpected error %v", id, err)
}
})
}
}
func TestValidateCertificateID_RejectsEmpty(t *testing.T) {
if err := ValidateCertificateID(""); err == nil {
t.Errorf("empty id: expected rejection, got nil")
}
}
func TestValidateCertificateID_RejectsOverlong(t *testing.T) {
id := strings.Repeat("a", 129)
err := ValidateCertificateID(id)
if err == nil {
t.Fatalf("overlong id: expected rejection, got nil")
}
if !strings.Contains(err.Error(), "exceeds 128") {
t.Errorf("expected length error, got %v", err)
}
}
// TestValidateCertificateID_RejectsPathTraversalVectors pins the four
// vectors called out in SEC-002 (../../etc/passwd, /absolute/path,
// NUL-byte, Windows separators) plus the bare ".." token.
func TestValidateCertificateID_RejectsPathTraversalVectors(t *testing.T) {
cases := []struct {
name string
id string
}{
{"posix_traversal", "../../etc/passwd"},
{"absolute_posix", "/absolute/path"},
{"absolute_root", "/"},
{"parent_token", ".."},
{"current_token", "."},
{"windows_traversal", `..\..\evil`},
{"windows_separator", `bad\path`},
{"nul_byte", "abc\x00def"},
{"newline_injection", "abc\ndef"},
{"tab_injection", "abc\tdef"},
{"colon_drive", "C:\\Windows"},
{"space_inside", "id with spaces"},
{"unicode_dots", "abcdef"}, // U+2024 ONE DOT LEADER — looks like .
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if err := ValidateCertificateID(tc.id); err == nil {
t.Errorf("id=%q: expected rejection, got nil", tc.id)
}
})
}
}