sec: fix C4/C5/M4/M5 + domain migration to syntrex.pro

C4: Remove localhost:9100 fallback from 27 dashboard files (use relative URLs)
C5: JWT token_type differentiation (access vs refresh) - middleware rejects refresh as Bearer
M4: Server-side registration gate via SOC_REGISTRATION_OPEN env var
M5: HTML tag stripping on name/org_name fields (XSS prevention)

Domain migration:
- users.go: admin@syntrex.pro
- zerotrust.go: SPIFFE trust domain
- sbom.go: namespace URL
- .env.production.example: all URLs updated
- identity_test.go: test email
This commit is contained in:
DmitrL-dev 2026-03-24 11:49:33 +10:00
parent 1b028099be
commit 62ecc1c7a3
7 changed files with 76 additions and 35 deletions

View file

@ -119,7 +119,7 @@ func TestStoreRegisterAndGet(t *testing.T) {
agent := &AgentIdentity{ agent := &AgentIdentity{
AgentID: "agent-01", AgentID: "agent-01",
AgentName: "Task Manager", AgentName: "Task Manager",
CreatedBy: "admin@xn--80akacl3adqr.xn--p1acf", CreatedBy: "admin@syntrex.pro",
AgentType: AgentSupervised, AgentType: AgentSupervised,
} }
if err := s.Register(agent); err != nil { if err := s.Register(agent); err != nil {

View file

@ -53,6 +53,7 @@ func HandleLogin(store *UserStore, secret []byte) http.HandlerFunc {
Sub: user.Email, Sub: user.Email,
Role: user.Role, Role: user.Role,
TenantID: user.TenantID, TenantID: user.TenantID,
TokenType: "access",
Exp: time.Now().Add(15 * time.Minute).Unix(), Exp: time.Now().Add(15 * time.Minute).Unix(),
}, secret) }, secret)
if err != nil { if err != nil {
@ -64,6 +65,7 @@ func HandleLogin(store *UserStore, secret []byte) http.HandlerFunc {
Sub: user.Email, Sub: user.Email,
Role: user.Role, Role: user.Role,
TenantID: user.TenantID, TenantID: user.TenantID,
TokenType: "refresh",
Exp: time.Now().Add(7 * 24 * time.Hour).Unix(), Exp: time.Now().Add(7 * 24 * time.Hour).Unix(),
}, secret) }, secret)
if err != nil { if err != nil {
@ -101,6 +103,12 @@ func HandleRefresh(secret []byte) http.HandlerFunc {
return return
} }
// SEC-C5: Only accept refresh tokens for token renewal
if claims.TokenType != "refresh" {
writeAuthError(w, http.StatusUnauthorized, "invalid token type — refresh token required")
return
}
accessToken, err := NewAccessToken(claims.Sub, claims.Role, secret, 0) accessToken, err := NewAccessToken(claims.Sub, claims.Role, secret, 0)
if err != nil { if err != nil {
writeAuthError(w, http.StatusInternalServerError, "token generation failed") writeAuthError(w, http.StatusInternalServerError, "token generation failed")

View file

@ -19,6 +19,7 @@ var (
ErrInvalidToken = errors.New("auth: invalid token") ErrInvalidToken = errors.New("auth: invalid token")
ErrExpiredToken = errors.New("auth: token expired") ErrExpiredToken = errors.New("auth: token expired")
ErrInvalidSecret = errors.New("auth: secret too short (min 32 bytes)") ErrInvalidSecret = errors.New("auth: secret too short (min 32 bytes)")
ErrWrongTokenType = errors.New("auth: wrong token type")
) )
// Claims represents JWT payload. // Claims represents JWT payload.
@ -26,6 +27,7 @@ type Claims struct {
Sub string `json:"sub"` // Subject (username or user ID) Sub string `json:"sub"` // Subject (username or user ID)
Role string `json:"role"` // RBAC role: admin, operator, analyst, viewer Role string `json:"role"` // RBAC role: admin, operator, analyst, viewer
TenantID string `json:"tenant_id,omitempty"` // Multi-tenant isolation TenantID string `json:"tenant_id,omitempty"` // Multi-tenant isolation
TokenType string `json:"token_type,omitempty"` // "access" or "refresh"
Exp int64 `json:"exp"` // Expiration (Unix timestamp) Exp int64 `json:"exp"` // Expiration (Unix timestamp)
Iat int64 `json:"iat"` // Issued at Iat int64 `json:"iat"` // Issued at
Iss string `json:"iss,omitempty"` // Issuer Iss string `json:"iss,omitempty"` // Issuer
@ -103,6 +105,7 @@ func NewAccessToken(subject, role string, secret []byte, ttl time.Duration) (str
return Sign(Claims{ return Sign(Claims{
Sub: subject, Sub: subject,
Role: role, Role: role,
TokenType: "access",
Exp: time.Now().Add(ttl).Unix(), Exp: time.Now().Add(ttl).Unix(),
}, secret) }, secret)
} }
@ -115,6 +118,7 @@ func NewRefreshToken(subject, role string, secret []byte, ttl time.Duration) (st
return Sign(Claims{ return Sign(Claims{
Sub: subject, Sub: subject,
Role: role, Role: role,
TokenType: "refresh",
Exp: time.Now().Add(ttl).Unix(), Exp: time.Now().Add(ttl).Unix(),
}, secret) }, secret)
} }

View file

@ -78,6 +78,18 @@ func (m *JWTMiddleware) Middleware(next http.Handler) http.Handler {
return return
} }
// SEC-C5: Reject refresh tokens used as access tokens.
// Only "access" tokens (or legacy tokens without type) can access protected routes.
if claims.TokenType == "refresh" {
slog.Warn("refresh token used as access token",
"sub", claims.Sub,
"path", r.URL.Path,
"remote", r.RemoteAddr,
)
writeAuthError(w, http.StatusUnauthorized, "access token required — refresh tokens cannot be used for API access")
return
}
// Inject claims into context for downstream handlers. // Inject claims into context for downstream handlers.
ctx := context.WithValue(r.Context(), claimsKey, claims) ctx := context.WithValue(r.Context(), claimsKey, claims)
next.ServeHTTP(w, r.WithContext(ctx)) next.ServeHTTP(w, r.WithContext(ctx))

