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)