mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-07 18:11:32 +00:00
7cb453a336
Mechanical reformat. The new 'gofmt drift' CI step (added in
ci-pipeline-cleanup Phase 4, commit 0f205a8) surfaced 111 files
with accumulated gofmt drift across cmd/, internal/, and deploy/test/.
Each file's diff is gofmt-standard: whitespace adjustments, intra-
group import sorting (alphabetical by import path within blank-line-
separated groups), and struct-tag column alignment. No semantic
changes — verified via 'git diff --ignore-all-space' which shows only
the line-position deltas from import reordering.
The gate stays in place after this commit. Going forward it catches
gofmt drift at PR time.
338 lines
12 KiB
Go
338 lines
12 KiB
Go
package handler
|
|
|
|
// Adversarial path-parameter and multi-segment path tests.
|
|
//
|
|
// These tests exercise the input parsing boundary of the certificate handler
|
|
// against the attack categories listed in certctl-adversarial-testing-prompt.md
|
|
// Tier 1A / 1B:
|
|
//
|
|
// * Empty and whitespace-only path IDs
|
|
// * SQL-injection sentinels embedded in the path
|
|
// * Directory traversal (`../../etc/passwd`)
|
|
// * Null bytes and control characters
|
|
// * Extremely long IDs (10 KiB)
|
|
// * Unicode homoglyphs (visually identical substitutes)
|
|
// * Multi-segment paths (OCSP, DER CRL, versions, renew, deploy, revoke)
|
|
//
|
|
// The contract we verify is defensive, not behavioural:
|
|
//
|
|
// 1. The handler never panics.
|
|
// 2. The HTTP status is one of {200, 400, 404, 405} — never 500.
|
|
// 3. The response body is either empty or valid JSON.
|
|
// 4. No attacker-controlled input is echoed verbatim in a 500 body.
|
|
//
|
|
// We do not assert the exact status code for every adversarial input because
|
|
// the current handler intentionally delegates identifier validation to the
|
|
// repository layer; its only job here is to stay up and well-formed.
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/shankar0123/certctl/internal/domain"
|
|
)
|
|
|
|
// adversarialPathInputs is the attack catalog shared by Tier 1A cases. Each
|
|
// entry targets a different parsing surface; adding a new category here makes
|
|
// every Tier 1A test below exercise it automatically.
|
|
func adversarialPathInputs() []struct {
|
|
name string
|
|
input string
|
|
} {
|
|
return []struct {
|
|
name string
|
|
input string
|
|
}{
|
|
{"sql_injection_drop_table", "'; DROP TABLE managed_certificates;--"},
|
|
{"sql_injection_or_true", "' OR 1=1--"},
|
|
{"sql_injection_union", "mc-001' UNION SELECT * FROM agents--"},
|
|
{"path_traversal_dot_dot", "../../etc/passwd"},
|
|
{"path_traversal_encoded", "..%2F..%2Fetc%2Fpasswd"},
|
|
{"null_byte_trailing", "mc-001\x00"},
|
|
{"null_byte_embedded", "mc-\x00-001"},
|
|
{"long_id_10k", strings.Repeat("A", 10000)},
|
|
{"unicode_homoglyph_hyphen", "mc\u2010001"}, // U+2010 HYPHEN
|
|
{"unicode_homoglyph_fullwidth", "mc\uFF0D001"}, // U+FF0D FULLWIDTH HYPHEN-MINUS
|
|
{"control_char_newline", "mc-001\n"},
|
|
{"control_char_tab", "mc\t001"},
|
|
{"control_char_bell", "mc\x07001"},
|
|
{"percent_encoded_null", "mc-001%00"},
|
|
{"whitespace_only", " "},
|
|
{"shell_metacharacters", "mc-001;`rm -rf /`"},
|
|
{"leading_slash", "/mc-001"},
|
|
{"trailing_slash", "mc-001/"},
|
|
{"double_slash", "mc//001"},
|
|
}
|
|
}
|
|
|
|
// assertSafeResponse is the core defensive check. Any adversarial input is
|
|
// allowed to produce a 4xx, but must not panic or leak through as a 500.
|
|
func assertSafeResponse(t *testing.T, w *httptest.ResponseRecorder, label string) {
|
|
t.Helper()
|
|
|
|
// 1. No 500 (500 implies the handler reached an unexpected internal state).
|
|
if w.Code == http.StatusInternalServerError {
|
|
t.Errorf("%s: handler returned 500, body=%q — adversarial input should not reach an internal error path",
|
|
label, w.Body.String())
|
|
}
|
|
|
|
// 2. Status must be in the expected safe set.
|
|
switch w.Code {
|
|
case http.StatusOK, http.StatusCreated, http.StatusAccepted, http.StatusNoContent,
|
|
http.StatusBadRequest, http.StatusNotFound, http.StatusMethodNotAllowed, http.StatusNotImplemented:
|
|
// ok
|
|
default:
|
|
t.Errorf("%s: unexpected status %d (body=%q)", label, w.Code, w.Body.String())
|
|
}
|
|
|
|
// 3. Non-empty bodies must be valid JSON (no template leakage, no raw panics).
|
|
if body := bytes.TrimSpace(w.Body.Bytes()); len(body) > 0 {
|
|
var discard interface{}
|
|
if err := json.Unmarshal(body, &discard); err != nil {
|
|
t.Errorf("%s: response body is not valid JSON: %v (body=%q)", label, err, w.Body.String())
|
|
}
|
|
}
|
|
}
|
|
|
|
// newCertHandlerWithMock builds a handler whose mock service returns nothing.
|
|
// This keeps every adversarial test focused on the handler's parsing layer
|
|
// rather than service behaviour.
|
|
func newCertHandlerWithMock() (CertificateHandler, *MockCertificateService) {
|
|
mock := &MockCertificateService{}
|
|
return NewCertificateHandler(mock), mock
|
|
}
|
|
|
|
// TestGetCertificate_PathInjection runs each adversarial path through the
|
|
// certificate GET handler.
|
|
func TestGetCertificate_PathInjection(t *testing.T) {
|
|
for _, tc := range adversarialPathInputs() {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
defer func() {
|
|
if r := recover(); r != nil {
|
|
t.Fatalf("handler panicked on input %q: %v", tc.input, r)
|
|
}
|
|
}()
|
|
|
|
handler, mock := newCertHandlerWithMock()
|
|
// Force a 404 so we can distinguish "service was called" from
|
|
// "parser accepted the ID"; a 200 with null body is also fine.
|
|
mock.GetCertificateFn = func(_ context.Context, id string) (*domain.ManagedCertificate, error) {
|
|
return nil, ErrMockNotFound
|
|
}
|
|
|
|
// Build the URL by string concatenation to keep attacker-controlled
|
|
// bytes intact (httptest.NewRequest uses url.Parse under the hood,
|
|
// which normalises some characters — we want the raw path on the
|
|
// request object).
|
|
req := httptest.NewRequest(http.MethodGet, "/api/v1/certificates/x", nil)
|
|
req.URL.Path = "/api/v1/certificates/" + tc.input
|
|
req = req.WithContext(contextWithRequestID())
|
|
|
|
w := httptest.NewRecorder()
|
|
handler.GetCertificate(w, req)
|
|
|
|
assertSafeResponse(t, w, "GetCertificate/"+tc.name)
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestUpdateCertificate_PathInjection exercises the PUT handler's path parser.
|
|
// UpdateCertificate splits the path on "/" and takes parts[0]; traversal and
|
|
// double-slash inputs must still short-circuit at the parser rather than
|
|
// reaching the service.
|
|
func TestUpdateCertificate_PathInjection(t *testing.T) {
|
|
body := `{"common_name":"example.com","owner_id":"o-alice","team_id":"t-a","issuer_id":"iss-local","name":"n","renewal_policy_id":"rp-1"}`
|
|
|
|
for _, tc := range adversarialPathInputs() {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
defer func() {
|
|
if r := recover(); r != nil {
|
|
t.Fatalf("handler panicked on input %q: %v", tc.input, r)
|
|
}
|
|
}()
|
|
|
|
handler, mock := newCertHandlerWithMock()
|
|
mock.UpdateCertificateFn = func(_ context.Context, id string, cert domain.ManagedCertificate) (*domain.ManagedCertificate, error) {
|
|
return nil, ErrMockNotFound
|
|
}
|
|
|
|
req := httptest.NewRequest(http.MethodPut, "/api/v1/certificates/x", bytes.NewBufferString(body))
|
|
req.URL.Path = "/api/v1/certificates/" + tc.input
|
|
req.Header.Set("Content-Type", "application/json")
|
|
req = req.WithContext(contextWithRequestID())
|
|
|
|
w := httptest.NewRecorder()
|
|
handler.UpdateCertificate(w, req)
|
|
|
|
assertSafeResponse(t, w, "UpdateCertificate/"+tc.name)
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestArchiveCertificate_PathInjection exercises DELETE.
|
|
func TestArchiveCertificate_PathInjection(t *testing.T) {
|
|
for _, tc := range adversarialPathInputs() {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
defer func() {
|
|
if r := recover(); r != nil {
|
|
t.Fatalf("handler panicked on input %q: %v", tc.input, r)
|
|
}
|
|
}()
|
|
|
|
handler, mock := newCertHandlerWithMock()
|
|
mock.ArchiveCertificateFn = func(_ context.Context, id string) error { return ErrMockNotFound }
|
|
|
|
req := httptest.NewRequest(http.MethodDelete, "/api/v1/certificates/x", nil)
|
|
req.URL.Path = "/api/v1/certificates/" + tc.input
|
|
req = req.WithContext(contextWithRequestID())
|
|
|
|
w := httptest.NewRecorder()
|
|
handler.ArchiveCertificate(w, req)
|
|
|
|
assertSafeResponse(t, w, "ArchiveCertificate/"+tc.name)
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestGetCertificateVersions_MultiSegment is a Tier 1B test: the versions
|
|
// handler requires a 2-segment path (certID/versions). The parser uses
|
|
// strings.Split(path, "/") and checks len(parts) < 2 — but an adversarial
|
|
// caller can inject extra slashes to either produce an empty parts[0] or a
|
|
// very long parts slice. Either way we must not panic.
|
|
func TestGetCertificateVersions_MultiSegment(t *testing.T) {
|
|
cases := []struct {
|
|
name string
|
|
path string
|
|
}{
|
|
{"missing_segment", "/api/v1/certificates/versions"},
|
|
{"empty_cert_id", "/api/v1/certificates//versions"},
|
|
{"traversal_cert_id", "/api/v1/certificates/..%2F..%2Fversions/versions"},
|
|
{"sql_injection_cert_id", "/api/v1/certificates/'%20OR%201=1--/versions"},
|
|
{"null_byte_cert_id", "/api/v1/certificates/mc\x00001/versions"},
|
|
{"very_long_cert_id", "/api/v1/certificates/" + strings.Repeat("A", 5000) + "/versions"},
|
|
{"trailing_segments", "/api/v1/certificates/mc-001/versions/extra/trailing"},
|
|
{"deep_nesting", "/api/v1/certificates/" + strings.Repeat("a/", 50) + "versions"},
|
|
}
|
|
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
defer func() {
|
|
if r := recover(); r != nil {
|
|
t.Fatalf("handler panicked on path %q: %v", tc.path, r)
|
|
}
|
|
}()
|
|
|
|
handler, mock := newCertHandlerWithMock()
|
|
mock.GetCertificateVersionsFn = func(_ context.Context, certID string, page, perPage int) ([]domain.CertificateVersion, int64, error) {
|
|
return []domain.CertificateVersion{}, 0, nil
|
|
}
|
|
|
|
// Use a dummy safe URL in NewRequest to avoid url.Parse panics
|
|
// on control chars, then overwrite with the raw attacker path.
|
|
req := httptest.NewRequest(http.MethodGet, "/safe", nil)
|
|
req.URL.Path = tc.path
|
|
req = req.WithContext(contextWithRequestID())
|
|
|
|
w := httptest.NewRecorder()
|
|
handler.GetCertificateVersions(w, req)
|
|
|
|
assertSafeResponse(t, w, "GetCertificateVersions/"+tc.name)
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestHandleOCSP_MultiSegment exercises the OCSP responder's 2-segment path
|
|
// parser (/.well-known/pki/ocsp/{issuer_id}/{serial_hex}). Each leg is
|
|
// attacker-controlled and the serial can be arbitrary length. This is a key
|
|
// adversarial surface because the serial is passed directly to the
|
|
// CA-operations service, which is expected to treat it as an opaque
|
|
// identifier.
|
|
//
|
|
// M-006 relocation: these paths were previously served at /api/v1/ocsp/*;
|
|
// under RFC 8615 and RFC 6960 they now live under /.well-known/pki/ocsp/*.
|
|
func TestHandleOCSP_MultiSegment(t *testing.T) {
|
|
cases := []struct {
|
|
name string
|
|
path string
|
|
}{
|
|
{"missing_serial", "/.well-known/pki/ocsp/iss-local"},
|
|
{"missing_both", "/.well-known/pki/ocsp/"},
|
|
{"empty_issuer", "/.well-known/pki/ocsp//01ABCDEF"},
|
|
{"empty_serial", "/.well-known/pki/ocsp/iss-local/"},
|
|
{"traversal_issuer", "/.well-known/pki/ocsp/..%2F..%2Fetc/passwd/01"},
|
|
{"null_byte_serial", "/.well-known/pki/ocsp/iss-local/01\x00FF"},
|
|
{"sql_injection_serial", "/.well-known/pki/ocsp/iss-local/01'; DROP TABLE--"},
|
|
{"negative_hex_serial", "/.well-known/pki/ocsp/iss-local/-1"},
|
|
{"unicode_serial", "/.well-known/pki/ocsp/iss-local/01\u2010FF"},
|
|
{"extremely_long_serial", "/.well-known/pki/ocsp/iss-local/" + strings.Repeat("F", 10000)},
|
|
{"extra_segments", "/.well-known/pki/ocsp/iss-local/01FF/extra/segments"},
|
|
}
|
|
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
defer func() {
|
|
if r := recover(); r != nil {
|
|
t.Fatalf("handler panicked on path %q: %v", tc.path, r)
|
|
}
|
|
}()
|
|
|
|
handler, mock := newCertHandlerWithMock()
|
|
mock.GetOCSPResponseFn = func(_ context.Context, issuerID, serialHex string) ([]byte, error) {
|
|
return nil, ErrMockNotFound
|
|
}
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/safe", nil)
|
|
req.URL.Path = tc.path
|
|
req = req.WithContext(contextWithRequestID())
|
|
|
|
w := httptest.NewRecorder()
|
|
handler.HandleOCSP(w, req)
|
|
|
|
// OCSP does NOT guarantee JSON responses (pkix-crl uses binary),
|
|
// so we only check status safety, not body structure.
|
|
if w.Code == http.StatusInternalServerError {
|
|
t.Errorf("HandleOCSP/%s: returned 500 body=%q", tc.name, w.Body.String())
|
|
}
|
|
if w.Code >= 500 {
|
|
t.Errorf("HandleOCSP/%s: unexpected 5xx %d", tc.name, w.Code)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestGetDERCRL_IssuerPathInjection exercises
|
|
// /.well-known/pki/crl/{issuer_id} (RFC 5280 CRL; M-006 relocation from
|
|
// /api/v1/crl/{issuer_id}).
|
|
func TestGetDERCRL_IssuerPathInjection(t *testing.T) {
|
|
for _, tc := range adversarialPathInputs() {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
defer func() {
|
|
if r := recover(); r != nil {
|
|
t.Fatalf("handler panicked on input %q: %v", tc.input, r)
|
|
}
|
|
}()
|
|
|
|
handler, mock := newCertHandlerWithMock()
|
|
mock.GenerateDERCRLFn = func(_ context.Context, issuerID string) ([]byte, error) {
|
|
return nil, ErrMockNotFound
|
|
}
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/.well-known/pki/crl/x", nil)
|
|
req.URL.Path = "/.well-known/pki/crl/" + tc.input
|
|
req = req.WithContext(contextWithRequestID())
|
|
|
|
w := httptest.NewRecorder()
|
|
handler.GetDERCRL(w, req)
|
|
|
|
if w.Code >= 500 {
|
|
t.Errorf("GetDERCRL/%s: unexpected 5xx %d (body=%q)", tc.name, w.Code, w.Body.String())
|
|
}
|
|
})
|
|
}
|
|
}
|