View file

@ -4,9 +4,14 @@ import (
"encoding/json" "encoding/json"
"log/slog" "log/slog"
"net/http" "net/http"
"os"
"regexp"
"time" "time"
) )
// htmlTagRegex strips HTML/script tags from user input (M5 XSS prevention).
var htmlTagRegex = regexp.MustCompile(`<[^>]*>`)
// EmailSendFunc is a callback for sending verification emails. // EmailSendFunc is a callback for sending verification emails.
// Signature: func(toEmail, userName, code string) error // Signature: func(toEmail, userName, code string) error
type EmailSendFunc func(toEmail, userName, code string) error type EmailSendFunc func(toEmail, userName, code string) error
@ -17,6 +22,12 @@ type EmailSendFunc func(toEmail, userName, code string) error
// If emailFn is nil, verification code is returned in response (dev mode). // If emailFn is nil, verification code is returned in response (dev mode).
func HandleRegister(userStore *UserStore, tenantStore *TenantStore, jwtSecret []byte, emailFn EmailSendFunc) http.HandlerFunc { func HandleRegister(userStore *UserStore, tenantStore *TenantStore, jwtSecret []byte, emailFn EmailSendFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
// SEC-M4: Server-side registration gate
if os.Getenv("SOC_REGISTRATION_OPEN") != "true" {
http.Error(w, `{"error":"registration is closed — contact admin for an invitation"}`, http.StatusForbidden)
return
}
var req struct { var req struct {
Email string `json:"email"` Email string `json:"email"`
Password string `json:"password"` Password string `json:"password"`
@ -40,6 +51,10 @@ func HandleRegister(userStore *UserStore, tenantStore *TenantStore, jwtSecret []
req.Name = req.Email req.Name = req.Email
} }
// SEC-M5: Strip HTML tags from user input to prevent stored XSS
req.Name = htmlTagRegex.ReplaceAllString(req.Name, "")
req.OrgName = htmlTagRegex.ReplaceAllString(req.OrgName, "")
// Create user first (admin of new tenant) // Create user first (admin of new tenant)
user, err := userStore.CreateUser(req.Email, req.Name, req.Password, "admin") user, err := userStore.CreateUser(req.Email, req.Name, req.Password, "admin")
if err != nil { if err != nil {
@ -144,6 +159,7 @@ func HandleVerifyEmail(userStore *UserStore, tenantStore *TenantStore, jwtSecret
Sub: user.Email, Sub: user.Email,
Role: user.Role, Role: user.Role,
TenantID: tenantID, TenantID: tenantID,
TokenType: "access",
Exp: time.Now().Add(15 * time.Minute).Unix(), Exp: time.Now().Add(15 * time.Minute).Unix(),
}, jwtSecret) }, jwtSecret)
if err != nil { if err != nil {
@ -155,6 +171,7 @@ func HandleVerifyEmail(userStore *UserStore, tenantStore *TenantStore, jwtSecret
Sub: user.Email, Sub: user.Email,
Role: user.Role, Role: user.Role,
TenantID: tenantID, TenantID: tenantID,
TokenType: "refresh",
Exp: time.Now().Add(7 * 24 * time.Hour).Unix(), Exp: time.Now().Add(7 * 24 * time.Hour).Unix(),
}, jwtSecret) }, jwtSecret)

View file

@ -64,11 +64,11 @@ func NewUserStore(db ...*sql.DB) *UserStore {
} }
// Ensure default admin exists // Ensure default admin exists
if _, err := s.GetByEmail("admin@xn--80akacl3adqr.xn--p1acf"); err != nil { if _, err := s.GetByEmail("admin@syntrex.pro"); err != nil {
hash, _ := bcrypt.GenerateFromPassword([]byte("syntrex-admin-2026"), bcrypt.DefaultCost) hash, _ := bcrypt.GenerateFromPassword([]byte("syntrex-admin-2026"), bcrypt.DefaultCost)
admin := &User{ admin := &User{
ID: generateID("usr"), ID: generateID("usr"),
Email: "admin@xn--80akacl3adqr.xn--p1acf", Email: "admin@syntrex.pro",
DisplayName: "Administrator", DisplayName: "Administrator",
Role: "admin", Role: "admin",
Active: true, Active: true,

View file

@ -101,7 +101,7 @@ func (g *Generator) GenerateSPDX() (*SPDXDocument, error) {
DataLicense: "CC0-1.0", DataLicense: "CC0-1.0",
SPDXID: "SPDXRef-DOCUMENT", SPDXID: "SPDXRef-DOCUMENT",
DocumentName: fmt.Sprintf("%s-%s", g.productName, g.version), DocumentName: fmt.Sprintf("%s-%s", g.productName, g.version),
Namespace: fmt.Sprintf("https://sentinel.xn--80akacl3adqr.xn--p1acf/spdx/%s/%s", g.productName, g.version), Namespace: fmt.Sprintf("https://sentinel.syntrex.pro/spdx/%s/%s", g.productName, g.version),
CreationInfo: CreationInfo{ CreationInfo: CreationInfo{
Created: time.Now().UTC().Format(time.RFC3339), Created: time.Now().UTC().Format(time.RFC3339),
Creators: []string{"Tool: sentinel-sbom-gen", "Organization: Syntrex"}, Creators: []string{"Tool: sentinel-sbom-gen", "Organization: Syntrex"},