security: fix remaining Red team findings (LOW + MEDIUM)

MEDIUM: Bridge wallet org isolation — added orgID filter to wallet query
  in handleBridgeSign, prevents cross-tenant signing.
LOW-1: JWT iss/aud claims — tokens now carry issuer "mpc.lux.network"
  and distinct audiences ("mpc-api" vs "mpc-refresh"). Refresh tokens
  can no longer be used as access tokens.
LOW-2: Rate limiter XFF bypass — clientIP uses r.RemoteAddr (set by
  RealIP middleware) instead of parsing X-Forwarded-For directly.
LOW-3: Request body size limit — 1MB maxBodySize middleware prevents OOM
  from oversized payloads.
This commit is contained in:
2026-04-06 23:28:34 -07:00
parent 7eaa71fcac
commit bfbbacccfa
5 changed files with 61 additions and 19 deletions
+38 -3
View File
@@ -6,6 +6,7 @@ import (
"crypto/sha256"
"encoding/hex"
"fmt"
"slices"
"time"
"github.com/golang-jwt/jwt/v5"
@@ -28,6 +29,8 @@ func (s *Server) generateJWT(userID, orgID, role string) (string, error) {
OrgID: orgID,
Role: role,
RegisteredClaims: jwt.RegisteredClaims{
Issuer: "mpc.lux.network",
Audience: jwt.ClaimStrings{"mpc-api"},
ExpiresAt: jwt.NewNumericDate(time.Now().Add(24 * time.Hour)),
IssuedAt: jwt.NewNumericDate(time.Now()),
},
@@ -42,6 +45,8 @@ func (s *Server) generateRefreshToken(userID, orgID, role string) (string, error
OrgID: orgID,
Role: role,
RegisteredClaims: jwt.RegisteredClaims{
Issuer: "mpc.lux.network",
Audience: jwt.ClaimStrings{"mpc-refresh"},
ExpiresAt: jwt.NewNumericDate(time.Now().Add(7 * 24 * time.Hour)),
IssuedAt: jwt.NewNumericDate(time.Now()),
},
@@ -60,10 +65,40 @@ func (s *Server) validateJWT(tokenStr string) (*JWTClaims, error) {
if err != nil {
return nil, err
}
if claims, ok := token.Claims.(*JWTClaims); ok && token.Valid {
return claims, nil
claims, ok := token.Claims.(*JWTClaims)
if !ok || !token.Valid {
return nil, fmt.Errorf("invalid token")
}
return nil, fmt.Errorf("invalid token")
if claims.Issuer != "mpc.lux.network" {
return nil, fmt.Errorf("invalid token issuer")
}
if !slices.Contains([]string(claims.Audience),"mpc-api") {
return nil, fmt.Errorf("invalid token audience")
}
return claims, nil
}
func (s *Server) validateRefreshToken(tokenStr string) (*JWTClaims, error) {
token, err := jwt.ParseWithClaims(tokenStr, &JWTClaims{}, func(token *jwt.Token) (interface{}, error) {
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"])
}
return s.jwtSecret, nil
})
if err != nil {
return nil, err
}
claims, ok := token.Claims.(*JWTClaims)
if !ok || !token.Valid {
return nil, fmt.Errorf("invalid token")
}
if claims.Issuer != "mpc.lux.network" {
return nil, fmt.Errorf("invalid token issuer")
}
if !slices.Contains([]string(claims.Audience),"mpc-refresh") {
return nil, fmt.Errorf("invalid token audience")
}
return claims, nil
}
func hashPassword(password string) (string, error) {
+1 -1
View File
@@ -159,7 +159,7 @@ func (s *Server) handleRefresh(w http.ResponseWriter, r *http.Request) {
return
}
claims, err := s.validateJWT(req.RefreshToken)
claims, err := s.validateRefreshToken(req.RefreshToken)
if err != nil {
writeError(w, http.StatusUnauthorized, "invalid refresh token")
return
+2 -1
View File
@@ -70,9 +70,11 @@ func (s *Server) handleBridgeSign(w http.ResponseWriter, r *http.Request) {
// Find a bridge wallet — look for a wallet named "bridge" or use the first active secp256k1 wallet
var bridgeWallet *db.Wallet
orgID := getOrgID(r.Context())
// Try to find wallet with "bridge" in name
wallets, err := orm.TypedQuery[db.Wallet](s.db.ORM).
Filter("orgId=", orgID).
Filter("status=", "active").
Filter("keyType=", "secp256k1").
Order("createdAt").
@@ -112,7 +114,6 @@ func (s *Server) handleBridgeSign(w http.ResponseWriter, r *http.Request) {
}
// Trigger MPC signing
orgID := getOrgID(r.Context())
result, err := s.mpc.TriggerSign(orgID, mpcWalletID, msgHash)
if err != nil {
json.NewEncoder(w).Encode(bridgeSignResponse{Status: false, Msg: "MPC signing failed: " + err.Error()})
+7 -14
View File
@@ -1,8 +1,8 @@
package api
import (
"net"
"net/http"
"strings"
"sync"
"sync/atomic"
"time"
@@ -74,21 +74,14 @@ func (rl *RateLimiter) cleanup() {
}
}
// clientIP extracts the client IP from X-Forwarded-For or RemoteAddr.
// clientIP extracts the client IP from RemoteAddr.
// chimw.RealIP middleware already processes X-Forwarded-For and sets RemoteAddr.
func clientIP(r *http.Request) string {
if xff := r.Header.Get("X-Forwarded-For"); xff != "" {
// First IP in the chain is the original client.
if ip, _, ok := strings.Cut(xff, ","); ok {
return strings.TrimSpace(ip)
}
return strings.TrimSpace(xff)
host, _, err := net.SplitHostPort(r.RemoteAddr)
if err != nil {
return r.RemoteAddr
}
// Strip port from RemoteAddr.
addr := r.RemoteAddr
if idx := strings.LastIndex(addr, ":"); idx != -1 {
return addr[:idx]
}
return addr
return host
}
// RateLimitMiddleware returns chi-compatible middleware that limits requests per
+13
View File
@@ -72,6 +72,18 @@ type Server struct {
replayGuard *replayGuard
}
// maxBodySize returns middleware that limits request body size to prevent OOM
// from oversized payloads. Handlers that read beyond the limit get an error
// from http.MaxBytesReader.
func maxBodySize(maxBytes int64) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
r.Body = http.MaxBytesReader(w, r.Body, maxBytes)
next.ServeHTTP(w, r)
})
}
}
func NewServer(database *db.Database, mpcBackend MPCBackend, jwtSecret string, listenAddr string, oidcIssuers ...string) *Server {
// Default allowed issuers if none provided
if len(oidcIssuers) == 0 {
@@ -145,6 +157,7 @@ func NewServer(database *db.Database, mpcBackend MPCBackend, jwtSecret string, l
r.Use(chimw.RequestID)
r.Use(chimw.RealIP)
r.Use(chimw.Recoverer)
r.Use(maxBodySize(1 << 20)) // 1 MB
r.Use(chimw.Timeout(120 * time.Second))
r.Use(cors.Handler(cors.Options{
AllowedOrigins: corsOrigins,