mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-07 19:21:29 +00:00
30f9f1e712
Closes M-001 + M-002 + M-013 + M-018 + M-025 from
comprehensive-audit-2026-04-25.
M-001 (CWE-916) — PBKDF2 100k -> 600k via v3 blob format
internal/crypto/encryption.go:
- New v3Magic (0x03), pbkdf2IterationsV3 (600,000 — OWASP 2024
Password Storage Cheat Sheet floor), v3SaltSize (16 bytes),
deriveKeyWithSaltV3 helper.
- EncryptIfKeySet now unconditionally writes v3:
magic(0x03) || salt(16) || nonce(12) || ciphertext+tag
- DecryptIfKeySet falls through v3 -> v2 -> v1 with AEAD verification
at each step. Wrong-passphrase v3 reads cannot be silently
misattributed to v2/v1.
- IsLegacyFormat updated to recognize 0x03 as non-legacy.
internal/crypto/encryption_v3_test.go (NEW, 7 tests):
V3 round-trip / V2 read-fallback against deterministic v2 fixture /
V3 wrong-passphrase fails / V3-vs-V2 dispatch order / V2 vs V3 keys
differ for same (passphrase, salt) / iteration-count pin at OWASP
2024 floor / IsLegacyFormat-recognises-V3.
Coverage internal/crypto: 86.7% -> 88.2%.
M-002 (CWE-862) — Auth-exempt allowlist constants + AST regression test
Recon found auth-exempt surface spans TWO layers (audit's claim was
incomplete):
Layer 1 (router.go direct r.mux.Handle):
GET /health, GET /ready, GET /api/v1/auth/info, GET /api/v1/version
Layer 2 (cmd/server/main.go::buildFinalHandler URL-prefix dispatch):
/.well-known/pki/*, /.well-known/est/*, /scep[/...]*
internal/api/router/router.go:
- New AuthExemptRouterRoutes constant with per-entry justifications.
- New AuthExemptDispatchPrefixes constant.
internal/api/router/auth_exempt_test.go (NEW, 2 tests):
AST-walks router.go for every direct mux.Handle call and asserts
set equals AuthExemptRouterRoutes; reads source bytes of Register /
RegisterFunc and asserts they still wrap with middleware.Chain.
cmd/server/auth_exempt_test.go (NEW, 2 tests):
14-case table test on buildFinalHandler asserting documented
prefixes route to noAuthHandler and authenticated routes route to
apiHandler; inverse-overlap pin proves no documented bypass shadows
an authenticated prefix.
M-013 (CWE-942) — CORS deny-by-default verified-already-clean + pin
Audit claim 'default allows all origins if env-var unset' was WRONG.
internal/api/middleware/middleware.go::NewCORS already denies cross-
origin requests when len(cfg.AllowedOrigins) == 0 (no
Access-Control-Allow-Origin header is emitted, same-origin policy
applies).
internal/api/middleware/cors_test.go: +TestNewCORS_NilOriginsDeniesAll
+ TestNewCORS_M013_ContractDocumentedInOrder (5-case table test
pinning the 3-arm dispatch contract).
M-018 (CWE-319 / PCI-DSS Req 4) — Postgres TLS opt-in toggle
deploy/helm/certctl/values.yaml: new postgresql.tls.{mode,caSecretRef}
operator-facing knobs. Default 'disable' preserves in-cluster pod-
network behavior; PCI-scoped operators set verify-full.
deploy/helm/certctl/templates/_helpers.tpl: certctl.databaseURL helper
pipes postgresql.tls.mode into ?sslmode=.
deploy/helm/certctl/templates/server-secret.yaml: uses the helper
instead of hardcoded sslmode=disable.
deploy/docker-compose.yml: CERTCTL_DATABASE_URL is now
${CERTCTL_DATABASE_URL:-...} so operators override without editing.
docs/database-tls.md (NEW): operator runbook covering 4 deployment
shapes, RDS verify-full example with PGSSLROOTCERT mount, and
pg_stat_ssl verification query.
helm template + helm lint clean.
M-025 (OWASP ASVS L2 §11.2.1) — Per-key rate limiting
internal/api/middleware/middleware.go::NewRateLimiter rewritten from
a single global tokenBucket to a keyedRateLimiter map keyed on
'user:'+GetUser(ctx) for authenticated callers
'ip:'+RemoteAddr-host for unauthenticated
- Empty UserKey strings treated as unauthenticated.
- X-Forwarded-For intentionally NOT consulted (header-spoofing risk).
- Create-on-demand bucket allocation under sync.RWMutex with double-
check pattern.
RateLimitConfig.PerUserRPS / PerUserBurstSize fields with env vars
CERTCTL_RATE_LIMIT_PER_USER_RPS / CERTCTL_RATE_LIMIT_PER_USER_BURST
allow per-user budgets distinct from per-IP.
internal/api/middleware/ratelimit_keyed_test.go (NEW, 5 tests):
TwoIPsHaveIndependentBuckets / SameUserDifferentIPsShareBucket /
TwoUsersHaveIndependentBuckets / PerUserBudgetOverride /
EmptyUserKeyTreatedAsAnonymous.
Coverage internal/api/middleware: 82.1% -> 83.7%.
Audit deliverables:
cowork/comprehensive-audit-2026-04-25/audit-report.md: score
25/55 -> 30/55 closed (High 7/9, Medium 7/27 -> 12/27, Low 8/19).
cowork/comprehensive-audit-2026-04-25/findings.yaml: 5 status flips
open -> closed with closure notes citing the Bundle B mechanism.
certctl/CHANGELOG.md: Bundle B section under [unreleased].
Verification:
go test -count=1 -short ./... all green
staticcheck on changed packages no new SA*/ST* hits
(the 4 pre-existing SA1019 sites in cmd/server/main_test.go are
Bundle 9 / M-028 partial closure leftovers tracked in Bundle C)
helm template + helm lint clean
internal/repository/postgres setup-fail sandbox disk pressure,
same on master HEAD before this branch — environmental, not Bundle B
183 lines
5.7 KiB
Go
183 lines
5.7 KiB
Go
package router
|
|
|
|
import (
|
|
"go/ast"
|
|
"go/parser"
|
|
"go/token"
|
|
"os"
|
|
"sort"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
// osReadFile is a thin wrapper that the test functions use; aliased so the
|
|
// file's helper section reads cleanly without importing "os" repeatedly in
|
|
// the body.
|
|
var osReadFile = os.ReadFile
|
|
|
|
// Bundle B / Audit M-002 (CWE-862 Authorization Bypass).
|
|
//
|
|
// The certctl router has TWO layers where a route can be made auth-exempt:
|
|
//
|
|
// 1. internal/api/router/router.go::RegisterHandlers calls r.mux.Handle
|
|
// directly (instead of r.Register), bypassing the router-level
|
|
// middleware.Chain wrap. The 4 routes that do this today are pinned
|
|
// in AuthExemptRouterRoutes.
|
|
//
|
|
// 2. cmd/server/main.go::buildFinalHandler dispatches by URL prefix,
|
|
// routing some prefixes through the noAuthHandler chain. Those are
|
|
// pinned in AuthExemptDispatchPrefixes.
|
|
//
|
|
// This file pins layer 1: it parses router.go's AST, finds every
|
|
// r.mux.Handle string-literal arg, and asserts that set equals
|
|
// AuthExemptRouterRoutes exactly. Adding a new mux.Handle without
|
|
// updating the allowlist constant fails CI; updating the constant
|
|
// requires a code reviewer to read the new entry's justification
|
|
// comment. Layer 2's pin lives in cmd/server/main_test.go for symmetry
|
|
// with the dispatch logic itself.
|
|
|
|
func TestRouter_AuthExemptAllowlist_PinsActualRegistrations(t *testing.T) {
|
|
actual, err := extractRouterDirectMuxHandles("router.go")
|
|
if err != nil {
|
|
t.Fatalf("scan router.go: %v", err)
|
|
}
|
|
expected := append([]string(nil), AuthExemptRouterRoutes...)
|
|
sort.Strings(actual)
|
|
sort.Strings(expected)
|
|
|
|
if !slicesEqual(actual, expected) {
|
|
t.Errorf("AuthExemptRouterRoutes drift detected.\n"+
|
|
" Direct r.mux.Handle calls in router.go: %v\n"+
|
|
" AuthExemptRouterRoutes constant: %v\n"+
|
|
"\n"+
|
|
"If you added a new mux.Handle, you MUST also add the route to\n"+
|
|
"AuthExemptRouterRoutes WITH a justification comment explaining\n"+
|
|
"why it is safe-without-auth. Adding a new auth-bypass without\n"+
|
|
"updating the allowlist is the M-002 regression this test guards.\n",
|
|
actual, expected)
|
|
}
|
|
}
|
|
|
|
func TestRouter_AllRegisterCallsGoThroughMiddlewareChain(t *testing.T) {
|
|
// Every r.Register / r.RegisterFunc call in router.go pipes through
|
|
// middleware.Chain(handler, r.middleware...). Any future change to
|
|
// the Register / RegisterFunc body that drops the middleware wrap
|
|
// silently exempts every "authenticated" route from auth — fail fast.
|
|
//
|
|
// We read router.go as raw bytes and check for the load-bearing
|
|
// strings inside each function body. AST stringification is overkill
|
|
// for a substring check.
|
|
raw, err := readFileBytes("router.go")
|
|
if err != nil {
|
|
t.Fatalf("read router.go: %v", err)
|
|
}
|
|
registerBody := extractFuncSourceByName(raw, "Register")
|
|
registerFuncBody := extractFuncSourceByName(raw, "RegisterFunc")
|
|
|
|
if !strings.Contains(registerBody, "middleware.Chain") {
|
|
t.Errorf("Router.Register no longer pipes through middleware.Chain — auth bypass risk. Body:\n%s", registerBody)
|
|
}
|
|
// RegisterFunc is allowed to either chain directly or delegate to Register.
|
|
if !strings.Contains(registerFuncBody, "r.Register") && !strings.Contains(registerFuncBody, "middleware.Chain") {
|
|
t.Errorf("Router.RegisterFunc no longer delegates to Register / middleware.Chain — auth bypass risk. Body:\n%s", registerFuncBody)
|
|
}
|
|
}
|
|
|
|
// --- helpers --------------------------------------------------------------
|
|
|
|
func parseRouterFile(name string) (*ast.File, error) {
|
|
fset := token.NewFileSet()
|
|
return parser.ParseFile(fset, name, nil, parser.ParseComments)
|
|
}
|
|
|
|
// extractRouterDirectMuxHandles returns every "<METHOD> <PATH>" string
|
|
// literal passed as the first argument to r.mux.Handle in the file.
|
|
func extractRouterDirectMuxHandles(name string) ([]string, error) {
|
|
src, err := parseRouterFile(name)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var out []string
|
|
ast.Inspect(src, func(n ast.Node) bool {
|
|
call, ok := n.(*ast.CallExpr)
|
|
if !ok {
|
|
return true
|
|
}
|
|
// Looking for r.mux.Handle(...) — selector chain Sel="Handle",
|
|
// X is itself a SelectorExpr Sel="mux".
|
|
sel, ok := call.Fun.(*ast.SelectorExpr)
|
|
if !ok || sel.Sel.Name != "Handle" {
|
|
return true
|
|
}
|
|
inner, ok := sel.X.(*ast.SelectorExpr)
|
|
if !ok || inner.Sel.Name != "mux" {
|
|
return true
|
|
}
|
|
if len(call.Args) == 0 {
|
|
return true
|
|
}
|
|
lit, ok := call.Args[0].(*ast.BasicLit)
|
|
if !ok || lit.Kind != token.STRING {
|
|
return true
|
|
}
|
|
// Skip the generic Register helper itself (line 38: r.mux.Handle(pattern, ...))
|
|
// — pattern there is a func parameter, not a string literal.
|
|
// Trim quotes on the literal value.
|
|
v := strings.Trim(lit.Value, "\"`")
|
|
if v == "" {
|
|
return true
|
|
}
|
|
out = append(out, v)
|
|
return true
|
|
})
|
|
return out, nil
|
|
}
|
|
|
|
func readFileBytes(name string) ([]byte, error) {
|
|
return osReadFile(name)
|
|
}
|
|
|
|
// extractFuncSourceByName returns the raw source body (between the opening
|
|
// and matching closing brace) of the named func defined in src.
|
|
func extractFuncSourceByName(src []byte, name string) string {
|
|
needle := []byte("func (r *Router) " + name + "(")
|
|
idx := indexOfBytes(src, needle)
|
|
if idx < 0 {
|
|
return ""
|
|
}
|
|
// Find first '{' after the signature, then walk to the matching '}'.
|
|
openIdx := idx + indexOfBytes(src[idx:], []byte("{"))
|
|
if openIdx < 0 {
|
|
return ""
|
|
}
|
|
depth := 0
|
|
for i := openIdx; i < len(src); i++ {
|
|
switch src[i] {
|
|
case '{':
|
|
depth++
|
|
case '}':
|
|
depth--
|
|
if depth == 0 {
|
|
return string(src[openIdx : i+1])
|
|
}
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func indexOfBytes(haystack, needle []byte) int {
|
|
return strings.Index(string(haystack), string(needle))
|
|
}
|
|
|
|
func slicesEqual(a, b []string) bool {
|
|
if len(a) != len(b) {
|
|
return false
|
|
}
|
|
for i := range a {
|
|
if a[i] != b[i] {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|