mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-07 15:21:35 +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.
347 lines
13 KiB
Go
347 lines
13 KiB
Go
package middleware
|
|
|
|
import (
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"testing"
|
|
)
|
|
|
|
// Bundle B / Audit M-013 (CWE-942) regression pins.
|
|
//
|
|
// The audit-finding text reads: "CORS configuration default allows all
|
|
// origins if env-var unset". Phase 0 recon proves that claim is WRONG —
|
|
// internal/api/middleware/middleware.go::NewCORS already denies when
|
|
// len(cfg.AllowedOrigins) == 0 (no Access-Control-Allow-Origin header is
|
|
// emitted, so same-origin policy applies). Bundle B's M-013 closure is
|
|
// "verified-already-clean": these tests pin the deny-by-default contract
|
|
// in BOTH shapes (nil slice and empty slice) so a future refactor that
|
|
// inverts the default fails CI.
|
|
|
|
// TestNewCORS_NilOriginsDeniesAll pins the deny-by-default contract for
|
|
// the nil-slice shape (which is what propagates from a missing
|
|
// CERTCTL_CORS_ORIGINS env var via internal/config/config.go::getEnvList).
|
|
func TestNewCORS_NilOriginsDeniesAll(t *testing.T) {
|
|
mw := NewCORS(CORSConfig{AllowedOrigins: nil})
|
|
handler := mw(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.WriteHeader(http.StatusOK)
|
|
}))
|
|
req := httptest.NewRequest(http.MethodGet, "/api/v1/certificates", nil)
|
|
req.Header.Set("Origin", "https://attacker.example.com")
|
|
rr := httptest.NewRecorder()
|
|
handler.ServeHTTP(rr, req)
|
|
if got := rr.Header().Get("Access-Control-Allow-Origin"); got != "" {
|
|
t.Errorf("nil AllowedOrigins must NOT emit Access-Control-Allow-Origin, got %q", got)
|
|
}
|
|
if got := rr.Header().Get("Vary"); got != "" {
|
|
t.Errorf("nil AllowedOrigins must NOT emit Vary, got %q", got)
|
|
}
|
|
}
|
|
|
|
// TestNewCORS_M013_ContractDocumentedInOrder pins the documented dispatch
|
|
// order so a refactor cannot silently invert the cases:
|
|
//
|
|
// 1. len(AllowedOrigins) == 0 → deny (no CORS headers)
|
|
// 2. AllowedOrigins == ["*"] → allow all (Access-Control-Allow-Origin: *)
|
|
// 3. else → exact-match allowlist with Vary: Origin
|
|
//
|
|
// If a refactor accidentally falls through to the allow-all branch when
|
|
// AllowedOrigins is empty, this test fails on case 1.
|
|
func TestNewCORS_M013_ContractDocumentedInOrder(t *testing.T) {
|
|
cases := []struct {
|
|
name string
|
|
origins []string
|
|
incomingOrigin string
|
|
wantHeader string // "" means no header expected
|
|
}{
|
|
{"deny_empty_slice", []string{}, "https://app.example.com", ""},
|
|
{"deny_nil", nil, "https://app.example.com", ""},
|
|
{"allow_all_with_star", []string{"*"}, "https://app.example.com", "*"},
|
|
{"exact_allow_match", []string{"https://app.example.com"}, "https://app.example.com", "https://app.example.com"},
|
|
{"exact_deny_mismatch", []string{"https://app.example.com"}, "https://attacker.example.com", ""},
|
|
}
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
mw := NewCORS(CORSConfig{AllowedOrigins: tc.origins})
|
|
handler := mw(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.WriteHeader(http.StatusOK)
|
|
}))
|
|
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
|
req.Header.Set("Origin", tc.incomingOrigin)
|
|
rr := httptest.NewRecorder()
|
|
handler.ServeHTTP(rr, req)
|
|
if got := rr.Header().Get("Access-Control-Allow-Origin"); got != tc.wantHeader {
|
|
t.Errorf("got Access-Control-Allow-Origin=%q, want %q (incoming origin=%q)", got, tc.wantHeader, tc.incomingOrigin)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestNewCORS_EmptyOriginList denies CORS by default (secure default).
|
|
func TestNewCORS_EmptyOriginList(t *testing.T) {
|
|
mw := NewCORS(CORSConfig{AllowedOrigins: []string{}})
|
|
|
|
handler := mw(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.WriteHeader(http.StatusOK)
|
|
w.Write([]byte(`{"ok":true}`))
|
|
}))
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/api/v1/certificates", nil)
|
|
req.Header.Set("Origin", "https://evil.example.com")
|
|
rr := httptest.NewRecorder()
|
|
handler.ServeHTTP(rr, req)
|
|
|
|
// Response should be OK, but no CORS headers should be set
|
|
if rr.Code != http.StatusOK {
|
|
t.Fatalf("expected 200, got %d", rr.Code)
|
|
}
|
|
|
|
// Verify no CORS headers are present
|
|
if rr.Header().Get("Access-Control-Allow-Origin") != "" {
|
|
t.Errorf("expected no Access-Control-Allow-Origin header, got %q", rr.Header().Get("Access-Control-Allow-Origin"))
|
|
}
|
|
if rr.Header().Get("Vary") != "" {
|
|
t.Errorf("expected no Vary header, got %q", rr.Header().Get("Vary"))
|
|
}
|
|
}
|
|
|
|
// TestNewCORS_EmptyOriginList_Preflight denies preflight when empty allowlist.
|
|
func TestNewCORS_EmptyOriginList_Preflight(t *testing.T) {
|
|
mw := NewCORS(CORSConfig{AllowedOrigins: []string{}})
|
|
|
|
handler := mw(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.WriteHeader(http.StatusOK)
|
|
}))
|
|
|
|
req := httptest.NewRequest(http.MethodOptions, "/api/v1/certificates", nil)
|
|
req.Header.Set("Origin", "https://app.example.com")
|
|
rr := httptest.NewRecorder()
|
|
handler.ServeHTTP(rr, req)
|
|
|
|
// Preflight should return 204, but no CORS headers
|
|
if rr.Code != http.StatusNoContent {
|
|
t.Fatalf("expected 204, got %d", rr.Code)
|
|
}
|
|
|
|
// No CORS headers should be set
|
|
if rr.Header().Get("Access-Control-Allow-Origin") != "" {
|
|
t.Errorf("expected no Access-Control-Allow-Origin header, got %q", rr.Header().Get("Access-Control-Allow-Origin"))
|
|
}
|
|
}
|
|
|
|
// TestNewCORS_WildcardAllowsAll allows all origins with wildcard.
|
|
func TestNewCORS_WildcardAllowsAll(t *testing.T) {
|
|
mw := NewCORS(CORSConfig{AllowedOrigins: []string{"*"}})
|
|
|
|
handler := mw(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.WriteHeader(http.StatusOK)
|
|
}))
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/api/v1/certificates", nil)
|
|
req.Header.Set("Origin", "https://any-origin.example.com")
|
|
rr := httptest.NewRecorder()
|
|
handler.ServeHTTP(rr, req)
|
|
|
|
if rr.Code != http.StatusOK {
|
|
t.Fatalf("expected 200, got %d", rr.Code)
|
|
}
|
|
|
|
// Wildcard should set Access-Control-Allow-Origin: *
|
|
if rr.Header().Get("Access-Control-Allow-Origin") != "*" {
|
|
t.Errorf("expected Access-Control-Allow-Origin: *, got %q", rr.Header().Get("Access-Control-Allow-Origin"))
|
|
}
|
|
|
|
// Verify other CORS headers are present
|
|
if rr.Header().Get("Access-Control-Allow-Methods") == "" {
|
|
t.Errorf("expected Access-Control-Allow-Methods header")
|
|
}
|
|
}
|
|
|
|
// TestNewCORS_ExactMatchAllows allows only exact matches from allowlist.
|
|
func TestNewCORS_ExactMatchAllows(t *testing.T) {
|
|
mw := NewCORS(CORSConfig{AllowedOrigins: []string{"https://app.example.com", "https://admin.example.com"}})
|
|
|
|
handler := mw(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.WriteHeader(http.StatusOK)
|
|
}))
|
|
|
|
// Test 1: Origin in allowlist
|
|
req1 := httptest.NewRequest(http.MethodGet, "/api/v1/certificates", nil)
|
|
req1.Header.Set("Origin", "https://app.example.com")
|
|
rr1 := httptest.NewRecorder()
|
|
handler.ServeHTTP(rr1, req1)
|
|
|
|
if rr1.Header().Get("Access-Control-Allow-Origin") != "https://app.example.com" {
|
|
t.Errorf("expected https://app.example.com, got %q", rr1.Header().Get("Access-Control-Allow-Origin"))
|
|
}
|
|
if rr1.Header().Get("Vary") != "Origin" {
|
|
t.Errorf("expected Vary: Origin, got %q", rr1.Header().Get("Vary"))
|
|
}
|
|
|
|
// Test 2: Different origin in allowlist
|
|
req2 := httptest.NewRequest(http.MethodGet, "/api/v1/certificates", nil)
|
|
req2.Header.Set("Origin", "https://admin.example.com")
|
|
rr2 := httptest.NewRecorder()
|
|
handler.ServeHTTP(rr2, req2)
|
|
|
|
if rr2.Header().Get("Access-Control-Allow-Origin") != "https://admin.example.com" {
|
|
t.Errorf("expected https://admin.example.com, got %q", rr2.Header().Get("Access-Control-Allow-Origin"))
|
|
}
|
|
|
|
// Test 3: Origin NOT in allowlist
|
|
req3 := httptest.NewRequest(http.MethodGet, "/api/v1/certificates", nil)
|
|
req3.Header.Set("Origin", "https://evil.example.com")
|
|
rr3 := httptest.NewRecorder()
|
|
handler.ServeHTTP(rr3, req3)
|
|
|
|
if rr3.Header().Get("Access-Control-Allow-Origin") != "" {
|
|
t.Errorf("expected no Access-Control-Allow-Origin for non-allowlisted origin, got %q", rr3.Header().Get("Access-Control-Allow-Origin"))
|
|
}
|
|
}
|
|
|
|
// TestNewCORS_NoOriginHeader denies CORS without Origin header.
|
|
func TestNewCORS_NoOriginHeader(t *testing.T) {
|
|
mw := NewCORS(CORSConfig{AllowedOrigins: []string{"https://app.example.com"}})
|
|
|
|
handler := mw(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.WriteHeader(http.StatusOK)
|
|
}))
|
|
|
|
// Request without Origin header
|
|
req := httptest.NewRequest(http.MethodGet, "/api/v1/certificates", nil)
|
|
// Don't set Origin header
|
|
rr := httptest.NewRecorder()
|
|
handler.ServeHTTP(rr, req)
|
|
|
|
if rr.Code != http.StatusOK {
|
|
t.Fatalf("expected 200, got %d", rr.Code)
|
|
}
|
|
|
|
// No CORS headers should be set (Origin header was missing)
|
|
if rr.Header().Get("Access-Control-Allow-Origin") != "" {
|
|
t.Errorf("expected no Access-Control-Allow-Origin without Origin header, got %q", rr.Header().Get("Access-Control-Allow-Origin"))
|
|
}
|
|
}
|
|
|
|
// TestNewCORS_PreflightRequestMatches tests OPTIONS preflight with matching origin.
|
|
func TestNewCORS_PreflightRequestMatches(t *testing.T) {
|
|
mw := NewCORS(CORSConfig{AllowedOrigins: []string{"https://app.example.com"}})
|
|
|
|
handler := mw(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.WriteHeader(http.StatusOK)
|
|
}))
|
|
|
|
req := httptest.NewRequest(http.MethodOptions, "/api/v1/certificates", nil)
|
|
req.Header.Set("Origin", "https://app.example.com")
|
|
req.Header.Set("Access-Control-Request-Method", "POST")
|
|
rr := httptest.NewRecorder()
|
|
handler.ServeHTTP(rr, req)
|
|
|
|
if rr.Code != http.StatusNoContent {
|
|
t.Fatalf("expected 204, got %d", rr.Code)
|
|
}
|
|
|
|
if rr.Header().Get("Access-Control-Allow-Origin") != "https://app.example.com" {
|
|
t.Errorf("expected https://app.example.com, got %q", rr.Header().Get("Access-Control-Allow-Origin"))
|
|
}
|
|
|
|
// Verify preflight response headers
|
|
if rr.Header().Get("Access-Control-Allow-Methods") == "" {
|
|
t.Errorf("expected Access-Control-Allow-Methods header")
|
|
}
|
|
if rr.Header().Get("Access-Control-Allow-Headers") == "" {
|
|
t.Errorf("expected Access-Control-Allow-Headers header")
|
|
}
|
|
if rr.Header().Get("Access-Control-Max-Age") == "" {
|
|
t.Errorf("expected Access-Control-Max-Age header")
|
|
}
|
|
}
|
|
|
|
// TestNewCORS_PreflightRequestMismatch tests OPTIONS preflight with non-matching origin.
|
|
func TestNewCORS_PreflightRequestMismatch(t *testing.T) {
|
|
mw := NewCORS(CORSConfig{AllowedOrigins: []string{"https://app.example.com"}})
|
|
|
|
handler := mw(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.WriteHeader(http.StatusOK)
|
|
}))
|
|
|
|
req := httptest.NewRequest(http.MethodOptions, "/api/v1/certificates", nil)
|
|
req.Header.Set("Origin", "https://evil.example.com")
|
|
req.Header.Set("Access-Control-Request-Method", "POST")
|
|
rr := httptest.NewRecorder()
|
|
handler.ServeHTTP(rr, req)
|
|
|
|
if rr.Code != http.StatusNoContent {
|
|
t.Fatalf("expected 204, got %d", rr.Code)
|
|
}
|
|
|
|
// No CORS headers should be set (origin not in allowlist)
|
|
if rr.Header().Get("Access-Control-Allow-Origin") != "" {
|
|
t.Errorf("expected no Access-Control-Allow-Origin for mismatched origin, got %q", rr.Header().Get("Access-Control-Allow-Origin"))
|
|
}
|
|
}
|
|
|
|
// TestNewCORS_MultipleOrigins tests with multiple configured origins.
|
|
func TestNewCORS_MultipleOrigins(t *testing.T) {
|
|
mw := NewCORS(CORSConfig{AllowedOrigins: []string{
|
|
"https://app.example.com",
|
|
"https://admin.example.com",
|
|
"http://localhost:3000",
|
|
}})
|
|
|
|
handler := mw(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.WriteHeader(http.StatusOK)
|
|
}))
|
|
|
|
tests := []struct {
|
|
origin string
|
|
shouldAllow bool
|
|
description string
|
|
}{
|
|
{"https://app.example.com", true, "first origin in list"},
|
|
{"https://admin.example.com", true, "second origin in list"},
|
|
{"http://localhost:3000", true, "third origin in list"},
|
|
{"https://evil.example.com", false, "origin not in list"},
|
|
{"http://localhost:8080", false, "different port than configured"},
|
|
{"", false, "no origin header"},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
req := httptest.NewRequest(http.MethodGet, "/api/v1/certificates", nil)
|
|
if tt.origin != "" {
|
|
req.Header.Set("Origin", tt.origin)
|
|
}
|
|
rr := httptest.NewRecorder()
|
|
handler.ServeHTTP(rr, req)
|
|
|
|
headerValue := rr.Header().Get("Access-Control-Allow-Origin")
|
|
if tt.shouldAllow {
|
|
if headerValue != tt.origin {
|
|
t.Errorf("test %q: expected %q, got %q", tt.description, tt.origin, headerValue)
|
|
}
|
|
} else {
|
|
if headerValue != "" {
|
|
t.Errorf("test %q: expected no header, got %q", tt.description, headerValue)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestNewCORS_NoOriginHeaderWithWildcard tests wildcard doesn't set origin without Origin header.
|
|
func TestNewCORS_NoOriginHeaderWithWildcard(t *testing.T) {
|
|
mw := NewCORS(CORSConfig{AllowedOrigins: []string{"*"}})
|
|
|
|
handler := mw(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.WriteHeader(http.StatusOK)
|
|
}))
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/api/v1/certificates", nil)
|
|
// Don't set Origin header
|
|
rr := httptest.NewRecorder()
|
|
handler.ServeHTTP(rr, req)
|
|
|
|
// Wildcard should still set * even without Origin header
|
|
if rr.Header().Get("Access-Control-Allow-Origin") != "*" {
|
|
t.Errorf("expected *, got %q", rr.Header().Get("Access-Control-Allow-Origin"))
|
|
}
|
|
}
|