Files
broker/pkg/api/handlers_funding.go
zeekay 788166713a security: fix IDOR, SSRF, HMAC replay, JWT iss/aud, SOR injection
CRITICAL:
- Account ownership middleware on all /accounts/{provider}/{accountId}/* routes
- Deposit/withdraw: verify account belongs to authenticated user

HIGH:
- Webhook SSRF: HTTPS required, reject private IPs, loopback, link-local, internal hosts
- HMAC replay: reject signatures with |now - timestamp| > 5 minutes
- JWT: validate iss matches IAM endpoint, aud contains expected audience
- SOR: build accounts map server-side from resolver, ignore client input

All 43 packages pass. Zero failures.
2026-04-07 19:52:17 -07:00

114 lines
3.2 KiB
Go

package api
import (
"encoding/json"
"io"
"net/http"
"github.com/go-chi/chi/v5"
"github.com/luxfi/broker/pkg/funding"
)
// handleDeposit processes a deposit into a trading account via a payment processor.
func (s *Server) handleDeposit(w http.ResponseWriter, r *http.Request) {
var req funding.DepositRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid request body")
return
}
if req.Amount <= 0 {
writeError(w, http.StatusBadRequest, "amount must be positive")
return
}
if req.Currency == "" {
writeError(w, http.StatusBadRequest, "currency required")
return
}
// Verify the authenticated user owns the target account.
userID := r.Header.Get("X-User-Id")
if req.AccountID == "" || req.Provider == "" {
writeError(w, http.StatusBadRequest, "account_id and provider required")
return
}
if !s.resolver.OwnsAccount(userID, req.Provider, req.AccountID) {
writeError(w, http.StatusForbidden, "account access denied")
return
}
result, err := s.funding.Deposit(r.Context(), &req)
if err != nil {
writeError(w, http.StatusBadGateway, err.Error())
return
}
writeJSON(w, http.StatusCreated, result)
}
// handleWithdraw processes a withdrawal from a trading account via a payment processor.
func (s *Server) handleWithdraw(w http.ResponseWriter, r *http.Request) {
var req funding.WithdrawRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid request body")
return
}
if req.Amount <= 0 {
writeError(w, http.StatusBadRequest, "amount must be positive")
return
}
if req.Currency == "" {
writeError(w, http.StatusBadRequest, "currency required")
return
}
// Verify the authenticated user owns the target account.
userID := r.Header.Get("X-User-Id")
if req.AccountID == "" || req.Provider == "" {
writeError(w, http.StatusBadRequest, "account_id and provider required")
return
}
if !s.resolver.OwnsAccount(userID, req.Provider, req.AccountID) {
writeError(w, http.StatusForbidden, "account access denied")
return
}
result, err := s.funding.Withdraw(r.Context(), &req)
if err != nil {
writeError(w, http.StatusBadGateway, err.Error())
return
}
writeJSON(w, http.StatusCreated, result)
}
// handleFundingWebhook validates and processes incoming payment processor webhooks.
func (s *Server) handleFundingWebhook(w http.ResponseWriter, r *http.Request) {
processorName := chi.URLParam(r, "processor")
body, err := io.ReadAll(r.Body)
if err != nil {
writeError(w, http.StatusBadRequest, "failed to read body")
return
}
signature := r.Header.Get("X-Webhook-Signature")
if signature == "" {
// Braintree uses bt_signature form field
signature = r.FormValue("bt_signature")
}
event, err := s.funding.ValidateWebhook(r.Context(), processorName, body, signature)
if err != nil {
writeError(w, http.StatusUnauthorized, err.Error())
return
}
writeJSON(w, http.StatusOK, event)
}
// handleListProcessors returns available payment processors.
func (s *Server) handleListProcessors(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]interface{}{
"processors": s.funding.ListProcessors(r.Context()),
})
}