Files
certctl/internal/api/handler/export.go
T
shankar0123 ed19312df6 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.
2026-04-30 05:08:04 +00:00

191 lines
6.3 KiB
Go

package handler
import (
"github.com/shankar0123/certctl/internal/repository"
"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"
)
// ExportService defines the service interface for certificate export operations.
type ExportService interface {
ExportPEM(ctx context.Context, certID string) (*service.ExportPEMResult, error)
ExportPKCS12(ctx context.Context, certID string, password string) ([]byte, error)
}
// ExportHandler handles HTTP requests for certificate export operations.
type ExportHandler struct {
svc ExportService
exportLimiter *ratelimit.SlidingWindowLimiter // production hardening II Phase 3
}
// NewExportHandler creates a new ExportHandler with a service dependency.
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) {
if r.Method != http.MethodGet {
Error(w, http.StatusMethodNotAllowed, "Method not allowed")
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
id := extractCertIDFromExportPath(r.URL.Path)
if id == "" {
ErrorWithRequestID(w, http.StatusBadRequest, "Certificate ID is required", requestID)
return
}
result, err := h.svc.ExportPEM(r.Context(), id)
if err != nil {
if errors.Is(err, repository.ErrNotFound) {
ErrorWithRequestID(w, http.StatusNotFound, "Certificate not found", requestID)
return
}
slog.Error("ExportPEM failed", "cert_id", id, "error", err.Error())
ErrorWithRequestID(w, http.StatusInternalServerError, "Failed to export certificate", requestID)
return
}
// Check if client wants file download via Accept header or ?download=true query param
if r.URL.Query().Get("download") == "true" {
w.Header().Set("Content-Type", "application/x-pem-file")
w.Header().Set("Content-Disposition", "attachment; filename=\"certificate.pem\"")
w.WriteHeader(http.StatusOK)
w.Write([]byte(result.FullPEM))
return
}
JSON(w, http.StatusOK, result)
}
// ExportPKCS12 exports a certificate and chain in PKCS#12 format.
// POST /api/v1/certificates/{id}/export/pkcs12
// Body: { "password": "optional-password" }
func (h ExportHandler) ExportPKCS12(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
Error(w, http.StatusMethodNotAllowed, "Method not allowed")
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
id := extractCertIDFromExportPath(r.URL.Path)
if id == "" {
ErrorWithRequestID(w, http.StatusBadRequest, "Certificate ID is required", requestID)
return
}
// Parse optional password from request body (may be empty)
var req struct {
Password string `json:"password"`
}
// Body is optional — empty body means empty password
_ = parseJSONBody(r, &req)
pfxData, err := h.svc.ExportPKCS12(r.Context(), id, req.Password)
if err != nil {
if errors.Is(err, repository.ErrNotFound) {
ErrorWithRequestID(w, http.StatusNotFound, "Certificate not found", requestID)
return
}
if strings.Contains(err.Error(), "cannot be parsed") || strings.Contains(err.Error(), "no certificates found") {
ErrorWithRequestID(w, http.StatusUnprocessableEntity, "Certificate data cannot be parsed as X.509", requestID)
return
}
slog.Error("ExportPKCS12 failed", "cert_id", id, "error", err.Error())
ErrorWithRequestID(w, http.StatusInternalServerError, "Failed to export PKCS#12", requestID)
return
}
w.Header().Set("Content-Type", "application/x-pkcs12")
w.Header().Set("Content-Disposition", "attachment; filename=\"certificate.p12\"")
w.WriteHeader(http.StatusOK)
w.Write(pfxData)
}
// extractCertIDFromExportPath extracts the certificate ID from an export path.
// Path format: /api/v1/certificates/{id}/export/pem or /api/v1/certificates/{id}/export/pkcs12
func extractCertIDFromExportPath(path string) string {
prefix := "/api/v1/certificates/"
if !strings.HasPrefix(path, prefix) {
return ""
}
rest := strings.TrimPrefix(path, prefix)
// rest should be "{id}/export/pem" or "{id}/export/pkcs12"
parts := strings.Split(rest, "/")
if len(parts) < 3 || parts[1] != "export" {
return ""
}
return parts[0]
}
// parseJSONBody is a helper that decodes JSON from the request body.
// Returns an error if the body is malformed, nil if body is empty.
func parseJSONBody(r *http.Request, v interface{}) error {
if r.Body == nil {
return nil
}
return json.NewDecoder(r.Body).Decode(v)
}