mirror of
https://github.com/luxfi/broker.git
synced 2026-09-24 00:25:18 +00:00
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:
@@ -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,
|
||||
|
||||
@@ -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"})
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user