mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-07 23:11:32 +00:00
5dc698307b
Mechanical sed across the main go.mod's module declaration, the f5-mock-icontrol
sub-module's go.mod, every Go file's import path (361 files), and a rebuild of
the checked-in f5-mock-icontrol binary so its embedded build-info reflects the
new module path. No behavior change.
Choice B from cowork/transfer-certctl-to-org.md, executed 2026-05-04. Choice A
(keep module path declared as github.com/shankar0123/certctl regardless of
repo URL) shipped on the day of the org transfer (2026-05-03) since we had no
external Go consumers; this commit closes that deferral.
Backward-compat: GitHub HTTP redirects continue to forward
github.com/shankar0123/certctl → github.com/certctl-io/certctl at the URL
level, but Go's module proxy uses the path declared in go.mod as the
canonical name. Pre-fix, anyone trying `go get github.com/certctl-io/certctl/...`
hit a "module path mismatch" error because go.mod said
github.com/shankar0123/certctl and the URL they fetched it from said
certctl-io/certctl. Post-fix, the canonical name and the URL agree, so
go get / go install / external Go consumers / Go-tooling integrations
work cleanly via either the new path (preferred) or the old path (which
redirects and Go follows the redirect for source fetch).
Anyone still importing the old path inside their own code keeps working
provided they update their go.mod's `require` line to match — the module
path declared in their consumer's go.sum / go.mod is the authoritative
import name, so a mass sed across their import statements is the migration
on the consumer side. No external consumers exist today.
Diff shape:
361 *.go files — import path replacement only
2 go.mod — module declaration replacement only
1 binary — deploy/test/f5-mock-icontrol/f5-mock-icontrol rebuilt
so embedded build-info reflects the new path (8618965 vs
8618933 bytes; 32-byte diff is the build-info change)
Total: 364 files, 730 insertions / 730 deletions, net-zero size, pure
mechanical substitution.
Verification:
gofmt: 17 files needed re-alignment after sed (the new path is one char
shorter than the old, so column-aligned import groups drifted). Applied
`gofmt -w` to fix.
go mod tidy: clean exit on both modules.
go vet ./...: clean exit.
go build ./...: clean exit.
go test -short -count=1 on representative packages: all green
(internal/domain, internal/validation, internal/crypto, internal/crypto/signer,
cmd/agent). Test output now reads `ok github.com/certctl-io/certctl/...`
confirming the module path resolves correctly.
binary: f5-mock-icontrol rebuilt; `strings | grep shankar0123` returns
nothing; `strings | grep certctl-io/certctl` shows the new module path
embedded in build-info.
Files intentionally NOT touched in this commit:
README.md / CHANGELOG.md / docs/ / etc. — already swept to certctl-io
URLs in commit bc6039a (the post-transfer URL refresh). This commit is
purely the Go-tooling layer.
Scarf pixels (`shankar0123.docker.scarf.sh/...`) — Scarf-account
namespace, not a Go import or GitHub repo URL. Stays.
This is a non-blocking, non-customer-impacting change. Operators pulling
container images, running `make verify`, hitting the API, or installing the
agent see no functional difference. Only Go-tooling consumers (none today)
are affected, and they're enabled — not broken — by this commit.
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/certctl-io/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())
|
|
}
|
|
})
|
|
}
|
|
}
|