mirror of
https://github.com/luxfi/mpc.git
synced 2026-09-24 00:26:24 +00:00
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:
+38
-3
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user