mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-07 19:41:30 +00:00
68f6fd474b
Closes #7. The issuer create/update handlers swallowed all service errors as generic 500s. Now differentiates: 409 for UNIQUE constraint violations, 400 for unsupported issuer type, 404 for not-found on update, 500 for unknown errors. Adds structured error logging via slog. OnboardingWizard now pre-populates config field defaults when a type is selected (matching IssuersPage behavior), preventing empty required fields from causing silent failures. install-agent.sh hardened for curl|bash usage: --agent-id flag, =value syntax, /dev/tty stdin reopening, proper stderr routing in download_binary, non-interactive install examples in help text, and updated wizard commands. Adds adversarial security tests for EST, path traversal, and query injection handlers. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
340 lines
12 KiB
Go
340 lines
12 KiB
Go
package handler
|
|
|
|
// Adversarial EST (RFC 7030) enrollment tests — Tier 1F.
|
|
//
|
|
// EST is the RFC 7030 protocol for certificate enrollment over HTTPS. The
|
|
// control-plane parser accepts PKCS#10 CSRs either as PEM or as base64-encoded
|
|
// DER, and it's a prime target for:
|
|
//
|
|
// * Malformed base64 / non-DER payloads
|
|
// * Valid base64 that doesn't decode to a valid CSR
|
|
// * PEM header spoofing (wrong block type)
|
|
// * Null bytes and control characters embedded in PEM or base64
|
|
// * Huge CSR bodies (we expect the handler's 1 MiB LimitReader to clamp them)
|
|
// * Truncated or partially-written PEM blocks
|
|
// * Unicode homoglyphs in PEM delimiters
|
|
// * Content-Type mismatch (handler ignores Content-Type, but attackers might
|
|
// still try header spoofing)
|
|
//
|
|
// The contract is the same as other adversarial tiers: the handler must never
|
|
// panic and must never return 500 for a malformed CSR (500 is reserved for
|
|
// issuer/service failures). For adversarial CSRs, the correct status is 400.
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/base64"
|
|
"errors"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/shankar0123/certctl/internal/domain"
|
|
)
|
|
|
|
// adversarialCSRInputs exercises the EST CSR parsing surface. None of these
|
|
// should reach the underlying ESTService — they must be rejected by
|
|
// readCSRFromRequest with a 400 before any service call is made.
|
|
func adversarialCSRInputs() []struct {
|
|
name string
|
|
body string
|
|
} {
|
|
// A garbage base64 string that decodes cleanly but isn't a PKCS#10 CSR.
|
|
// base64 of "this is definitely not a CSR" = dGhpcyBpcyBkZWZpbml0ZWx5IG5vdCBhIENTUg==
|
|
nonCSRBase64 := base64.StdEncoding.EncodeToString([]byte("this is definitely not a CSR"))
|
|
|
|
return []struct {
|
|
name string
|
|
body string
|
|
}{
|
|
{"garbage_string", "not-a-csr-at-all"},
|
|
{"base64_garbage", "!!!@@@###$$$%%%"},
|
|
{"base64_valid_non_csr", nonCSRBase64},
|
|
{"base64_very_short", "AA=="},
|
|
{"null_byte_only", "\x00"},
|
|
{"null_bytes_padding", "\x00\x00\x00\x00\x00\x00\x00\x00"},
|
|
{"control_chars", "\x01\x02\x03\x04\x05\x06\x07\x08"},
|
|
{"pem_wrong_block_type", "-----BEGIN CERTIFICATE-----\nMIIB\n-----END CERTIFICATE-----\n"},
|
|
{"pem_wrong_header_close", "-----BEGIN CERTIFICATE REQUEST-----\nMIIB\n-----END PRIVATE KEY-----\n"},
|
|
{"pem_empty_block", "-----BEGIN CERTIFICATE REQUEST-----\n-----END CERTIFICATE REQUEST-----\n"},
|
|
{"pem_garbage_body", "-----BEGIN CERTIFICATE REQUEST-----\n!!!not base64!!!\n-----END CERTIFICATE REQUEST-----\n"},
|
|
{"pem_truncated", "-----BEGIN CERTIFICATE REQUEST-----\nMIIBijCCAT"},
|
|
{"pem_no_end_marker", "-----BEGIN CERTIFICATE REQUEST-----\nMIIBijCCATICAQAwFjEUMBIGA1UE\n"},
|
|
{"pem_header_injection", "-----BEGIN CERTIFICATE REQUEST-----\r\nHost: evil.com\r\n\r\nMIIB\n-----END CERTIFICATE REQUEST-----\n"},
|
|
{"pem_embedded_null", "-----BEGIN CERTIFICATE\x00REQUEST-----\nMIIB\n-----END CERTIFICATE REQUEST-----\n"},
|
|
{"unicode_homoglyph_pem", "-----BEGIN CERTIFICATE REQUEST─────\nMIIB\n─────END CERTIFICATE REQUEST-----\n"},
|
|
{"double_pem_block", "-----BEGIN CERTIFICATE REQUEST-----\nMIIB\n-----END CERTIFICATE REQUEST-----\n-----BEGIN CERTIFICATE REQUEST-----\nMIIB\n-----END CERTIFICATE REQUEST-----\n"},
|
|
{"json_body", `{"csr":"MIIB","common_name":"attacker.com"}`},
|
|
{"xml_body", `<?xml version="1.0"?><csr>MIIB</csr>`},
|
|
{"shell_metacharacters", "$(whoami); rm -rf / #"},
|
|
{"sql_injection", "' OR 1=1; DROP TABLE certificates;--"},
|
|
{"long_garbage_10k", strings.Repeat("A", 10000)},
|
|
{"long_base64_not_csr", base64.StdEncoding.EncodeToString(bytes.Repeat([]byte{0xFF}, 5000))},
|
|
{"base64_with_newlines_garbage", "AAAAAAAAAAAAAAAA\nBBBBBBBBBBBBBBBB\nCCCCCCCCCCCCCCCC"},
|
|
{"percent_encoded_pem", "%2D%2D%2D%2D%2DBEGIN+CERTIFICATE+REQUEST%2D%2D%2D%2D%2D"},
|
|
}
|
|
}
|
|
|
|
// assertESTErrorResponse enforces the EST handler contract for adversarial CSRs:
|
|
// no panic, no 500, body is valid JSON (since Error helper emits JSON errors).
|
|
func assertESTErrorResponse(t *testing.T, w *httptest.ResponseRecorder, label string) {
|
|
t.Helper()
|
|
|
|
// The handler must never reach a 500 for parser-rejected CSRs — that would
|
|
// indicate a service call slipped through.
|
|
if w.Code == http.StatusInternalServerError {
|
|
t.Errorf("%s: handler returned 500 body=%q — adversarial CSR should not reach the service layer",
|
|
label, w.Body.String())
|
|
}
|
|
|
|
// The handler should return 400 Bad Request for adversarial CSR inputs.
|
|
// A 405 (method not allowed) is impossible here because we always POST.
|
|
if w.Code != http.StatusBadRequest {
|
|
t.Errorf("%s: expected 400, got %d (body=%q)", label, w.Code, w.Body.String())
|
|
}
|
|
}
|
|
|
|
// newESTHandlerWithTrap returns an ESTHandler whose service panics if reached.
|
|
// This is the core invariant for Tier 1F: adversarial CSRs must be rejected at
|
|
// the parser, never reaching SimpleEnroll/SimpleReEnroll on the service.
|
|
func newESTHandlerWithTrap() (ESTHandler, *trappedESTService) {
|
|
svc := &trappedESTService{}
|
|
return NewESTHandler(svc), svc
|
|
}
|
|
|
|
// trappedESTService is a mock that fails the test if any service method is
|
|
// called with an adversarial CSR. The parser should reject these before they
|
|
// get here.
|
|
type trappedESTService struct {
|
|
serviceCalled bool
|
|
}
|
|
|
|
func (t *trappedESTService) GetCACerts(ctx context.Context) (string, error) {
|
|
t.serviceCalled = true
|
|
return "", errors.New("trap: GetCACerts should not be called from adversarial CSR tests")
|
|
}
|
|
|
|
func (t *trappedESTService) SimpleEnroll(ctx context.Context, csrPEM string) (*domain.ESTEnrollResult, error) {
|
|
t.serviceCalled = true
|
|
return nil, errors.New("trap: SimpleEnroll should not be called from adversarial CSR tests")
|
|
}
|
|
|
|
func (t *trappedESTService) SimpleReEnroll(ctx context.Context, csrPEM string) (*domain.ESTEnrollResult, error) {
|
|
t.serviceCalled = true
|
|
return nil, errors.New("trap: SimpleReEnroll should not be called from adversarial CSR tests")
|
|
}
|
|
|
|
func (t *trappedESTService) GetCSRAttrs(ctx context.Context) ([]byte, error) {
|
|
t.serviceCalled = true
|
|
return nil, errors.New("trap: GetCSRAttrs should not be called from adversarial CSR tests")
|
|
}
|
|
|
|
// TestESTSimpleEnroll_AdversarialCSRs runs each adversarial CSR through the
|
|
// enrollment endpoint.
|
|
func TestESTSimpleEnroll_AdversarialCSRs(t *testing.T) {
|
|
for _, tc := range adversarialCSRInputs() {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
defer func() {
|
|
if r := recover(); r != nil {
|
|
t.Fatalf("handler panicked on body %q: %v", tc.body, r)
|
|
}
|
|
}()
|
|
|
|
h, svc := newESTHandlerWithTrap()
|
|
|
|
req := httptest.NewRequest(http.MethodPost, "/.well-known/est/simpleenroll", strings.NewReader(tc.body))
|
|
req.Header.Set("Content-Type", "application/pkcs10")
|
|
|
|
w := httptest.NewRecorder()
|
|
h.SimpleEnroll(w, req)
|
|
|
|
assertESTErrorResponse(t, w, "SimpleEnroll/"+tc.name)
|
|
|
|
if svc.serviceCalled {
|
|
t.Errorf("SimpleEnroll/%s: service was reached with adversarial CSR (body=%q)",
|
|
tc.name, tc.body)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestESTSimpleReEnroll_AdversarialCSRs runs each adversarial CSR through the
|
|
// re-enrollment endpoint. Same contract as simpleenroll.
|
|
func TestESTSimpleReEnroll_AdversarialCSRs(t *testing.T) {
|
|
for _, tc := range adversarialCSRInputs() {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
defer func() {
|
|
if r := recover(); r != nil {
|
|
t.Fatalf("handler panicked on body %q: %v", tc.body, r)
|
|
}
|
|
}()
|
|
|
|
h, svc := newESTHandlerWithTrap()
|
|
|
|
req := httptest.NewRequest(http.MethodPost, "/.well-known/est/simplereenroll", strings.NewReader(tc.body))
|
|
req.Header.Set("Content-Type", "application/pkcs10")
|
|
|
|
w := httptest.NewRecorder()
|
|
h.SimpleReEnroll(w, req)
|
|
|
|
assertESTErrorResponse(t, w, "SimpleReEnroll/"+tc.name)
|
|
|
|
if svc.serviceCalled {
|
|
t.Errorf("SimpleReEnroll/%s: service was reached with adversarial CSR (body=%q)",
|
|
tc.name, tc.body)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestESTSimpleEnroll_HugeBody verifies the handler's 1 MiB limit truncates
|
|
// oversized requests at the LimitReader boundary. We send a 2 MiB body of
|
|
// base64 garbage and confirm the handler rejects it cleanly (400, no panic,
|
|
// no 500) and the service is never reached.
|
|
func TestESTSimpleEnroll_HugeBody(t *testing.T) {
|
|
defer func() {
|
|
if r := recover(); r != nil {
|
|
t.Fatalf("handler panicked on 2 MiB body: %v", r)
|
|
}
|
|
}()
|
|
|
|
// 2 MiB of base64-valid garbage: the LimitReader will truncate to 1 MiB, and
|
|
// the truncated base64 chunk won't parse as a valid PKCS#10 CSR.
|
|
huge := strings.Repeat("A", 2<<20)
|
|
|
|
h, svc := newESTHandlerWithTrap()
|
|
|
|
req := httptest.NewRequest(http.MethodPost, "/.well-known/est/simpleenroll", strings.NewReader(huge))
|
|
req.Header.Set("Content-Type", "application/pkcs10")
|
|
|
|
w := httptest.NewRecorder()
|
|
h.SimpleEnroll(w, req)
|
|
|
|
// Contract: 400 Bad Request (parser fail), no panic, no 500.
|
|
if w.Code == http.StatusInternalServerError {
|
|
t.Errorf("HugeBody: handler returned 500 for 2 MiB body (body=%q)", w.Body.String())
|
|
}
|
|
if w.Code != http.StatusBadRequest {
|
|
t.Errorf("HugeBody: expected 400, got %d (body=%q)", w.Code, w.Body.String())
|
|
}
|
|
if svc.serviceCalled {
|
|
t.Error("HugeBody: service was reached with 2 MiB adversarial body")
|
|
}
|
|
}
|
|
|
|
// TestESTSimpleEnroll_ExactlyAtLimit sends a body exactly at the 1 MiB
|
|
// LimitReader boundary. The body is still garbage (won't parse as CSR), but we
|
|
// verify the handler doesn't panic or hang on the boundary case.
|
|
func TestESTSimpleEnroll_ExactlyAtLimit(t *testing.T) {
|
|
defer func() {
|
|
if r := recover(); r != nil {
|
|
t.Fatalf("handler panicked on exact-limit body: %v", r)
|
|
}
|
|
}()
|
|
|
|
atLimit := strings.Repeat("A", 1<<20) // exactly 1 MiB
|
|
|
|
h, _ := newESTHandlerWithTrap()
|
|
|
|
req := httptest.NewRequest(http.MethodPost, "/.well-known/est/simpleenroll", strings.NewReader(atLimit))
|
|
w := httptest.NewRecorder()
|
|
h.SimpleEnroll(w, req)
|
|
|
|
if w.Code == http.StatusInternalServerError {
|
|
t.Errorf("ExactlyAtLimit: handler returned 500 (body=%q)", w.Body.String())
|
|
}
|
|
}
|
|
|
|
// TestESTSimpleEnroll_MultipartBody sends a multipart/form-data body that a
|
|
// naive parser might try to unwrap. The handler should treat the raw bytes as
|
|
// a CSR payload and reject them.
|
|
func TestESTSimpleEnroll_MultipartBody(t *testing.T) {
|
|
defer func() {
|
|
if r := recover(); r != nil {
|
|
t.Fatalf("handler panicked on multipart body: %v", r)
|
|
}
|
|
}()
|
|
|
|
multipart := "--boundary\r\nContent-Disposition: form-data; name=\"csr\"\r\n\r\nMIIB\r\n--boundary--\r\n"
|
|
|
|
h, svc := newESTHandlerWithTrap()
|
|
|
|
req := httptest.NewRequest(http.MethodPost, "/.well-known/est/simpleenroll", strings.NewReader(multipart))
|
|
req.Header.Set("Content-Type", "multipart/form-data; boundary=boundary")
|
|
w := httptest.NewRecorder()
|
|
h.SimpleEnroll(w, req)
|
|
|
|
if w.Code != http.StatusBadRequest {
|
|
t.Errorf("MultipartBody: expected 400, got %d (body=%q)", w.Code, w.Body.String())
|
|
}
|
|
if svc.serviceCalled {
|
|
t.Error("MultipartBody: service was reached with multipart wrapper")
|
|
}
|
|
}
|
|
|
|
// TestESTCACerts_MethodAbuse verifies the /cacerts endpoint only accepts GET
|
|
// and rejects every other method cleanly. This is a small safety check for
|
|
// the spec invariant.
|
|
func TestESTCACerts_MethodAbuse(t *testing.T) {
|
|
methods := []string{
|
|
http.MethodPost, http.MethodPut, http.MethodDelete,
|
|
http.MethodPatch, http.MethodHead, http.MethodOptions,
|
|
"TRACE", "CONNECT", "PROPFIND", "BOGUS",
|
|
}
|
|
|
|
for _, method := range methods {
|
|
t.Run(method, func(t *testing.T) {
|
|
defer func() {
|
|
if r := recover(); r != nil {
|
|
t.Fatalf("handler panicked on method %s: %v", method, r)
|
|
}
|
|
}()
|
|
|
|
h, _ := newESTHandlerWithTrap()
|
|
|
|
req := httptest.NewRequest(method, "/.well-known/est/cacerts", nil)
|
|
w := httptest.NewRecorder()
|
|
h.CACerts(w, req)
|
|
|
|
// HEAD on a GET handler in Go's stdlib is normally accepted, but
|
|
// this handler enforces strict GET-only — so HEAD should also get 405.
|
|
if w.Code != http.StatusMethodNotAllowed {
|
|
t.Errorf("method %s: expected 405, got %d", method, w.Code)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestESTSimpleEnroll_MethodAbuse verifies strict POST-only enforcement.
|
|
func TestESTSimpleEnroll_MethodAbuse(t *testing.T) {
|
|
methods := []string{
|
|
http.MethodGet, http.MethodPut, http.MethodDelete,
|
|
http.MethodPatch, http.MethodHead, http.MethodOptions,
|
|
"TRACE", "CONNECT",
|
|
}
|
|
|
|
for _, method := range methods {
|
|
t.Run(method, func(t *testing.T) {
|
|
defer func() {
|
|
if r := recover(); r != nil {
|
|
t.Fatalf("handler panicked on method %s: %v", method, r)
|
|
}
|
|
}()
|
|
|
|
h, svc := newESTHandlerWithTrap()
|
|
|
|
req := httptest.NewRequest(method, "/.well-known/est/simpleenroll", strings.NewReader("body"))
|
|
w := httptest.NewRecorder()
|
|
h.SimpleEnroll(w, req)
|
|
|
|
if w.Code != http.StatusMethodNotAllowed {
|
|
t.Errorf("method %s: expected 405, got %d", method, w.Code)
|
|
}
|
|
if svc.serviceCalled {
|
|
t.Errorf("method %s: service was called for non-POST", method)
|
|
}
|
|
})
|
|
}
|
|
}
|