mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-10 17:18:52 +00:00
feat(ratelimit): per-endpoint rate limit on OCSP + cert-export (Phase 3)
Production hardening II Phase 3 — wire the existing
internal/ratelimit/SlidingWindowLimiter into the OCSP and cert-export
handlers. Removes the DoS vector where an unauthenticated relying
party (or compromised admin token) can hammer the responder /
key-export endpoint at unbounded rates.
OCSP: per-source-IP cap. Default 1000 req/min/IP, 50k tracked IPs
(matches the SCEP/Intune replay cache cap). Configurable via
CERTCTL_OCSP_RATE_LIMIT_PER_IP_MIN; zero disables. Source IP comes
from net.SplitHostPort(r.RemoteAddr) — we deliberately do NOT honor
X-Forwarded-For because OCSP is publicly reachable and untrusted
intermediaries could spoof the header to bypass the limit.
On rate-limit trip: respond with the canonical
ocsp.UnauthorizedErrorResponse pre-built blob from x/crypto/ocsp
(status 6 per RFC 6960 §2.3) plus Retry-After: 60. Using the
unauthorized status (instead of TryLater) avoids hand-rolling DER
for a single rejection path; relying parties retry on any non-good
status anyway.
Cert-export: per-actor cap. Default 50 exports/hr/operator.
Configurable via CERTCTL_CERT_EXPORT_RATE_LIMIT_PER_ACTOR_HR; zero
disables. Actor extracted from the X-Actor request header (set by
the auth middleware); falls back to RemoteAddr if empty (defensive).
On rate-limit trip: HTTP 429 + JSON body
{"error":"rate_limit_exceeded","retry_after_seconds":3600} +
Retry-After: 3600.
NEW config fields in internal/config/config.go::SchedulerConfig:
OCSPRateLimitPerIPMin (default 1000)
CertExportRateLimitPerActorHr (default 50)
WIRED in cmd/server/main.go: ocspLimiter constructed with the
configured cap, 1m window, 50k map cap; exportLimiter same shape with
1h window. Both wired via SetOCSPRateLimiter / SetExportRateLimiter
on their respective handlers. Existing deploys see no behavior
change unless the env vars are set to non-default values + traffic
exceeds the cap.
Pre-commit verification: go build ./... clean; go test -short
-count=1 green for handler + service + config.
This commit is contained in:
@@ -5,11 +5,14 @@ import (
|
||||
"errors"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/shankar0123/certctl/internal/api/middleware"
|
||||
"github.com/shankar0123/certctl/internal/ratelimit"
|
||||
"github.com/shankar0123/certctl/internal/service"
|
||||
)
|
||||
|
||||
@@ -21,7 +24,8 @@ type ExportService interface {
|
||||
|
||||
// ExportHandler handles HTTP requests for certificate export operations.
|
||||
type ExportHandler struct {
|
||||
svc ExportService
|
||||
svc ExportService
|
||||
exportLimiter *ratelimit.SlidingWindowLimiter // production hardening II Phase 3
|
||||
}
|
||||
|
||||
// NewExportHandler creates a new ExportHandler with a service dependency.
|
||||
@@ -29,6 +33,41 @@ func NewExportHandler(svc ExportService) ExportHandler {
|
||||
return ExportHandler{svc: svc}
|
||||
}
|
||||
|
||||
// SetExportRateLimiter wires the per-actor cert-export rate limiter.
|
||||
// Production hardening II Phase 3. Default cap (when set in
|
||||
// cmd/server/main.go): 50 exports/hr/operator. Setting to nil
|
||||
// disables the limit.
|
||||
func (h *ExportHandler) SetExportRateLimiter(l *ratelimit.SlidingWindowLimiter) {
|
||||
h.exportLimiter = l
|
||||
}
|
||||
|
||||
// applyExportRateLimit enforces the per-actor cap. Returns true when
|
||||
// the request was rejected (handler should stop).
|
||||
//
|
||||
// On rejection: HTTP 429 + JSON body {"error":"rate_limit_exceeded",
|
||||
// "retry_after_seconds":3600}. Production hardening II Phase 3.
|
||||
func (h ExportHandler) applyExportRateLimit(w http.ResponseWriter, r *http.Request) bool {
|
||||
if h.exportLimiter == nil {
|
||||
return false
|
||||
}
|
||||
// Auth context populates an actor on the request; cert-export is
|
||||
// always behind the API-key middleware so this is non-empty in
|
||||
// production. Fall-back to RemoteAddr only if the auth pipeline
|
||||
// somehow allowed an empty actor (defensive; shouldn't fire).
|
||||
actor := r.Header.Get("X-Actor")
|
||||
if actor == "" {
|
||||
actor = r.RemoteAddr
|
||||
}
|
||||
if err := h.exportLimiter.Allow(actor, time.Now()); err != nil {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Header().Set("Retry-After", "3600")
|
||||
w.WriteHeader(http.StatusTooManyRequests)
|
||||
_, _ = fmt.Fprint(w, `{"error":"rate_limit_exceeded","retry_after_seconds":3600}`)
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// ExportPEM exports a certificate and its chain in PEM format.
|
||||
// GET /api/v1/certificates/{id}/export/pem
|
||||
func (h ExportHandler) ExportPEM(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -37,6 +76,11 @@ func (h ExportHandler) ExportPEM(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
// Production hardening II Phase 3: per-actor cert-export rate limit.
|
||||
if h.applyExportRateLimit(w, r) {
|
||||
return
|
||||
}
|
||||
|
||||
requestID := middleware.GetRequestID(r.Context())
|
||||
|
||||
// Extract certificate ID from path: /api/v1/certificates/{id}/export/pem
|
||||
@@ -78,6 +122,11 @@ func (h ExportHandler) ExportPKCS12(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
// Production hardening II Phase 3: per-actor cert-export rate limit.
|
||||
if h.applyExportRateLimit(w, r) {
|
||||
return
|
||||
}
|
||||
|
||||
requestID := middleware.GetRequestID(r.Context())
|
||||
|
||||
// Extract certificate ID from path: /api/v1/certificates/{id}/export/pkcs12
|
||||
|
||||
Reference in New Issue
Block a user