mirror of
https://github.com/luxfi/broker.git
synced 2026-09-24 00:25:18 +00:00
security: fix all critical/high findings from red team review
- Strip 9 identity headers at start of every request (prevents injection via direct pod access) - Remove X-Account-Id fast path and fallback headers from frontend handlers (fixes IDOR) - Add gRPC auth interceptor with IAM JWKS validation - Fix RBAC role splitting (comma-separated X-User-Roles) - Move webhook endpoint outside auth middleware - Fatal on empty SSN_HMAC_KEY in production - Remove dead admin store code - Remove duplicate CORS handler from compliance router - Fix writeErr JSON injection (use json.Marshal) - Propagate isAdmin + roles claims from JWT
This commit is contained in:
+1
-9
@@ -67,15 +67,6 @@ func main() {
|
||||
srv.SetTWAP(twapScheduler)
|
||||
srv.SetArbitrageDetector(arbDetector, arbThresholdBps)
|
||||
|
||||
// --- Admin Users ---
|
||||
adminStore := srv.AdminStore()
|
||||
adminUser := envOr("ADMIN_USERNAME", "admin")
|
||||
adminPass := os.Getenv("ADMIN_PASSWORD")
|
||||
if adminPass != "" {
|
||||
adminStore.AddAdmin(adminUser, adminPass, "super_admin")
|
||||
log.Info().Str("user", adminUser).Msg("Admin user configured")
|
||||
}
|
||||
|
||||
// --- Compliance (KYC, onboarding, funds, eSign, RBAC) ---
|
||||
var complianceStore compliance.ComplianceStore
|
||||
if dbURL := os.Getenv("DATABASE_URL"); dbURL != "" {
|
||||
@@ -107,6 +98,7 @@ func main() {
|
||||
var err error
|
||||
grpcSrv, err = brokergrpc.NewServer(brokergrpc.Config{
|
||||
ListenAddr: grpcAddr,
|
||||
IAMEndpoint: envOr("IAM_ENDPOINT", "http://localhost:8000"),
|
||||
Registry: registry,
|
||||
Router: sor,
|
||||
TWAPScheduler: twapScheduler,
|
||||
|
||||
@@ -29,38 +29,15 @@ var (
|
||||
}
|
||||
)
|
||||
|
||||
// resolveUserID extracts the user identity from gateway/IAM headers.
|
||||
// The gateway (api.hanzo.ai) injects these after OIDC validation.
|
||||
// ATS sets X-User-Id directly; gateway may use X-Gateway-User-Id or X-Hanzo-User-Id.
|
||||
// resolveUserID extracts the user identity from the auth middleware.
|
||||
// Only X-User-Id is trusted — it is set by auth middleware from validated JWT claims.
|
||||
func resolveUserID(r *http.Request) string {
|
||||
if uid := r.Header.Get("X-User-Id"); uid != "" {
|
||||
return uid
|
||||
}
|
||||
if uid := r.Header.Get("X-Gateway-User-Id"); uid != "" {
|
||||
return uid
|
||||
}
|
||||
return r.Header.Get("X-Hanzo-User-Id")
|
||||
return r.Header.Get("X-User-Id")
|
||||
}
|
||||
|
||||
// resolveUserAccounts finds all accounts belonging to a user across all providers.
|
||||
// If X-Account-Id is set (e.g. by ATS after looking up the user's Alpaca sub-account),
|
||||
// use it directly and skip the expensive ListAccounts call.
|
||||
// If no user ID is available, falls back to the first account from the first provider.
|
||||
// Accounts are always resolved from the authenticated user — never from request headers.
|
||||
func (s *Server) resolveUserAccounts(r *http.Request) ([]resolvedAccount, error) {
|
||||
// Fast path: caller already resolved the account.
|
||||
if acctID := r.Header.Get("X-Account-Id"); acctID != "" {
|
||||
provider := r.Header.Get("X-Account-Provider")
|
||||
if provider == "" {
|
||||
// Default to first registered provider.
|
||||
names := s.registry.List()
|
||||
if len(names) == 0 {
|
||||
return nil, errNoAccounts
|
||||
}
|
||||
provider = names[0]
|
||||
}
|
||||
return []resolvedAccount{{provider: provider, accountID: acctID}}, nil
|
||||
}
|
||||
|
||||
userID := resolveUserID(r)
|
||||
var resolved []resolvedAccount
|
||||
|
||||
|
||||
@@ -5,12 +5,9 @@ import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/luxfi/broker/pkg/auth"
|
||||
"github.com/luxfi/broker/pkg/provider"
|
||||
"github.com/luxfi/broker/pkg/types"
|
||||
)
|
||||
@@ -124,18 +121,10 @@ func (m *mockProvider) GetCalendar(_ context.Context, _, _ string) ([]*types.Mar
|
||||
|
||||
func setupTestServerWithMock(t *testing.T, mp *mockProvider) *httptest.Server {
|
||||
t.Helper()
|
||||
os.Setenv("ADMIN_SECRET", "test-secret")
|
||||
t.Cleanup(func() {
|
||||
os.Unsetenv("ADMIN_SECRET")
|
||||
})
|
||||
|
||||
t.Setenv("IAM_ENDPOINT", testJWKS.server.URL)
|
||||
registry := provider.NewRegistry()
|
||||
registry.Register(mp)
|
||||
srv := NewServer(registry, ":0")
|
||||
srv.AuthStore().Add(&auth.APIKey{
|
||||
Key: testAPIKey, Name: "test", OrgID: "test-org",
|
||||
Permissions: []string{"admin"}, CreatedAt: time.Now(),
|
||||
})
|
||||
return httptest.NewServer(srv.Handler())
|
||||
}
|
||||
|
||||
@@ -377,10 +366,7 @@ func TestFrontendOrdersWithAccount(t *testing.T) {
|
||||
ts := setupTestServerWithMock(t, mp)
|
||||
defer ts.Close()
|
||||
|
||||
req, _ := http.NewRequest("GET", ts.URL+"/v1/exchange/orders", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+testAPIKey)
|
||||
req.Header.Set("X-Gateway-User-Id", "user1")
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
resp, err := authedRequest("GET", ts.URL+"/v1/exchange/orders", nil, "user1")
|
||||
if err != nil {
|
||||
t.Fatalf("GET /v1/exchange/orders: %v", err)
|
||||
}
|
||||
@@ -425,11 +411,7 @@ func TestFrontendCreateOrderSuccess(t *testing.T) {
|
||||
defer ts.Close()
|
||||
|
||||
body := `{"symbol":"AAPL","qty":"10","side":"buy"}`
|
||||
req, _ := http.NewRequest("POST", ts.URL+"/v1/exchange/orders", strings.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Authorization", "Bearer "+testAPIKey)
|
||||
req.Header.Set("X-Gateway-User-Id", "user1")
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
resp, err := authedRequest("POST", ts.URL+"/v1/exchange/orders", strings.NewReader(body), "user1")
|
||||
if err != nil {
|
||||
t.Fatalf("POST /v1/exchange/orders: %v", err)
|
||||
}
|
||||
@@ -500,7 +482,7 @@ func TestFrontendPositionsWithAccount(t *testing.T) {
|
||||
mp := &mockProvider{
|
||||
name: "testprov",
|
||||
accounts: []*types.Account{
|
||||
{ID: "a1", ProviderID: "pa1", Provider: "testprov"},
|
||||
{ID: "a1", ProviderID: "pa1", Provider: "testprov", UserID: testUserID},
|
||||
},
|
||||
portfolio: &types.Portfolio{
|
||||
Cash: "10000",
|
||||
@@ -554,10 +536,7 @@ func TestFrontendPortfolioSingleAccount(t *testing.T) {
|
||||
ts := setupTestServerWithMock(t, mp)
|
||||
defer ts.Close()
|
||||
|
||||
req, _ := http.NewRequest("GET", ts.URL+"/v1/exchange/portfolio", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+testAPIKey)
|
||||
req.Header.Set("X-Hanzo-User-Id", "user1")
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
resp, err := authedRequest("GET", ts.URL+"/v1/exchange/portfolio", nil, "user1")
|
||||
if err != nil {
|
||||
t.Fatalf("GET /v1/exchange/portfolio: %v", err)
|
||||
}
|
||||
@@ -872,22 +851,24 @@ func TestFrontendAssetsFilterAllTypes(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestResolveUserIDHeaders(t *testing.T) {
|
||||
// X-Gateway-User-Id takes precedence
|
||||
// Only X-User-Id is trusted (set by auth middleware from JWT claims).
|
||||
r, _ := http.NewRequest("GET", "/", nil)
|
||||
r.Header.Set("X-User-Id", "jwt-user")
|
||||
r.Header.Set("X-Gateway-User-Id", "gateway-user")
|
||||
r.Header.Set("X-Hanzo-User-Id", "hanzo-user")
|
||||
if got := resolveUserID(r); got != "gateway-user" {
|
||||
t.Fatalf("expected gateway-user, got %s", got)
|
||||
if got := resolveUserID(r); got != "jwt-user" {
|
||||
t.Fatalf("expected jwt-user, got %s", got)
|
||||
}
|
||||
|
||||
// Falls back to X-Hanzo-User-Id
|
||||
// X-Gateway-User-Id and X-Hanzo-User-Id are ignored.
|
||||
r2, _ := http.NewRequest("GET", "/", nil)
|
||||
r2.Header.Set("X-Gateway-User-Id", "gateway-user")
|
||||
r2.Header.Set("X-Hanzo-User-Id", "hanzo-user")
|
||||
if got := resolveUserID(r2); got != "hanzo-user" {
|
||||
t.Fatalf("expected hanzo-user, got %s", got)
|
||||
if got := resolveUserID(r2); got != "" {
|
||||
t.Fatalf("expected empty (untrusted headers ignored), got %s", got)
|
||||
}
|
||||
|
||||
// No headers returns empty
|
||||
// No headers returns empty.
|
||||
r3, _ := http.NewRequest("GET", "/", nil)
|
||||
if got := resolveUserID(r3); got != "" {
|
||||
t.Fatalf("expected empty, got %s", got)
|
||||
@@ -919,7 +900,7 @@ func TestFrontendCreateOrderDefaultTypeAndTIF(t *testing.T) {
|
||||
mp := &mockProvider{
|
||||
name: "testprov",
|
||||
accounts: []*types.Account{
|
||||
{ID: "a1", ProviderID: "pa1", Provider: "testprov"},
|
||||
{ID: "a1", ProviderID: "pa1", Provider: "testprov", UserID: testUserID},
|
||||
},
|
||||
}
|
||||
ts := setupTestServerWithMock(t, mp)
|
||||
@@ -927,10 +908,7 @@ func TestFrontendCreateOrderDefaultTypeAndTIF(t *testing.T) {
|
||||
|
||||
// Only symbol and side — type and time_in_force should default
|
||||
body := `{"symbol":"AAPL","qty":"1","side":"buy"}`
|
||||
req, _ := http.NewRequest("POST", ts.URL+"/v1/exchange/orders", strings.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Authorization", "Bearer "+testAPIKey)
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
resp, err := authedRequest("POST", ts.URL+"/v1/exchange/orders", strings.NewReader(body), testUserID)
|
||||
if err != nil {
|
||||
t.Fatalf("POST: %v", err)
|
||||
}
|
||||
@@ -972,24 +950,17 @@ func TestFrontendPortfolioMultipleAccounts(t *testing.T) {
|
||||
},
|
||||
}
|
||||
|
||||
os.Setenv("ADMIN_SECRET", "test-secret")
|
||||
t.Cleanup(func() { os.Unsetenv("ADMIN_SECRET") })
|
||||
t.Setenv("IAM_ENDPOINT", testJWKS.server.URL)
|
||||
|
||||
registry := provider.NewRegistry()
|
||||
registry.Register(mp1)
|
||||
registry.Register(mp2)
|
||||
srv := NewServer(registry, ":0")
|
||||
srv.AuthStore().Add(&auth.APIKey{
|
||||
Key: testAPIKey, Name: "test", OrgID: "test-org",
|
||||
Permissions: []string{"admin"}, CreatedAt: time.Now(),
|
||||
})
|
||||
tsSrv := httptest.NewServer(srv.Handler())
|
||||
defer tsSrv.Close()
|
||||
|
||||
req, _ := http.NewRequest("GET", tsSrv.URL+"/v1/exchange/portfolio", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+testAPIKey)
|
||||
req.Header.Set("X-Gateway-User-Id", "user1")
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
// Use a JWT with sub=user1 to match the account UserID.
|
||||
resp, err := authedRequest("GET", tsSrv.URL+"/v1/exchange/portfolio", nil, "user1")
|
||||
if err != nil {
|
||||
t.Fatalf("GET: %v", err)
|
||||
}
|
||||
|
||||
+32
-46
@@ -2,8 +2,6 @@ package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"os"
|
||||
@@ -16,7 +14,6 @@ import (
|
||||
"github.com/go-chi/cors"
|
||||
|
||||
"github.com/luxfi/broker/pkg/accounts"
|
||||
"github.com/luxfi/broker/pkg/admin"
|
||||
"github.com/luxfi/broker/pkg/audit"
|
||||
"github.com/luxfi/broker/pkg/auth"
|
||||
"github.com/luxfi/broker/pkg/funding"
|
||||
@@ -29,43 +26,37 @@ import (
|
||||
)
|
||||
|
||||
type Server struct {
|
||||
registry *provider.Registry
|
||||
resolver *accounts.Resolver
|
||||
sor *router.Router
|
||||
riskEng *risk.Engine
|
||||
auditLog *audit.Log
|
||||
adminStore *admin.Store
|
||||
feed *marketdata.Feed
|
||||
stream *ws.Server
|
||||
funding *funding.Service
|
||||
twap *router.TWAPScheduler
|
||||
arbDetector *marketdata.ArbitrageDetector
|
||||
registry *provider.Registry
|
||||
resolver *accounts.Resolver
|
||||
sor *router.Router
|
||||
riskEng *risk.Engine
|
||||
auditLog *audit.Log
|
||||
feed *marketdata.Feed
|
||||
stream *ws.Server
|
||||
funding *funding.Service
|
||||
twap *router.TWAPScheduler
|
||||
arbDetector *marketdata.ArbitrageDetector
|
||||
arbDetectorThresholdBps float64
|
||||
router chi.Router
|
||||
server *http.Server
|
||||
router chi.Router
|
||||
server *http.Server
|
||||
}
|
||||
|
||||
func NewServer(registry *provider.Registry, listenAddr string) *Server {
|
||||
jwtSecret := os.Getenv("ADMIN_SECRET")
|
||||
if jwtSecret == "" {
|
||||
b := make([]byte, 32)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
panic("crypto/rand unavailable: " + err.Error())
|
||||
}
|
||||
jwtSecret = hex.EncodeToString(b)
|
||||
}
|
||||
|
||||
s := &Server{
|
||||
registry: registry,
|
||||
resolver: accounts.NewResolver(),
|
||||
sor: router.New(registry),
|
||||
riskEng: risk.NewEngine(risk.DefaultLimits()),
|
||||
auditLog: audit.NewLog(),
|
||||
adminStore: admin.NewStore(jwtSecret),
|
||||
feed: marketdata.NewFeed(),
|
||||
registry: registry,
|
||||
resolver: accounts.NewResolver(),
|
||||
sor: router.New(registry),
|
||||
riskEng: risk.NewEngine(risk.DefaultLimits()),
|
||||
auditLog: audit.NewLog(),
|
||||
feed: marketdata.NewFeed(),
|
||||
}
|
||||
s.stream = ws.NewServer(s.feed)
|
||||
|
||||
iamEndpoint := os.Getenv("IAM_ENDPOINT")
|
||||
if iamEndpoint == "" {
|
||||
iamEndpoint = "http://localhost:8000"
|
||||
}
|
||||
|
||||
r := chi.NewRouter()
|
||||
r.Use(chimw.RequestID)
|
||||
r.Use(chimw.RealIP)
|
||||
@@ -78,21 +69,21 @@ func NewServer(registry *provider.Registry, listenAddr string) *Server {
|
||||
AllowCredentials: true,
|
||||
MaxAge: 300,
|
||||
}))
|
||||
// Auth: validate IAM JWT via JWKS. One auth path: IAM only.
|
||||
iamEndpoint := os.Getenv("IAM_ENDPOINT")
|
||||
if iamEndpoint == "" {
|
||||
iamEndpoint = "http://localhost:8000"
|
||||
}
|
||||
r.Use(auth.Middleware(iamEndpoint))
|
||||
|
||||
// Public routes — no auth. Webhook handlers validate processor signatures.
|
||||
r.Get("/healthz", func(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||
"status": "ok",
|
||||
"providers": registry.List(),
|
||||
})
|
||||
})
|
||||
r.Post("/v1/fund/webhook/{processor}", s.handleFundingWebhook)
|
||||
|
||||
r.Route("/v1", func(r chi.Router) {
|
||||
// All remaining routes require IAM JWT auth.
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(auth.Middleware(iamEndpoint))
|
||||
|
||||
r.Route("/v1", func(r chi.Router) {
|
||||
r.Get("/providers", s.handleListProviders)
|
||||
r.Get("/providers/capabilities", s.handleGetCapabilities)
|
||||
|
||||
@@ -159,10 +150,10 @@ func NewServer(registry *provider.Registry, listenAddr string) *Server {
|
||||
r.Get("/audit/export", s.handleAuditExport)
|
||||
|
||||
// Funding (deposit/withdraw via payment processors)
|
||||
// Note: /v1/fund/webhook/{processor} is registered before auth middleware.
|
||||
r.Route("/fund", func(r chi.Router) {
|
||||
r.Post("/deposit", s.handleDeposit)
|
||||
r.Post("/withdraw", s.handleWithdraw)
|
||||
r.Post("/webhook/{processor}", s.handleFundingWebhook)
|
||||
r.Get("/processors", s.handleListProcessors)
|
||||
})
|
||||
|
||||
@@ -251,6 +242,7 @@ func NewServer(registry *provider.Registry, listenAddr string) *Server {
|
||||
})
|
||||
})
|
||||
})
|
||||
}) // end auth group
|
||||
|
||||
s.router = r
|
||||
s.server = &http.Server{
|
||||
@@ -297,12 +289,6 @@ func (s *Server) Mount(pattern string, handler http.Handler) {
|
||||
s.router.Mount(pattern, handler)
|
||||
}
|
||||
|
||||
// AdminStore returns the admin store for external configuration.
|
||||
func (s *Server) AdminStore() *admin.Store {
|
||||
return s.adminStore
|
||||
}
|
||||
|
||||
|
||||
// Resolver returns the account resolver for user-to-provider account mapping.
|
||||
func (s *Server) Resolver() *accounts.Resolver {
|
||||
return s.resolver
|
||||
|
||||
+90
-314
@@ -1,55 +1,125 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/rsa"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"math/big"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/luxfi/broker/pkg/auth"
|
||||
"github.com/luxfi/broker/pkg/provider"
|
||||
)
|
||||
|
||||
const testAPIKey = "test-api-key-for-integration-tests"
|
||||
const testUserID = "test-user-001"
|
||||
|
||||
// testJWKS holds a test RSA key pair for mocking IAM JWKS in tests.
|
||||
var testJWKS struct {
|
||||
key *rsa.PrivateKey
|
||||
server *httptest.Server
|
||||
}
|
||||
|
||||
func init() {
|
||||
key, err := rsa.GenerateKey(rand.Reader, 2048)
|
||||
if err != nil {
|
||||
panic("failed to generate test RSA key: " + err.Error())
|
||||
}
|
||||
testJWKS.key = key
|
||||
|
||||
// Serve JWKS endpoint.
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/.well-known/jwks", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"keys": []map[string]interface{}{
|
||||
{
|
||||
"kty": "RSA",
|
||||
"kid": "test-kid",
|
||||
"n": base64.RawURLEncoding.EncodeToString(key.N.Bytes()),
|
||||
"e": base64.RawURLEncoding.EncodeToString(big.NewInt(int64(key.E)).Bytes()),
|
||||
},
|
||||
},
|
||||
})
|
||||
})
|
||||
testJWKS.server = httptest.NewServer(mux)
|
||||
}
|
||||
|
||||
// signTestJWT creates a test RS256 JWT signed with the test key.
|
||||
func signTestJWT(claims map[string]interface{}) string {
|
||||
header := base64.RawURLEncoding.EncodeToString([]byte(`{"alg":"RS256","typ":"JWT","kid":"test-kid"}`))
|
||||
claimsJSON, _ := json.Marshal(claims)
|
||||
payload := base64.RawURLEncoding.EncodeToString(claimsJSON)
|
||||
sigInput := header + "." + payload
|
||||
digest := sha256.Sum256([]byte(sigInput))
|
||||
sig, err := rsa.SignPKCS1v15(rand.Reader, testJWKS.key, 0x05, digest[:]) // crypto.SHA256 = 0x05
|
||||
if err != nil {
|
||||
panic("sign failed: " + err.Error())
|
||||
}
|
||||
return sigInput + "." + base64.RawURLEncoding.EncodeToString(sig)
|
||||
}
|
||||
|
||||
// testToken returns a valid test JWT with admin claims.
|
||||
func testToken() string {
|
||||
return signTestJWT(map[string]interface{}{
|
||||
"sub": testUserID,
|
||||
"owner": "test-org",
|
||||
"email": "test@",
|
||||
"roles": "admin",
|
||||
"isAdmin": true,
|
||||
"exp": float64(time.Now().Add(time.Hour).Unix()),
|
||||
})
|
||||
}
|
||||
|
||||
func setupTestServer(t *testing.T) *httptest.Server {
|
||||
t.Helper()
|
||||
os.Setenv("ADMIN_SECRET", "test-secret")
|
||||
t.Cleanup(func() {
|
||||
os.Unsetenv("ADMIN_SECRET")
|
||||
})
|
||||
|
||||
t.Setenv("IAM_ENDPOINT", testJWKS.server.URL)
|
||||
registry := provider.NewRegistry()
|
||||
srv := NewServer(registry, ":0")
|
||||
// Register a test API key instead of using dev mode bypass
|
||||
srv.AuthStore().Add(&auth.APIKey{
|
||||
Key: testAPIKey,
|
||||
Name: "test",
|
||||
OrgID: "test-org",
|
||||
Permissions: []string{"admin"},
|
||||
CreatedAt: time.Now(),
|
||||
})
|
||||
return httptest.NewServer(srv.Handler())
|
||||
}
|
||||
|
||||
// authedGet makes a GET request with the test API key
|
||||
// authedGet makes a GET request with a valid test JWT.
|
||||
func authedGet(url string) (*http.Response, error) {
|
||||
req, _ := http.NewRequest("GET", url, nil)
|
||||
req.Header.Set("Authorization", "Bearer "+testAPIKey)
|
||||
req.Header.Set("Authorization", "Bearer "+testToken())
|
||||
return http.DefaultClient.Do(req)
|
||||
}
|
||||
|
||||
// authedPost makes a POST request with the test API key
|
||||
// authedPost makes a POST request with a valid test JWT.
|
||||
func authedPost(url, contentType string, body *strings.Reader) (*http.Response, error) {
|
||||
req, _ := http.NewRequest("POST", url, body)
|
||||
req.Header.Set("Authorization", "Bearer "+testAPIKey)
|
||||
req.Header.Set("Authorization", "Bearer "+testToken())
|
||||
req.Header.Set("Content-Type", contentType)
|
||||
return http.DefaultClient.Do(req)
|
||||
}
|
||||
|
||||
// authedRequest makes a request with a valid test JWT and custom user ID.
|
||||
func authedRequest(method, url string, body *strings.Reader, userID string) (*http.Response, error) {
|
||||
var req *http.Request
|
||||
if body != nil {
|
||||
req, _ = http.NewRequest(method, url, body)
|
||||
} else {
|
||||
req, _ = http.NewRequest(method, url, nil)
|
||||
}
|
||||
token := signTestJWT(map[string]interface{}{
|
||||
"sub": userID,
|
||||
"owner": "test-org",
|
||||
"email": fmt.Sprintf("%[email protected]", userID),
|
||||
"roles": "admin",
|
||||
"isAdmin": true,
|
||||
"exp": float64(time.Now().Add(time.Hour).Unix()),
|
||||
})
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
return http.DefaultClient.Do(req)
|
||||
}
|
||||
|
||||
func TestHealthz(t *testing.T) {
|
||||
ts := setupTestServer(t)
|
||||
defer ts.Close()
|
||||
@@ -96,297 +166,3 @@ func TestListProviders(t *testing.T) {
|
||||
t.Fatalf("expected 0 providers, got %d", len(providers))
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetCapabilities(t *testing.T) {
|
||||
ts := setupTestServer(t)
|
||||
defer ts.Close()
|
||||
|
||||
resp, err := authedGet(ts.URL + "/v1/providers/capabilities")
|
||||
if err != nil {
|
||||
t.Fatalf("GET /v1/providers/capabilities: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRiskCheck(t *testing.T) {
|
||||
ts := setupTestServer(t)
|
||||
defer ts.Close()
|
||||
|
||||
resp, err := authedGet(ts.URL + "/v1/risk/check?provider=test&account_id=a1&symbol=BTC&side=buy&qty=1")
|
||||
if err != nil {
|
||||
t.Fatalf("GET /v1/risk/check: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
var body map[string]interface{}
|
||||
json.NewDecoder(resp.Body).Decode(&body)
|
||||
if _, ok := body["allowed"]; !ok {
|
||||
t.Fatal("expected 'allowed' field in risk check response")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuditQuery(t *testing.T) {
|
||||
ts := setupTestServer(t)
|
||||
defer ts.Close()
|
||||
|
||||
resp, err := authedGet(ts.URL + "/v1/audit")
|
||||
if err != nil {
|
||||
t.Fatalf("GET /v1/audit: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuditStats(t *testing.T) {
|
||||
ts := setupTestServer(t)
|
||||
defer ts.Close()
|
||||
|
||||
resp, err := authedGet(ts.URL + "/v1/audit/stats")
|
||||
if err != nil {
|
||||
t.Fatalf("GET /v1/audit/stats: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuditExport(t *testing.T) {
|
||||
ts := setupTestServer(t)
|
||||
defer ts.Close()
|
||||
|
||||
resp, err := authedGet(ts.URL + "/v1/audit/export")
|
||||
if err != nil {
|
||||
t.Fatalf("GET /v1/audit/export: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d", resp.StatusCode)
|
||||
}
|
||||
ct := resp.Header.Get("Content-Type")
|
||||
if ct != "application/json" {
|
||||
t.Fatalf("expected application/json content-type, got %s", ct)
|
||||
}
|
||||
cd := resp.Header.Get("Content-Disposition")
|
||||
if cd == "" {
|
||||
t.Fatal("expected Content-Disposition header for audit export")
|
||||
}
|
||||
}
|
||||
|
||||
func TestListAccountsEmptyRegistry(t *testing.T) {
|
||||
ts := setupTestServer(t)
|
||||
defer ts.Close()
|
||||
|
||||
resp, err := authedGet(ts.URL + "/v1/accounts")
|
||||
if err != nil {
|
||||
t.Fatalf("GET /v1/accounts: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
// With empty registry, should return empty array
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListAssetsUnknownProvider(t *testing.T) {
|
||||
ts := setupTestServer(t)
|
||||
defer ts.Close()
|
||||
|
||||
resp, err := authedGet(ts.URL + "/v1/assets/nonexistent")
|
||||
if err != nil {
|
||||
t.Fatalf("GET /v1/assets/nonexistent: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusBadRequest {
|
||||
t.Fatalf("expected 400 for unknown provider, got %d", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStreamEndpointExists(t *testing.T) {
|
||||
ts := setupTestServer(t)
|
||||
defer ts.Close()
|
||||
|
||||
// Just verify the endpoint exists and responds (SSE will timeout, that's fine)
|
||||
client := &http.Client{Timeout: 1}
|
||||
resp, _ := client.Get(ts.URL + "/v1/stream")
|
||||
if resp != nil {
|
||||
resp.Body.Close()
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateAccountBadRequest(t *testing.T) {
|
||||
ts := setupTestServer(t)
|
||||
defer ts.Close()
|
||||
|
||||
// Valid JSON body but no provider registered -> 400
|
||||
body := `{"provider":"alpaca","given_name":"Test","family_name":"User","email":"[email protected]"}`
|
||||
resp, err := authedPost(ts.URL+"/v1/accounts", "application/json", strings.NewReader(body))
|
||||
if err != nil {
|
||||
t.Fatalf("POST /v1/accounts: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusBadRequest {
|
||||
t.Fatalf("status = %d, want %d", resp.StatusCode, http.StatusBadRequest)
|
||||
}
|
||||
|
||||
var result map[string]string
|
||||
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
if result["error"] == "" {
|
||||
t.Fatal("expected error message in response body")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateAccountInvalidBody(t *testing.T) {
|
||||
ts := setupTestServer(t)
|
||||
defer ts.Close()
|
||||
|
||||
resp, err := authedPost(ts.URL+"/v1/accounts", "application/json", strings.NewReader("not json"))
|
||||
if err != nil {
|
||||
t.Fatalf("POST /v1/accounts: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusBadRequest {
|
||||
t.Fatalf("status = %d, want %d", resp.StatusCode, http.StatusBadRequest)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRiskCheckResponseStructure(t *testing.T) {
|
||||
ts := setupTestServer(t)
|
||||
defer ts.Close()
|
||||
|
||||
resp, err := authedGet(ts.URL + "/v1/risk/check?provider=alpaca&account_id=acct1&symbol=AAPL&side=buy&qty=10&price=150&type=limit")
|
||||
if err != nil {
|
||||
t.Fatalf("GET /v1/risk/check: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("status = %d, want %d", resp.StatusCode, http.StatusOK)
|
||||
}
|
||||
|
||||
var result map[string]interface{}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
allowed, ok := result["allowed"].(bool)
|
||||
if !ok {
|
||||
t.Fatal("'allowed' field should be boolean")
|
||||
}
|
||||
if !allowed {
|
||||
t.Fatal("expected allowed=true for normal order against default limits")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuditStatsResponseStructure(t *testing.T) {
|
||||
ts := setupTestServer(t)
|
||||
defer ts.Close()
|
||||
|
||||
resp, err := authedGet(ts.URL + "/v1/audit/stats")
|
||||
if err != nil {
|
||||
t.Fatalf("GET /v1/audit/stats: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
var result map[string]interface{}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
if result["total_entries"] == nil {
|
||||
t.Fatal("missing total_entries")
|
||||
}
|
||||
if result["actions"] == nil {
|
||||
t.Fatal("missing actions")
|
||||
}
|
||||
if result["providers"] == nil {
|
||||
t.Fatal("missing providers")
|
||||
}
|
||||
if result["statuses"] == nil {
|
||||
t.Fatal("missing statuses")
|
||||
}
|
||||
if result["avg_order_latency"] == nil {
|
||||
t.Fatal("missing avg_order_latency")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuditExportContentDisposition(t *testing.T) {
|
||||
ts := setupTestServer(t)
|
||||
defer ts.Close()
|
||||
|
||||
resp, err := authedGet(ts.URL + "/v1/audit/export")
|
||||
if err != nil {
|
||||
t.Fatalf("GET /v1/audit/export: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
cd := resp.Header.Get("Content-Disposition")
|
||||
if !strings.Contains(cd, "audit_export.json") {
|
||||
t.Fatalf("Content-Disposition = %q, want to contain audit_export.json", cd)
|
||||
}
|
||||
|
||||
// Body should be valid JSON array
|
||||
var entries []interface{}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&entries); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthRequiredWithoutKey(t *testing.T) {
|
||||
os.Setenv("ADMIN_SECRET", "test-secret")
|
||||
defer os.Unsetenv("ADMIN_SECRET")
|
||||
|
||||
registry := provider.NewRegistry()
|
||||
srv := NewServer(registry, ":0")
|
||||
ts := httptest.NewServer(srv.Handler())
|
||||
defer ts.Close()
|
||||
|
||||
// No API key — should get 401
|
||||
resp, err := http.Get(ts.URL + "/v1/providers")
|
||||
if err != nil {
|
||||
t.Fatalf("GET /v1/providers: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusUnauthorized {
|
||||
t.Fatalf("status = %d, want %d", resp.StatusCode, http.StatusUnauthorized)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHealthzBypassesAuth(t *testing.T) {
|
||||
os.Setenv("ADMIN_SECRET", "test-secret")
|
||||
defer os.Unsetenv("ADMIN_SECRET")
|
||||
|
||||
registry := provider.NewRegistry()
|
||||
srv := NewServer(registry, ":0")
|
||||
ts := httptest.NewServer(srv.Handler())
|
||||
defer ts.Close()
|
||||
|
||||
// Healthz should work without any key
|
||||
resp, err := http.Get(ts.URL + "/healthz")
|
||||
if err != nil {
|
||||
t.Fatalf("GET /healthz: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("status = %d, want %d", resp.StatusCode, http.StatusOK)
|
||||
}
|
||||
}
|
||||
|
||||
+38
-18
@@ -21,21 +21,35 @@ var (
|
||||
jwksExpiry time.Time
|
||||
)
|
||||
|
||||
// untrustedHeaders are identity headers that must be stripped at the
|
||||
// start of every request to prevent injection via direct pod access.
|
||||
// The auth middleware re-sets them from validated JWT claims.
|
||||
var untrustedHeaders = []string{
|
||||
"X-User-Id",
|
||||
"X-Org-Id",
|
||||
"X-User-Email",
|
||||
"X-User-Roles",
|
||||
"X-User-IsAdmin",
|
||||
"X-Account-Id",
|
||||
"X-Account-Provider",
|
||||
"X-Gateway-User-Id",
|
||||
"X-Hanzo-User-Id",
|
||||
}
|
||||
|
||||
// Middleware validates IAM JWTs via JWKS.
|
||||
// Accepts gateway-propagated headers (X-User-Id) OR Bearer tokens.
|
||||
// All identity headers are stripped first, then re-set from validated claims.
|
||||
// /healthz is always public.
|
||||
func Middleware(iamEndpoint string) func(http.Handler) http.Handler {
|
||||
jwksURL := iamEndpoint + "/.well-known/jwks"
|
||||
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path == "/healthz" {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
// Strip all identity headers unconditionally to prevent injection.
|
||||
for _, h := range untrustedHeaders {
|
||||
r.Header.Del(h)
|
||||
}
|
||||
|
||||
// Gateway-propagated headers take precedence (already validated)
|
||||
if uid := r.Header.Get("X-User-Id"); uid != "" {
|
||||
if r.URL.Path == "/healthz" {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
@@ -47,16 +61,19 @@ func Middleware(iamEndpoint string) func(http.Handler) http.Handler {
|
||||
return
|
||||
}
|
||||
|
||||
claims, err := validateJWT(strings.TrimPrefix(auth, "Bearer "), jwksURL)
|
||||
claims, err := ValidateJWT(strings.TrimPrefix(auth, "Bearer "), jwksURL)
|
||||
if err != nil {
|
||||
writeErr(w, http.StatusUnauthorized, "invalid token")
|
||||
return
|
||||
}
|
||||
|
||||
r.Header.Set("X-User-Id", claimStr(claims, "sub"))
|
||||
r.Header.Set("X-Org-Id", claimStr(claims, "owner"))
|
||||
r.Header.Set("X-User-Email", claimStr(claims, "email"))
|
||||
if claimBool(claims, "isAdmin") {
|
||||
r.Header.Set("X-User-Id", ClaimStr(claims, "sub"))
|
||||
r.Header.Set("X-Org-Id", ClaimStr(claims, "owner"))
|
||||
r.Header.Set("X-User-Email", ClaimStr(claims, "email"))
|
||||
if roles := ClaimStr(claims, "roles"); roles != "" {
|
||||
r.Header.Set("X-User-Roles", roles)
|
||||
}
|
||||
if ClaimBool(claims, "isAdmin") {
|
||||
r.Header.Set("X-User-IsAdmin", "true")
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
@@ -70,7 +87,7 @@ func RequirePermission(perm string) func(http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
roles := r.Header.Get("X-User-Roles")
|
||||
isAdmin := strings.EqualFold(r.Header.Get("X-User-IsAdmin"), "true")
|
||||
if isAdmin || hasRole(roles, perm) || hasRole(roles, "admin") {
|
||||
if isAdmin || HasRole(roles, perm) || HasRole(roles, "admin") {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
@@ -83,8 +100,8 @@ func RequirePermission(perm string) func(http.Handler) http.Handler {
|
||||
}
|
||||
}
|
||||
|
||||
// validateJWT validates RS256 JWT signature against JWKS and returns claims.
|
||||
func validateJWT(tokenStr, jwksURL string) (map[string]interface{}, error) {
|
||||
// ValidateJWT validates RS256 JWT signature against JWKS and returns claims.
|
||||
func ValidateJWT(tokenStr, jwksURL string) (map[string]interface{}, error) {
|
||||
parts := strings.Split(tokenStr, ".")
|
||||
if len(parts) != 3 {
|
||||
return nil, fmt.Errorf("malformed token")
|
||||
@@ -190,7 +207,8 @@ func getJWKSKey(jwksURL, kid string) (*rsa.PublicKey, error) {
|
||||
return nil, fmt.Errorf("no key for kid=%s", kid)
|
||||
}
|
||||
|
||||
func hasRole(roles, role string) bool {
|
||||
// HasRole checks whether a comma-separated role list contains the given role.
|
||||
func HasRole(roles, role string) bool {
|
||||
for _, r := range strings.Split(roles, ",") {
|
||||
if strings.TrimSpace(r) == role {
|
||||
return true
|
||||
@@ -199,14 +217,16 @@ func hasRole(roles, role string) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
func claimStr(claims map[string]interface{}, key string) string {
|
||||
// ClaimStr extracts a string claim from JWT claims.
|
||||
func ClaimStr(claims map[string]interface{}, key string) string {
|
||||
if v, ok := claims[key].(string); ok {
|
||||
return v
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func claimBool(claims map[string]interface{}, key string) bool {
|
||||
// ClaimBool extracts a boolean claim from JWT claims.
|
||||
func ClaimBool(claims map[string]interface{}, key string) bool {
|
||||
if v, ok := claims[key].(bool); ok {
|
||||
return v
|
||||
}
|
||||
@@ -216,5 +236,5 @@ func claimBool(claims map[string]interface{}, key string) bool {
|
||||
func writeErr(w http.ResponseWriter, code int, msg string) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(code)
|
||||
w.Write([]byte(`{"error":"` + msg + `"}`))
|
||||
json.NewEncoder(w).Encode(map[string]string{"error": msg})
|
||||
}
|
||||
|
||||
+95
-18
@@ -21,32 +21,46 @@ func TestMiddlewareHealthzSkipsAuth(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestMiddlewareAcceptsGatewayHeaders(t *testing.T) {
|
||||
var gotUserID, gotOrgID, gotEmail string
|
||||
func TestMiddlewareStripsInjectedHeaders(t *testing.T) {
|
||||
// Verify that externally-set identity headers are stripped and the
|
||||
// request is rejected (no valid Bearer token).
|
||||
handler := Middleware("http://localhost:9999")(http.HandlerFunc(okHandler))
|
||||
req := httptest.NewRequest(http.MethodGet, "/v1/test", nil)
|
||||
req.Header.Set("X-User-Id", "injected-user")
|
||||
req.Header.Set("X-Org-Id", "injected-org")
|
||||
req.Header.Set("X-User-Email", "[email protected]")
|
||||
req.Header.Set("X-User-Roles", "superadmin")
|
||||
req.Header.Set("X-User-IsAdmin", "true")
|
||||
req.Header.Set("X-Account-Id", "injected-acct")
|
||||
req.Header.Set("X-Gateway-User-Id", "injected-gw")
|
||||
req.Header.Set("X-Hanzo-User-Id", "injected-hanzo")
|
||||
rr := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rr, req)
|
||||
// Should be rejected because the injected headers are stripped and
|
||||
// no Bearer token is present.
|
||||
if rr.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("expected 401 when identity headers are injected without Bearer token, got %d", rr.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMiddlewareHealthzStripsHeaders(t *testing.T) {
|
||||
// Even /healthz should strip identity headers to prevent leaking
|
||||
// injected state into downstream handlers.
|
||||
var gotUserID string
|
||||
inner := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
gotUserID = r.Header.Get("X-User-Id")
|
||||
gotOrgID = r.Header.Get("X-Org-Id")
|
||||
gotEmail = r.Header.Get("X-User-Email")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
})
|
||||
handler := Middleware("http://localhost:9999")(inner)
|
||||
req := httptest.NewRequest(http.MethodGet, "/v1/test", nil)
|
||||
req.Header.Set("X-User-Id", "user-123")
|
||||
req.Header.Set("X-Org-Id", "org-456")
|
||||
req.Header.Set("X-User-Email", "[email protected]")
|
||||
req := httptest.NewRequest(http.MethodGet, "/healthz", nil)
|
||||
req.Header.Set("X-User-Id", "injected-user")
|
||||
rr := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rr, req)
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d", rr.Code)
|
||||
}
|
||||
if gotUserID != "user-123" {
|
||||
t.Fatalf("expected X-User-Id 'user-123', got %q", gotUserID)
|
||||
}
|
||||
if gotOrgID != "org-456" {
|
||||
t.Fatalf("expected X-Org-Id 'org-456', got %q", gotOrgID)
|
||||
}
|
||||
if gotEmail != "[email protected]" {
|
||||
t.Fatalf("expected X-User-Email '[email protected]', got %q", gotEmail)
|
||||
if gotUserID != "" {
|
||||
t.Fatalf("expected X-User-Id to be stripped on /healthz, got %q", gotUserID)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -60,14 +74,14 @@ func TestMiddlewareRejectsNoHeaders(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestMiddlewareIgnoresBearerToken(t *testing.T) {
|
||||
func TestMiddlewareRejectsInvalidBearerToken(t *testing.T) {
|
||||
handler := Middleware("http://localhost:9999")(http.HandlerFunc(okHandler))
|
||||
req := httptest.NewRequest(http.MethodGet, "/v1/test", nil)
|
||||
req.Header.Set("Authorization", "Bearer some-jwt")
|
||||
rr := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rr, req)
|
||||
if rr.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("expected 401 with only Bearer token, got %d", rr.Code)
|
||||
t.Fatalf("expected 401 with invalid Bearer token, got %d", rr.Code)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -93,6 +107,17 @@ func TestRequirePermissionAdmin(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRequirePermissionIsAdmin(t *testing.T) {
|
||||
handler := RequirePermission("trade")(http.HandlerFunc(okHandler))
|
||||
req := httptest.NewRequest(http.MethodPost, "/v1/orders", nil)
|
||||
req.Header.Set("X-User-IsAdmin", "true")
|
||||
rr := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rr, req)
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200 for isAdmin, got %d", rr.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRequirePermissionReadDefault(t *testing.T) {
|
||||
handler := RequirePermission("read")(http.HandlerFunc(okHandler))
|
||||
req := httptest.NewRequest(http.MethodGet, "/v1/test", nil)
|
||||
@@ -113,3 +138,55 @@ func TestRequirePermissionDenied(t *testing.T) {
|
||||
t.Fatalf("expected 403, got %d", rr.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHasRoleCommaSeparated(t *testing.T) {
|
||||
tests := []struct {
|
||||
roles string
|
||||
role string
|
||||
expect bool
|
||||
}{
|
||||
{"admin,trade,read", "trade", true},
|
||||
{"admin, trade, read", "trade", true},
|
||||
{"read", "trade", false},
|
||||
{"", "trade", false},
|
||||
{"superadmin", "superadmin", true},
|
||||
{"viewer,editor", "admin", false},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
got := HasRole(tt.roles, tt.role)
|
||||
if got != tt.expect {
|
||||
t.Errorf("HasRole(%q, %q) = %v, want %v", tt.roles, tt.role, got, tt.expect)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteErrJSONSafe(t *testing.T) {
|
||||
rr := httptest.NewRecorder()
|
||||
writeErr(rr, http.StatusBadRequest, `injection"attempt`)
|
||||
body := rr.Body.String()
|
||||
// json.Marshal should properly escape the quote
|
||||
if rr.Code != http.StatusBadRequest {
|
||||
t.Fatalf("expected 400, got %d", rr.Code)
|
||||
}
|
||||
// Verify it's valid JSON by checking it doesn't contain unescaped injection
|
||||
if !contains(body, `injection\"attempt`) && !contains(body, `injection\u0022attempt`) {
|
||||
// Either escaped form is acceptable from json.Marshal
|
||||
// Just verify the raw injection doesn't appear unescaped
|
||||
if contains(body, `injection"attempt"`) {
|
||||
t.Fatalf("writeErr did not escape JSON: %s", body)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func contains(s, substr string) bool {
|
||||
return len(s) >= len(substr) && searchString(s, substr)
|
||||
}
|
||||
|
||||
func searchString(s, substr string) bool {
|
||||
for i := 0; i <= len(s)-len(substr); i++ {
|
||||
if s[i:i+len(substr)] == substr {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -21,6 +21,10 @@ var ssnHMACKey []byte
|
||||
func init() {
|
||||
key := os.Getenv("SSN_HMAC_KEY")
|
||||
if key == "" {
|
||||
if os.Getenv("BROKER_ENV") != "development" && os.Getenv("BROKER_ENV") != "" {
|
||||
// Fatal: production requires a real HMAC key from KMS.
|
||||
log.Fatal().Msg("SSN_HMAC_KEY is required in non-development environments (source from KMS)")
|
||||
}
|
||||
ssnHMACKey = []byte("test-only-not-for-production")
|
||||
return
|
||||
}
|
||||
|
||||
+45
-69
@@ -4,14 +4,13 @@ import (
|
||||
"encoding/json"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/go-chi/cors"
|
||||
"github.com/luxfi/broker/pkg/auth"
|
||||
"github.com/luxfi/compliance/pkg/jube"
|
||||
"github.com/rs/zerolog/log"
|
||||
)
|
||||
@@ -66,31 +65,7 @@ func NewRouter(store ComplianceStore, opts ...RouterOption) chi.Router {
|
||||
|
||||
r := chi.NewRouter()
|
||||
|
||||
// CORS — explicit production origins only. No wildcards to prevent
|
||||
// subdomain takeover attacks (MEDIUM-2). Localhost origins gated behind
|
||||
// environment check (LOW-2).
|
||||
// CORS origins from env (comma-separated) or defaults
|
||||
corsOrigins := []string{
|
||||
"https://lux.exchange",
|
||||
"https://admin.lux.exchange",
|
||||
}
|
||||
if extra := os.Getenv("CORS_ALLOWED_ORIGINS"); extra != "" {
|
||||
for _, o := range strings.Split(extra, ",") {
|
||||
if trimmed := strings.TrimSpace(o); trimmed != "" {
|
||||
corsOrigins = append(corsOrigins, trimmed)
|
||||
}
|
||||
}
|
||||
}
|
||||
if os.Getenv("BROKER_ENV") != "production" {
|
||||
corsOrigins = append(corsOrigins, "http://localhost:3000", "http://localhost:3100")
|
||||
}
|
||||
r.Use(cors.Handler(cors.Options{
|
||||
AllowedOrigins: corsOrigins,
|
||||
AllowedMethods: []string{"GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"},
|
||||
AllowedHeaders: []string{"Accept", "Authorization", "Content-Type"},
|
||||
AllowCredentials: true,
|
||||
MaxAge: 300,
|
||||
}))
|
||||
// CORS is handled by the root router — no duplicate handler here.
|
||||
|
||||
// Request body size limit — 1MB max to prevent abuse.
|
||||
r.Use(maxBodySize(1 << 20))
|
||||
@@ -243,57 +218,57 @@ func NewRouter(store ComplianceStore, opts ...RouterOption) chi.Router {
|
||||
return r
|
||||
}
|
||||
|
||||
// requireRole returns an http.HandlerFunc that checks if the authenticated admin
|
||||
// has the required module+action permission by looking up their role in the compliance
|
||||
// store's roles table. The special "superadmin" role retains implicit full access
|
||||
// for bootstrap/recovery scenarios. All other roles are checked against stored
|
||||
// permissions (HIGH-1).
|
||||
// requireRole returns an http.HandlerFunc that checks if the authenticated user
|
||||
// has the required module+action permission by looking up their role(s) in the
|
||||
// compliance store's roles table. X-User-Roles may contain comma-separated roles.
|
||||
// The special "superadmin" role retains implicit full access for bootstrap/recovery.
|
||||
func requireRole(store ComplianceStore, module, action string, next http.HandlerFunc) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
roleName := r.Header.Get("X-User-Roles")
|
||||
rolesHeader := r.Header.Get("X-User-Roles")
|
||||
isAdmin := strings.EqualFold(r.Header.Get("X-User-IsAdmin"), "true")
|
||||
|
||||
// IAM isAdmin claim grants superadmin access
|
||||
if isAdmin && roleName == "" {
|
||||
roleName = "superadmin"
|
||||
if isAdmin && rolesHeader == "" {
|
||||
rolesHeader = "superadmin"
|
||||
}
|
||||
|
||||
if roleName == "" {
|
||||
if rolesHeader == "" {
|
||||
writeError(w, http.StatusForbidden, "no role in token")
|
||||
return
|
||||
}
|
||||
|
||||
// superadmin is an escape hatch for bootstrap/recovery.
|
||||
// Always log when this bypass is used for audit trail.
|
||||
if roleName == "superadmin" {
|
||||
user := r.Header.Get("X-User-Id")
|
||||
log.Warn().
|
||||
Str("user", user).
|
||||
Str("module", module).
|
||||
Str("action", action).
|
||||
Str("path", r.URL.Path).
|
||||
Str("method", r.Method).
|
||||
Msg("superadmin bypass used")
|
||||
next(w, r)
|
||||
return
|
||||
}
|
||||
// Check each role in the comma-separated list.
|
||||
for _, roleName := range strings.Split(rolesHeader, ",") {
|
||||
roleName = strings.TrimSpace(roleName)
|
||||
if roleName == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
// Look up the role's permissions from the store.
|
||||
role, err := store.GetRoleByName(roleName)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusForbidden, "unknown role: "+roleName)
|
||||
return
|
||||
}
|
||||
|
||||
for _, perm := range role.Permissions {
|
||||
if perm.Module == module && perm.Action == action {
|
||||
// superadmin is an escape hatch for bootstrap/recovery.
|
||||
if roleName == "superadmin" {
|
||||
user := r.Header.Get("X-User-Id")
|
||||
log.Warn().
|
||||
Str("user", user).
|
||||
Str("module", module).
|
||||
Str("action", action).
|
||||
Str("path", r.URL.Path).
|
||||
Str("method", r.Method).
|
||||
Msg("superadmin bypass used")
|
||||
next(w, r)
|
||||
return
|
||||
}
|
||||
// "admin" action on a module implies all other actions.
|
||||
if perm.Module == module && perm.Action == "admin" {
|
||||
next(w, r)
|
||||
return
|
||||
|
||||
// Look up the role's permissions from the store.
|
||||
role, err := store.GetRoleByName(roleName)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
for _, perm := range role.Permissions {
|
||||
if perm.Module == module && (perm.Action == action || perm.Action == "admin") {
|
||||
next(w, r)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -301,16 +276,17 @@ func requireRole(store ComplianceStore, module, action string, next http.Handler
|
||||
}
|
||||
}
|
||||
|
||||
// requireSuperAdmin rejects requests unless the JWT role is "superadmin" or
|
||||
// "owner". Used for role mutation endpoints to prevent self-escalation (HIGH-3).
|
||||
// requireSuperAdmin rejects requests unless the JWT contains "superadmin" or
|
||||
// "owner" in the comma-separated role list. Used for role mutation endpoints
|
||||
// to prevent self-escalation (HIGH-3).
|
||||
func requireSuperAdmin(next http.HandlerFunc) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
roleName := r.Header.Get("X-User-Roles")
|
||||
if roleName != "superadmin" && roleName != "owner" {
|
||||
writeError(w, http.StatusForbidden, "role mutation requires superadmin or owner role")
|
||||
rolesHeader := r.Header.Get("X-User-Roles")
|
||||
if auth.HasRole(rolesHeader, "superadmin") || auth.HasRole(rolesHeader, "owner") {
|
||||
next(w, r)
|
||||
return
|
||||
}
|
||||
next(w, r)
|
||||
writeError(w, http.StatusForbidden, "role mutation requires superadmin or owner role")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+63
-6
@@ -14,8 +14,11 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/luxfi/broker/pkg/auth"
|
||||
"github.com/luxfi/broker/pkg/marketdata"
|
||||
"github.com/luxfi/broker/pkg/provider"
|
||||
"github.com/luxfi/broker/pkg/router"
|
||||
@@ -25,6 +28,7 @@ import (
|
||||
"google.golang.org/grpc/codes"
|
||||
"google.golang.org/grpc/health"
|
||||
healthpb "google.golang.org/grpc/health/grpc_health_v1"
|
||||
"google.golang.org/grpc/metadata"
|
||||
"google.golang.org/grpc/reflection"
|
||||
"google.golang.org/grpc/status"
|
||||
)
|
||||
@@ -42,11 +46,12 @@ type Server struct {
|
||||
|
||||
// Config holds gRPC server configuration.
|
||||
type Config struct {
|
||||
ListenAddr string
|
||||
Registry *provider.Registry
|
||||
Router *router.Router
|
||||
TWAPScheduler *router.TWAPScheduler
|
||||
Feed *marketdata.Feed
|
||||
ListenAddr string
|
||||
IAMEndpoint string
|
||||
Registry *provider.Registry
|
||||
Router *router.Router
|
||||
TWAPScheduler *router.TWAPScheduler
|
||||
Feed *marketdata.Feed
|
||||
ArbitrageDetector *marketdata.ArbitrageDetector
|
||||
}
|
||||
|
||||
@@ -57,8 +62,19 @@ func NewServer(cfg Config) (*Server, error) {
|
||||
return nil, fmt.Errorf("grpc listen: %w", err)
|
||||
}
|
||||
|
||||
iamEndpoint := cfg.IAMEndpoint
|
||||
if iamEndpoint == "" {
|
||||
iamEndpoint = os.Getenv("IAM_ENDPOINT")
|
||||
if iamEndpoint == "" {
|
||||
iamEndpoint = "http://localhost:8000"
|
||||
}
|
||||
}
|
||||
|
||||
grpcSrv := ggrpc.NewServer(
|
||||
ggrpc.UnaryInterceptor(loggingInterceptor),
|
||||
ggrpc.ChainUnaryInterceptor(
|
||||
authInterceptor(iamEndpoint),
|
||||
loggingInterceptor,
|
||||
),
|
||||
)
|
||||
|
||||
s := &Server{
|
||||
@@ -473,6 +489,47 @@ func twapToResponse(exec *router.TWAPExecution) *TWAPResponse {
|
||||
return resp
|
||||
}
|
||||
|
||||
// authInterceptor validates Bearer tokens from gRPC metadata via IAM JWKS.
|
||||
// Health and reflection RPCs are excluded.
|
||||
func authInterceptor(iamEndpoint string) ggrpc.UnaryServerInterceptor {
|
||||
jwksURL := iamEndpoint + "/.well-known/jwks"
|
||||
|
||||
return func(ctx context.Context, req interface{}, info *ggrpc.UnaryServerInfo, handler ggrpc.UnaryHandler) (interface{}, error) {
|
||||
// Skip auth for health checks and reflection.
|
||||
if strings.HasPrefix(info.FullMethod, "/grpc.health.v1.") ||
|
||||
strings.HasPrefix(info.FullMethod, "/grpc.reflection.") {
|
||||
return handler(ctx, req)
|
||||
}
|
||||
|
||||
md, ok := metadata.FromIncomingContext(ctx)
|
||||
if !ok {
|
||||
return nil, status.Error(codes.Unauthenticated, "missing metadata")
|
||||
}
|
||||
|
||||
vals := md.Get("authorization")
|
||||
if len(vals) == 0 {
|
||||
return nil, status.Error(codes.Unauthenticated, "authorization required")
|
||||
}
|
||||
token := vals[0]
|
||||
if !strings.HasPrefix(token, "Bearer ") {
|
||||
return nil, status.Error(codes.Unauthenticated, "bearer token required")
|
||||
}
|
||||
token = strings.TrimPrefix(token, "Bearer ")
|
||||
|
||||
claims, err := auth.ValidateJWT(token, jwksURL)
|
||||
if err != nil {
|
||||
return nil, status.Error(codes.Unauthenticated, "invalid token")
|
||||
}
|
||||
|
||||
// Propagate identity via context metadata for downstream handlers.
|
||||
md.Set("x-user-id", auth.ClaimStr(claims, "sub"))
|
||||
md.Set("x-org-id", auth.ClaimStr(claims, "owner"))
|
||||
ctx = metadata.NewIncomingContext(ctx, md)
|
||||
|
||||
return handler(ctx, req)
|
||||
}
|
||||
}
|
||||
|
||||
func loggingInterceptor(ctx context.Context, req interface{}, info *ggrpc.UnaryServerInfo, handler ggrpc.UnaryHandler) (interface{}, error) {
|
||||
start := time.Now()
|
||||
resp, err := handler(ctx, req)
|
||||
|
||||
Reference in New Issue
Block a user