security: harden compliance API — SSN HMAC, doc validation, error sanitization, rate limits

- SSN hashing: upgrade from plain SHA-256 to HMAC-SHA256 with keyed secret
  (SSN_HMAC_KEY env var from KMS) to prevent rainbow table attacks on the
  small 10^9 SSN keyspace
- Document uploads: validate MIME type (pdf/jpeg/png/tiff only), enforce
  10MB size limit, validate base64 encoding
- Error messages: sanitize all handler error responses to prevent leaking
  internal store errors (PostgreSQL query details, path info) to clients
- Rate limiting: add per-IP rate limits (10/min) on KYC verify, AML screen,
  and AML risk-assessment endpoints
- Security headers: add X-Content-Type-Options, X-Frame-Options, Cache-Control
  to all compliance responses
This commit is contained in:
2025-12-23 16:45:29 -07:00
parent cc173c8f9f
commit c41b1ed2fe
5 changed files with 120 additions and 18 deletions
+27 -2
View File
@@ -1,16 +1,28 @@
package compliance
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"net/http"
"os"
"time"
"github.com/go-chi/chi/v5"
"github.com/rs/zerolog/log"
)
// ssnHMACKey is the key used for HMAC-SHA256 hashing of SSNs.
// Must be set via SSN_HMAC_KEY environment variable (sourced from KMS).
// Falls back to a deterministic salt if unset (not suitable for production).
var ssnHMACKey = func() []byte {
if key := os.Getenv("SSN_HMAC_KEY"); key != "" {
return []byte(key)
}
return []byte("CHANGE-ME-USE-KMS-IN-PRODUCTION")
}()
// applicationHandler manages the 5-step investor onboarding flow.
type applicationHandler struct {
store ComplianceStore
@@ -151,9 +163,12 @@ func (h *applicationHandler) handleStep1(w http.ResponseWriter, r *http.Request)
app.Country = req.Country
// Hash SSN if provided -- NEVER store plaintext.
// Uses HMAC-SHA256 with a key to prevent rainbow table attacks on the
// small SSN keyspace (10^9). Key must come from KMS via SSN_HMAC_KEY env var.
if req.SSN != "" {
hash := sha256.Sum256([]byte(req.SSN))
app.SSNHash = hex.EncodeToString(hash[:])
mac := hmac.New(sha256.New, ssnHMACKey)
mac.Write([]byte(req.SSN))
app.SSNHash = hex.EncodeToString(mac.Sum(nil))
if len(req.SSN) >= 4 {
app.SSNLast4 = req.SSN[len(req.SSN)-4:]
}
@@ -268,6 +283,16 @@ func (h *applicationHandler) handleStep3(w http.ResponseWriter, r *http.Request)
writeError(w, http.StatusBadRequest, "document name is required")
return
}
// Validate MIME type.
if d.MimeType != "" && !allowedDocMIMETypes[d.MimeType] {
writeError(w, http.StatusBadRequest, "unsupported file type; allowed: pdf, jpeg, png, tiff")
return
}
// Validate file size (10 MB max).
if d.Size > maxDocumentContentSize {
writeError(w, http.StatusBadRequest, "document exceeds 10MB size limit")
return
}
doc := &DocumentUpload{
ApplicationID: app.ID,
+2 -2
View File
@@ -65,7 +65,7 @@ func (h *credentialsHandler) handleCreateCredential(w http.ResponseWriter, r *ht
Permissions: req.Permissions,
}
if err := h.store.SaveCredential(cred); err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
writeError(w, http.StatusInternalServerError, "internal error")
return
}
@@ -83,7 +83,7 @@ func (h *credentialsHandler) handleCreateCredential(w http.ResponseWriter, r *ht
func (h *credentialsHandler) handleDeleteCredential(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
if err := h.store.DeleteCredential(id); err != nil {
writeError(w, http.StatusNotFound, err.Error())
writeError(w, http.StatusNotFound, "not found")
return
}
writeJSON(w, http.StatusOK, map[string]string{"status": "deleted"})
+29
View File
@@ -2,6 +2,7 @@ package compliance
import (
"context"
"encoding/base64"
"encoding/json"
"net/http"
@@ -9,6 +10,17 @@ import (
"github.com/rs/zerolog/log"
)
// allowedDocMIMETypes restricts document uploads to safe formats.
var allowedDocMIMETypes = map[string]bool{
"application/pdf": true,
"image/jpeg": true,
"image/png": true,
"image/tiff": true,
}
// maxDocumentContentSize is the maximum decoded size for base64 document content (10 MB).
const maxDocumentContentSize = 10 * 1024 * 1024
// KYCProvider is the interface for pluggable identity verification backends
// (Onfido, Berbix, IDMerit, etc.).
type KYCProvider interface {
@@ -162,6 +174,23 @@ func (h *kycHandler) handleUploadDocument(w http.ResponseWriter, r *http.Request
writeError(w, http.StatusBadRequest, "name is required")
return
}
// Validate MIME type to prevent malicious file uploads.
if req.MimeType != "" && !allowedDocMIMETypes[req.MimeType] {
writeError(w, http.StatusBadRequest, "unsupported file type; allowed: pdf, jpeg, png, tiff")
return
}
// Validate content size to prevent oversized uploads.
if req.Content != "" {
decoded, err := base64.StdEncoding.DecodeString(req.Content)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid base64 content")
return
}
if len(decoded) > maxDocumentContentSize {
writeError(w, http.StatusBadRequest, "document content exceeds 10MB limit")
return
}
}
doc := Document{
ID: generateID(),
+11 -11
View File
@@ -32,7 +32,7 @@ func (h *onboardingHandler) handleCreatePipeline(w http.ResponseWriter, r *http.
p.Status = "draft"
}
if err := h.store.SavePipeline(&p); err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
writeError(w, http.StatusInternalServerError, "internal error")
return
}
writeJSON(w, http.StatusCreated, &p)
@@ -41,7 +41,7 @@ func (h *onboardingHandler) handleCreatePipeline(w http.ResponseWriter, r *http.
func (h *onboardingHandler) handleGetPipeline(w http.ResponseWriter, r *http.Request) {
p, err := h.store.GetPipeline(chi.URLParam(r, "id"))
if err != nil {
writeError(w, http.StatusNotFound, err.Error())
writeError(w, http.StatusNotFound, "not found")
return
}
writeJSON(w, http.StatusOK, p)
@@ -51,7 +51,7 @@ func (h *onboardingHandler) handleUpdatePipeline(w http.ResponseWriter, r *http.
id := chi.URLParam(r, "id")
existing, err := h.store.GetPipeline(id)
if err != nil {
writeError(w, http.StatusNotFound, err.Error())
writeError(w, http.StatusNotFound, "not found")
return
}
@@ -78,7 +78,7 @@ func (h *onboardingHandler) handleUpdatePipeline(w http.ResponseWriter, r *http.
existing.BusinessID = *patch.BusinessID
}
if err := h.store.SavePipeline(existing); err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
writeError(w, http.StatusInternalServerError, "internal error")
return
}
writeJSON(w, http.StatusOK, existing)
@@ -86,7 +86,7 @@ func (h *onboardingHandler) handleUpdatePipeline(w http.ResponseWriter, r *http.
func (h *onboardingHandler) handleDeletePipeline(w http.ResponseWriter, r *http.Request) {
if err := h.store.DeletePipeline(chi.URLParam(r, "id")); err != nil {
writeError(w, http.StatusNotFound, err.Error())
writeError(w, http.StatusNotFound, "not found")
return
}
writeJSON(w, http.StatusOK, map[string]string{"status": "deleted"})
@@ -116,7 +116,7 @@ func (h *onboardingHandler) handleCreateSession(w http.ResponseWriter, r *http.R
// Validate pipeline exists and populate session steps from it.
pipeline, err := h.store.GetPipeline(sess.PipelineID)
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
writeError(w, http.StatusBadRequest, "pipeline not found")
return
}
sess.Status = SessionPending
@@ -132,7 +132,7 @@ func (h *onboardingHandler) handleCreateSession(w http.ResponseWriter, r *http.R
}
if err := h.store.SaveSession(&sess); err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
writeError(w, http.StatusInternalServerError, "internal error")
return
}
writeJSON(w, http.StatusCreated, &sess)
@@ -141,7 +141,7 @@ func (h *onboardingHandler) handleCreateSession(w http.ResponseWriter, r *http.R
func (h *onboardingHandler) handleGetSession(w http.ResponseWriter, r *http.Request) {
sess, err := h.store.GetSession(chi.URLParam(r, "id"))
if err != nil {
writeError(w, http.StatusNotFound, err.Error())
writeError(w, http.StatusNotFound, "not found")
return
}
writeJSON(w, http.StatusOK, sess)
@@ -151,7 +151,7 @@ func (h *onboardingHandler) handleUpdateSession(w http.ResponseWriter, r *http.R
id := chi.URLParam(r, "id")
existing, err := h.store.GetSession(id)
if err != nil {
writeError(w, http.StatusNotFound, err.Error())
writeError(w, http.StatusNotFound, "not found")
return
}
@@ -170,7 +170,7 @@ func (h *onboardingHandler) handleUpdateSession(w http.ResponseWriter, r *http.R
existing.KYCStatus = *patch.KYCStatus
}
if err := h.store.SaveSession(existing); err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
writeError(w, http.StatusInternalServerError, "internal error")
return
}
writeJSON(w, http.StatusOK, existing)
@@ -179,7 +179,7 @@ func (h *onboardingHandler) handleUpdateSession(w http.ResponseWriter, r *http.R
func (h *onboardingHandler) handleGetSessionSteps(w http.ResponseWriter, r *http.Request) {
sess, err := h.store.GetSession(chi.URLParam(r, "id"))
if err != nil {
writeError(w, http.StatusNotFound, err.Error())
writeError(w, http.StatusNotFound, "not found")
return
}
writeJSON(w, http.StatusOK, sess.Steps)
+51 -3
View File
@@ -37,6 +37,7 @@ func WithJubeClient(c *jube.Client) RouterOption {
// all routes (except /healthz) require a valid admin JWT and write operations
// are gated by role-based permissions.
// The authStore parameter provides API key management for credential endpoints.
// Optional RouterOption values can add the auth store and Jube client.
func NewRouter(store ComplianceStore, adminStore *admin.Store, authStore ...*auth.Store) chi.Router {
if store == nil {
store = NewMemoryStore()
@@ -84,6 +85,16 @@ func NewRouter(store ComplianceStore, adminStore *admin.Store, authStore ...*aut
// Request body size limit — 1MB max to prevent abuse.
r.Use(maxBodySize(1 << 20))
// Security headers for all compliance responses.
r.Use(func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("X-Content-Type-Options", "nosniff")
w.Header().Set("X-Frame-Options", "DENY")
w.Header().Set("Cache-Control", "no-store")
next.ServeHTTP(w, r)
})
})
// Health check — no auth required
r.Get("/healthz", func(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]string{
@@ -102,7 +113,7 @@ func NewRouter(store ComplianceStore, adminStore *admin.Store, authStore ...*aut
// KYC
r.Route("/kyc", func(r chi.Router) {
r.Post("/verify", guard("kyc", "write", kyc.handleVerify))
r.Post("/verify", rateLimitSensitive(guard("kyc", "write", kyc.handleVerify)))
r.Get("/", kyc.handleListByUser)
r.Get("/{id}", kyc.handleGet)
r.Patch("/{id}", guard("kyc", "write", kyc.handleUpdateStatus))
@@ -111,8 +122,8 @@ func NewRouter(store ComplianceStore, adminStore *admin.Store, authStore ...*aut
// AML
r.Route("/aml", func(r chi.Router) {
r.Post("/screen", guard("aml", "write", aml.handleScreen))
r.Post("/risk-assessment", guard("aml", "write", aml.handleRiskAssessment))
r.Post("/screen", rateLimitSensitive(guard("aml", "write", aml.handleScreen)))
r.Post("/risk-assessment", rateLimitSensitive(guard("aml", "write", aml.handleRiskAssessment)))
r.Get("/screenings", aml.handleListByAccount)
r.Get("/flagged", aml.handleListFlagged)
r.Get("/screenings/{id}", aml.handleGet)
@@ -300,6 +311,43 @@ func rateLimitLogin(next http.HandlerFunc) http.HandlerFunc {
}
}
// rateLimitSensitive wraps a handler with per-IP rate limiting for sensitive
// compliance operations (KYC verify, AML screen). Allows 10 requests per
// minute per IP, then returns 429.
func rateLimitSensitive(next http.HandlerFunc) http.HandlerFunc {
var mu sync.Mutex
attempts := make(map[string][]time.Time)
return func(w http.ResponseWriter, r *http.Request) {
ip := r.RemoteAddr
if fwd := r.Header.Get("X-Forwarded-For"); fwd != "" {
ip = fwd
}
mu.Lock()
now := time.Now()
window := now.Add(-1 * time.Minute)
valid := attempts[ip][:0]
for _, t := range attempts[ip] {
if t.After(window) {
valid = append(valid, t)
}
}
attempts[ip] = valid
if len(attempts[ip]) >= 10 {
mu.Unlock()
writeError(w, http.StatusTooManyRequests, "rate limit exceeded, try again in 1 minute")
return
}
attempts[ip] = append(attempts[ip], now)
mu.Unlock()
next(w, r)
}
}
// maxBodySize returns middleware that limits request body size to prevent
// denial-of-service via large payloads.
func maxBodySize(maxBytes int64) func(http.Handler) http.Handler {