mirror of
https://github.com/syntrex-lab/gomcp.git
synced 2026-04-25 04:16:22 +02:00
SEC: Fix 3 CRITICAL + 3 MEDIUM red team findings
C1: Remove verification_code_dev from API response (CVSS 9.8)
- Code now logged server-side only when email service not configured
C2: Tenant isolation on /api/auth/users (CVSS 9.1)
- HandleListUsers filters by claims.TenantID
- TenantID added to User struct, DB migration, persistUser, loadFromDB
C3: Include TenantID in JWT tokens (CVSS 8.8)
- Login handler now uses Sign() with full Claims including TenantID
- Enables downstream RBAC tenant filtering
M1: nginx server_tokens off (hide version fingerprint)
M2: syntrex.pro added to server_name
M3: CORS multi-origin support (SOC_CORS_ORIGIN=origin1,origin2)
This commit is contained in:
parent
8d87c453b0
commit
4ce94e9c77
4 changed files with 65 additions and 24 deletions
|
|
@ -4,6 +4,7 @@ import (
|
|||
"encoding/json"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// LoginRequest is the POST /api/auth/login body.
|
||||
|
|
@ -48,13 +49,23 @@ func HandleLogin(store *UserStore, secret []byte) http.HandlerFunc {
|
|||
return
|
||||
}
|
||||
|
||||
accessToken, err := NewAccessToken(user.Email, user.Role, secret, 0)
|
||||
accessToken, err := Sign(Claims{
|
||||
Sub: user.Email,
|
||||
Role: user.Role,
|
||||
TenantID: user.TenantID,
|
||||
Exp: time.Now().Add(15 * time.Minute).Unix(),
|
||||
}, secret)
|
||||
if err != nil {
|
||||
writeAuthError(w, http.StatusInternalServerError, "token generation failed")
|
||||
return
|
||||
}
|
||||
|
||||
refreshToken, err := NewRefreshToken(user.Email, user.Role, secret, 0)
|
||||
refreshToken, err := Sign(Claims{
|
||||
Sub: user.Email,
|
||||
Role: user.Role,
|
||||
TenantID: user.TenantID,
|
||||
Exp: time.Now().Add(7 * 24 * time.Hour).Unix(),
|
||||
}, secret)
|
||||
if err != nil {
|
||||
writeAuthError(w, http.StatusInternalServerError, "token generation failed")
|
||||
return
|
||||
|
|
@ -129,15 +140,29 @@ func HandleMe(store *UserStore) http.HandlerFunc {
|
|||
}
|
||||
}
|
||||
|
||||
// HandleListUsers returns all users (admin only).
|
||||
// HandleListUsers returns users scoped to the caller's tenant (admin only).
|
||||
// GET /api/auth/users
|
||||
func HandleListUsers(store *UserStore) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
users := store.ListUsers()
|
||||
claims := GetClaims(r.Context())
|
||||
if claims == nil || claims.Role != "admin" {
|
||||
writeAuthError(w, http.StatusForbidden, "admin role required")
|
||||
return
|
||||
}
|
||||
|
||||
// SEC: Filter users by tenant_id to prevent cross-tenant data leak
|
||||
allUsers := store.ListUsers()
|
||||
var filtered []*User
|
||||
for _, u := range allUsers {
|
||||
if u.TenantID == claims.TenantID {
|
||||
filtered = append(filtered, u)
|
||||
}
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]any{
|
||||
"users": users,
|
||||
"total": len(users),
|
||||
"users": filtered,
|
||||
"total": len(filtered),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -88,8 +88,10 @@ func HandleRegister(userStore *UserStore, tenantStore *TenantStore, jwtSecret []
|
|||
// Still return success — code is in DB, user can retry
|
||||
}
|
||||
} else {
|
||||
// Dev mode — include code in response
|
||||
resp["verification_code_dev"] = code
|
||||
// SEC: Never expose verification code in API response.
|
||||
// Log server-side only for development debugging.
|
||||
slog.Warn("email service not configured — verification code logged (dev only)",
|
||||
"email", req.Email, "code", code)
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
|
|
|||
|
|
@ -30,6 +30,7 @@ type User struct {
|
|||
Email string `json:"email"`
|
||||
DisplayName string `json:"display_name"`
|
||||
Role string `json:"role"` // admin, analyst, viewer
|
||||
TenantID string `json:"tenant_id,omitempty"`
|
||||
Active bool `json:"active"`
|
||||
EmailVerified bool `json:"email_verified"`
|
||||
PasswordHash string `json:"-"` // never serialized
|
||||
|
|
@ -121,12 +122,13 @@ func (s *UserStore) migrate() error {
|
|||
s.db.Exec(`ALTER TABLE users ADD COLUMN IF NOT EXISTS email_verified BOOLEAN NOT NULL DEFAULT false`)
|
||||
s.db.Exec(`ALTER TABLE users ADD COLUMN IF NOT EXISTS verify_token TEXT DEFAULT ''`)
|
||||
s.db.Exec(`ALTER TABLE users ADD COLUMN IF NOT EXISTS verify_expiry TIMESTAMPTZ`)
|
||||
s.db.Exec(`ALTER TABLE users ADD COLUMN IF NOT EXISTS tenant_id TEXT DEFAULT ''`)
|
||||
return nil
|
||||
}
|
||||
|
||||
// loadFromDB loads all users from DB into memory cache.
|
||||
func (s *UserStore) loadFromDB() {
|
||||
rows, err := s.db.Query(`SELECT id, email, display_name, role, active, password_hash, created_at, last_login_at FROM users`)
|
||||
rows, err := s.db.Query(`SELECT id, email, display_name, role, active, password_hash, created_at, last_login_at, COALESCE(tenant_id, '') FROM users`)
|
||||
if err != nil {
|
||||
slog.Error("load users from DB", "error", err)
|
||||
return
|
||||
|
|
@ -138,7 +140,7 @@ func (s *UserStore) loadFromDB() {
|
|||
for rows.Next() {
|
||||
var u User
|
||||
var lastLogin sql.NullTime
|
||||
if err := rows.Scan(&u.ID, &u.Email, &u.DisplayName, &u.Role, &u.Active, &u.PasswordHash, &u.CreatedAt, &lastLogin); err != nil {
|
||||
if err := rows.Scan(&u.ID, &u.Email, &u.DisplayName, &u.Role, &u.Active, &u.PasswordHash, &u.CreatedAt, &lastLogin, &u.TenantID); err != nil {
|
||||
slog.Warn("load user row scan", "error", err)
|
||||
continue
|
||||
}
|
||||
|
|
@ -156,8 +158,8 @@ func (s *UserStore) persistUser(u *User) {
|
|||
return
|
||||
}
|
||||
_, err := s.db.Exec(`
|
||||
INSERT INTO users (id, email, display_name, role, active, email_verified, password_hash, verify_token, verify_expiry, created_at, last_login_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
|
||||
INSERT INTO users (id, email, display_name, role, active, email_verified, password_hash, verify_token, verify_expiry, created_at, last_login_at, tenant_id)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)
|
||||
ON CONFLICT (id) DO UPDATE SET
|
||||
email = EXCLUDED.email,
|
||||
display_name = EXCLUDED.display_name,
|
||||
|
|
@ -167,8 +169,9 @@ func (s *UserStore) persistUser(u *User) {
|
|||
password_hash = EXCLUDED.password_hash,
|
||||
verify_token = EXCLUDED.verify_token,
|
||||
verify_expiry = EXCLUDED.verify_expiry,
|
||||
last_login_at = EXCLUDED.last_login_at`,
|
||||
u.ID, u.Email, u.DisplayName, u.Role, u.Active, u.EmailVerified, u.PasswordHash, u.VerifyToken, u.VerifyExpiry, u.CreatedAt, u.LastLoginAt,
|
||||
last_login_at = EXCLUDED.last_login_at,
|
||||
tenant_id = EXCLUDED.tenant_id`,
|
||||
u.ID, u.Email, u.DisplayName, u.Role, u.Active, u.EmailVerified, u.PasswordHash, u.VerifyToken, u.VerifyExpiry, u.CreatedAt, u.LastLoginAt, u.TenantID,
|
||||
)
|
||||
if err != nil {
|
||||
slog.Error("persist user", "email", u.Email, "error", err)
|
||||
|
|
|
|||
|
|
@ -3,29 +3,40 @@ package httpserver
|
|||
import (
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// corsAllowedOrigin returns the configured CORS origin.
|
||||
// Set SOC_CORS_ORIGIN in production (e.g. "https://soc.отражение.рус").
|
||||
// corsAllowedOrigins returns the configured CORS origins.
|
||||
// Set SOC_CORS_ORIGIN in production (e.g. "https://syntrex.pro,https://xn--80akacl3adqr.xn--p1acf").
|
||||
// Defaults to "*" for local development.
|
||||
func corsAllowedOrigin() string {
|
||||
func corsAllowedOrigins() []string {
|
||||
if v := os.Getenv("SOC_CORS_ORIGIN"); v != "" {
|
||||
return v
|
||||
parts := strings.Split(v, ",")
|
||||
for i := range parts {
|
||||
parts[i] = strings.TrimSpace(parts[i])
|
||||
}
|
||||
return parts
|
||||
}
|
||||
return "*"
|
||||
return []string{"*"}
|
||||
}
|
||||
|
||||
// corsMiddleware adds CORS headers with configurable origin.
|
||||
// Production: set SOC_CORS_ORIGIN=https://your-domain.com
|
||||
// Production: set SOC_CORS_ORIGIN=https://syntrex.pro,https://xn--80akacl3adqr.xn--p1acf
|
||||
func corsMiddleware(next http.Handler) http.Handler {
|
||||
origin := corsAllowedOrigin()
|
||||
origins := corsAllowedOrigins()
|
||||
allowAll := len(origins) == 1 && origins[0] == "*"
|
||||
allowedSet := make(map[string]bool, len(origins))
|
||||
for _, o := range origins {
|
||||
allowedSet[o] = true
|
||||
}
|
||||
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if origin == "*" {
|
||||
if allowAll {
|
||||
w.Header().Set("Access-Control-Allow-Origin", "*")
|
||||
} else {
|
||||
reqOrigin := r.Header.Get("Origin")
|
||||
if reqOrigin == origin {
|
||||
w.Header().Set("Access-Control-Allow-Origin", origin)
|
||||
if allowedSet[reqOrigin] {
|
||||
w.Header().Set("Access-Control-Allow-Origin", reqOrigin)
|
||||
w.Header().Set("Vary", "Origin")
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue