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:
DmitrL-dev 2026-03-24 10:32:50 +10:00
parent 8d87c453b0
commit 4ce94e9c77
4 changed files with 65 additions and 24 deletions

View file

@ -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),
})
}
}

View file

@ -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")

View file

@ -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)

View file

@ -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")
}
}