mirror of
https://github.com/syntrex-lab/gomcp.git
synced 2026-05-18 13:45:13 +02:00
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:
parent
1b028099be
commit
62ecc1c7a3
7 changed files with 76 additions and 35 deletions
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -50,10 +50,11 @@ func HandleLogin(store *UserStore, secret []byte) http.HandlerFunc {
|
||||||
}
|
}
|
||||||
|
|
||||||
accessToken, err := Sign(Claims{
|
accessToken, err := Sign(Claims{
|
||||||
Sub: user.Email,
|
Sub: user.Email,
|
||||||
Role: user.Role,
|
Role: user.Role,
|
||||||
TenantID: user.TenantID,
|
TenantID: user.TenantID,
|
||||||
Exp: time.Now().Add(15 * time.Minute).Unix(),
|
TokenType: "access",
|
||||||
|
Exp: time.Now().Add(15 * time.Minute).Unix(),
|
||||||
}, secret)
|
}, secret)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeAuthError(w, http.StatusInternalServerError, "token generation failed")
|
writeAuthError(w, http.StatusInternalServerError, "token generation failed")
|
||||||
|
|
@ -61,10 +62,11 @@ func HandleLogin(store *UserStore, secret []byte) http.HandlerFunc {
|
||||||
}
|
}
|
||||||
|
|
||||||
refreshToken, err := Sign(Claims{
|
refreshToken, err := Sign(Claims{
|
||||||
Sub: user.Email,
|
Sub: user.Email,
|
||||||
Role: user.Role,
|
Role: user.Role,
|
||||||
TenantID: user.TenantID,
|
TenantID: user.TenantID,
|
||||||
Exp: time.Now().Add(7 * 24 * time.Hour).Unix(),
|
TokenType: "refresh",
|
||||||
|
Exp: time.Now().Add(7 * 24 * time.Hour).Unix(),
|
||||||
}, secret)
|
}, secret)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeAuthError(w, http.StatusInternalServerError, "token generation failed")
|
writeAuthError(w, http.StatusInternalServerError, "token generation failed")
|
||||||
|
|
@ -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")
|
||||||
|
|
|
||||||
|
|
@ -16,19 +16,21 @@ import (
|
||||||
|
|
||||||
// Standard JWT errors.
|
// Standard JWT errors.
|
||||||
var (
|
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.
|
||||||
type Claims struct {
|
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
|
||||||
Exp int64 `json:"exp"` // Expiration (Unix timestamp)
|
TokenType string `json:"token_type,omitempty"` // "access" or "refresh"
|
||||||
Iat int64 `json:"iat"` // Issued at
|
Exp int64 `json:"exp"` // Expiration (Unix timestamp)
|
||||||
Iss string `json:"iss,omitempty"` // Issuer
|
Iat int64 `json:"iat"` // Issued at
|
||||||
|
Iss string `json:"iss,omitempty"` // Issuer
|
||||||
}
|
}
|
||||||
|
|
||||||
// IsExpired returns true if the token has expired.
|
// IsExpired returns true if the token has expired.
|
||||||
|
|
@ -101,9 +103,10 @@ func NewAccessToken(subject, role string, secret []byte, ttl time.Duration) (str
|
||||||
ttl = 15 * time.Minute
|
ttl = 15 * time.Minute
|
||||||
}
|
}
|
||||||
return Sign(Claims{
|
return Sign(Claims{
|
||||||
Sub: subject,
|
Sub: subject,
|
||||||
Role: role,
|
Role: role,
|
||||||
Exp: time.Now().Add(ttl).Unix(),
|
TokenType: "access",
|
||||||
|
Exp: time.Now().Add(ttl).Unix(),
|
||||||
}, secret)
|
}, secret)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -113,9 +116,10 @@ func NewRefreshToken(subject, role string, secret []byte, ttl time.Duration) (st
|
||||||
ttl = 7 * 24 * time.Hour
|
ttl = 7 * 24 * time.Hour
|
||||||
}
|
}
|
||||||
return Sign(Claims{
|
return Sign(Claims{
|
||||||
Sub: subject,
|
Sub: subject,
|
||||||
Role: role,
|
Role: role,
|
||||||
Exp: time.Now().Add(ttl).Unix(),
|
TokenType: "refresh",
|
||||||
|
Exp: time.Now().Add(ttl).Unix(),
|
||||||
}, secret)
|
}, secret)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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))
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
@ -141,10 +156,11 @@ func HandleVerifyEmail(userStore *UserStore, tenantStore *TenantStore, jwtSecret
|
||||||
|
|
||||||
// Issue JWT with tenant context
|
// Issue JWT with tenant context
|
||||||
accessToken, err := Sign(Claims{
|
accessToken, err := Sign(Claims{
|
||||||
Sub: user.Email,
|
Sub: user.Email,
|
||||||
Role: user.Role,
|
Role: user.Role,
|
||||||
TenantID: tenantID,
|
TenantID: tenantID,
|
||||||
Exp: time.Now().Add(15 * time.Minute).Unix(),
|
TokenType: "access",
|
||||||
|
Exp: time.Now().Add(15 * time.Minute).Unix(),
|
||||||
}, jwtSecret)
|
}, jwtSecret)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(w, `{"error":"failed to issue token"}`, http.StatusInternalServerError)
|
http.Error(w, `{"error":"failed to issue token"}`, http.StatusInternalServerError)
|
||||||
|
|
@ -152,10 +168,11 @@ func HandleVerifyEmail(userStore *UserStore, tenantStore *TenantStore, jwtSecret
|
||||||
}
|
}
|
||||||
|
|
||||||
refreshToken, _ := Sign(Claims{
|
refreshToken, _ := Sign(Claims{
|
||||||
Sub: user.Email,
|
Sub: user.Email,
|
||||||
Role: user.Role,
|
Role: user.Role,
|
||||||
TenantID: tenantID,
|
TenantID: tenantID,
|
||||||
Exp: time.Now().Add(7 * 24 * time.Hour).Unix(),
|
TokenType: "refresh",
|
||||||
|
Exp: time.Now().Add(7 * 24 * time.Hour).Unix(),
|
||||||
}, jwtSecret)
|
}, jwtSecret)
|
||||||
|
|
||||||
var tenant *Tenant
|
var tenant *Tenant
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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"},
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue