Fix SEC-11: use json.Marshal for audit details
- Add internal/audit package with JSON() and JSONWithRoles() helpers
that use json.Marshal instead of fmt.Sprintf with %q
- Replace all fmt.Sprintf audit detail construction in:
- internal/server/server.go (10 occurrences)
- internal/ui/handlers_auth.go (4 occurrences)
- internal/grpcserver/auth.go (4 occurrences)
- Add tests for the helpers including edge-case Unicode,
null bytes, special characters, and odd argument counts
- Fix broken {"roles":%v} formatting that produced invalid JSON
Security: Audit log detail strings are now constructed via
json.Marshal, which correctly handles all Unicode edge cases
(U+2028, U+2029, null bytes, etc.) that fmt.Sprintf with %q
may mishandle. This prevents potential log injection or parsing
issues in audit event consumers.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -19,6 +19,7 @@ import (
|
||||
"net"
|
||||
"net/http"
|
||||
|
||||
"git.wntrmute.dev/kyle/mcias/internal/audit"
|
||||
"git.wntrmute.dev/kyle/mcias/internal/auth"
|
||||
"git.wntrmute.dev/kyle/mcias/internal/config"
|
||||
"git.wntrmute.dev/kyle/mcias/internal/crypto"
|
||||
@@ -214,7 +215,7 @@ func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
|
||||
// Security: return a generic error whether the user exists or not.
|
||||
// Always run a dummy Argon2 check to prevent timing-based user enumeration.
|
||||
_, _ = auth.VerifyPassword("dummy", auth.DummyHash())
|
||||
s.writeAudit(r, model.EventLoginFail, nil, nil, fmt.Sprintf(`{"username":%q,"reason":"unknown_user"}`, req.Username))
|
||||
s.writeAudit(r, model.EventLoginFail, nil, nil, audit.JSON("username", req.Username, "reason", "unknown_user"))
|
||||
middleware.WriteError(w, http.StatusUnauthorized, "invalid credentials", "unauthorized")
|
||||
return
|
||||
}
|
||||
@@ -315,7 +316,7 @@ func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
s.writeAudit(r, model.EventLoginOK, &acct.ID, nil, "")
|
||||
s.writeAudit(r, model.EventTokenIssued, &acct.ID, nil, fmt.Sprintf(`{"jti":%q}`, claims.JTI))
|
||||
s.writeAudit(r, model.EventTokenIssued, &acct.ID, nil, audit.JSON("jti", claims.JTI))
|
||||
|
||||
writeJSON(w, http.StatusOK, loginResponse{
|
||||
Token: tokenStr,
|
||||
@@ -330,7 +331,7 @@ func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) {
|
||||
middleware.WriteError(w, http.StatusInternalServerError, "internal error", "internal_error")
|
||||
return
|
||||
}
|
||||
s.writeAudit(r, model.EventTokenRevoked, nil, nil, fmt.Sprintf(`{"jti":%q,"reason":"logout"}`, claims.JTI))
|
||||
s.writeAudit(r, model.EventTokenRevoked, nil, nil, audit.JSON("jti", claims.JTI, "reason", "logout"))
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
@@ -376,7 +377,7 @@ func (s *Server) handleRenew(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
s.writeAudit(r, model.EventTokenRenewed, &acct.ID, nil, fmt.Sprintf(`{"old_jti":%q,"new_jti":%q}`, claims.JTI, newClaims.JTI))
|
||||
s.writeAudit(r, model.EventTokenRenewed, &acct.ID, nil, audit.JSON("old_jti", claims.JTI, "new_jti", newClaims.JTI))
|
||||
|
||||
writeJSON(w, http.StatusOK, loginResponse{
|
||||
Token: newTokenStr,
|
||||
@@ -482,7 +483,7 @@ func (s *Server) handleTokenIssue(w http.ResponseWriter, r *http.Request) {
|
||||
actorID = &a.ID
|
||||
}
|
||||
}
|
||||
s.writeAudit(r, model.EventTokenIssued, actorID, &acct.ID, fmt.Sprintf(`{"jti":%q}`, claims.JTI))
|
||||
s.writeAudit(r, model.EventTokenIssued, actorID, &acct.ID, audit.JSON("jti", claims.JTI))
|
||||
|
||||
writeJSON(w, http.StatusOK, loginResponse{
|
||||
Token: tokenStr,
|
||||
@@ -502,7 +503,7 @@ func (s *Server) handleTokenRevoke(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
s.writeAudit(r, model.EventTokenRevoked, nil, nil, fmt.Sprintf(`{"jti":%q}`, jti))
|
||||
s.writeAudit(r, model.EventTokenRevoked, nil, nil, audit.JSON("jti", jti))
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
@@ -597,7 +598,7 @@ func (s *Server) handleCreateAccount(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
s.writeAudit(r, model.EventAccountCreated, nil, &acct.ID, fmt.Sprintf(`{"username":%q}`, acct.Username))
|
||||
s.writeAudit(r, model.EventAccountCreated, nil, &acct.ID, audit.JSON("username", acct.Username))
|
||||
writeJSON(w, http.StatusCreated, accountToResponse(acct))
|
||||
}
|
||||
|
||||
@@ -712,7 +713,7 @@ func (s *Server) handleSetRoles(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
s.writeAudit(r, model.EventRoleGranted, grantedBy, &acct.ID, fmt.Sprintf(`{"roles":%v}`, req.Roles))
|
||||
s.writeAudit(r, model.EventRoleGranted, grantedBy, &acct.ID, audit.JSONWithRoles(req.Roles))
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
@@ -745,7 +746,7 @@ func (s *Server) handleGrantRole(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
s.writeAudit(r, model.EventRoleGranted, grantedBy, &acct.ID, fmt.Sprintf(`{"role":"%s"}`, req.Role))
|
||||
s.writeAudit(r, model.EventRoleGranted, grantedBy, &acct.ID, audit.JSON("role", req.Role))
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
@@ -774,7 +775,7 @@ func (s *Server) handleRevokeRole(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
s.writeAudit(r, model.EventRoleRevoked, revokedBy, &acct.ID, fmt.Sprintf(`{"role":"%s"}`, role))
|
||||
s.writeAudit(r, model.EventRoleRevoked, revokedBy, &acct.ID, audit.JSON("role", role))
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user