- Mark SEC-01 through SEC-12 as fixed with fix descriptions - Update executive summary to reflect full remediation - Move original finding descriptions to collapsible section - Replace remediation priority table with status section Security: documentation-only change, no code modifications Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
10 KiB
MCIAS Security Audit Report
Date: 2026-03-14 (updated — all findings remediated) Original audit date: 2026-03-13 Auditor role: Penetration tester (code review + live instance probing) Scope: Full codebase and running instance at localhost:8443 — authentication flows, token lifecycle, cryptography, database layer, REST/gRPC/UI servers, authorization, headers, and operational security. Methodology: Static code analysis, live HTTP probing, architectural review.
Executive Summary
MCIAS has a strong security posture. All findings from three audit rounds (CRIT-01/CRIT-02, DEF-01 through DEF-10, and SEC-01 through SEC-12) have been remediated. The cryptographic foundations are sound, JWT validation is correct, SQL injection is not possible, XSS is prevented by Go's html/template auto-escaping, and CSRF protection is well-implemented.
All findings from this audit have been remediated. See the remediation table below for details.
Remediated Findings (SEC-01 through SEC-12)
All findings from this audit have been remediated. The original descriptions are preserved below for reference.
| ID | Severity | Finding | Status |
|---|---|---|---|
| SEC-01 | Medium | TOTP enrollment did not require password re-authentication | Fixed — both REST and gRPC now require current password, with lockout counter on failure |
| SEC-02 | Medium | Account lockout response leaked account existence | Fixed — locked accounts now return same 401 "invalid credentials" as wrong password, with dummy Argon2 for timing uniformity |
| SEC-03 | Medium | Token renewal had no proximity or re-auth check | Fixed — renewal requires token to have consumed ≥50% of its lifetime |
| SEC-04 | Low-Med | REST API responses lacked security headers | Fixed — globalSecurityHeaders middleware applies X-Content-Type-Options, HSTS, and Cache-Control: no-store to all routes |
| SEC-05 | Low | No request body size limit on REST API | Fixed — decodeJSON wraps body with http.MaxBytesReader (1 MiB); max password length enforced |
| SEC-06 | Low | gRPC rate limiter ignored TrustedProxy | Fixed — grpcClientIP extracts real client IP via metadata when peer matches trusted proxy |
| SEC-07 | Low | Static file directory listing enabled | Fixed — noDirListing wrapper returns 404 for directory requests |
| SEC-08 | Low | System token issuance was not atomic | Fixed — IssueSystemToken wraps revoke+track in a single SQLite transaction |
| SEC-09 | Info | Navigation bar exposed admin UI structure to non-admin users | Fixed — nav links conditionally rendered with {{if .IsAdmin}} |
| SEC-10 | Info | No Permissions-Policy header |
Fixed — Permissions-Policy: camera=(), microphone=(), geolocation=(), payment=() added |
| SEC-11 | Info | Audit log details used fmt.Sprintf instead of json.Marshal |
Fixed — audit.JSON and audit.JSONWithRoles helpers use json.Marshal |
| SEC-12 | Info | Default token expiry was 30 days | Fixed — default reduced to 7 days (168h); renewal proximity check (SEC-03) further limits exposure |
Original finding descriptions (click to expand)
SEC-01 — TOTP Enrollment Does Not Require Password Re-authentication (Medium)
Files: internal/server/server.go, internal/grpcserver/auth.go
POST /v1/auth/totp/enroll and the gRPC EnrollTOTP RPC originally required only a valid JWT — no password confirmation. If an attacker stole a session token, they could enroll TOTP on the victim's account.
Fix: Both endpoints now require the current password, with lockout counter incremented on failure.
SEC-02 — Account Lockout Response Leaks Account Existence (Medium)
Locked accounts originally returned HTTP 429 / gRPC ResourceExhausted with "account temporarily locked", distinguishable from the HTTP 401 "invalid credentials" returned for wrong passwords.
Fix: All login paths now return the same "invalid credentials" response for locked accounts, with dummy Argon2 to maintain timing uniformity.
SEC-03 — Token Renewal Has No Proximity or Re-auth Check (Medium)
POST /v1/auth/renew originally accepted any valid token regardless of remaining lifetime.
Fix: Renewal now requires the token to have consumed ≥50% of its lifetime before it can be renewed.
SEC-04 — REST API Responses Lack Security Headers (Low-Medium)
API endpoints originally returned only Content-Type — no Cache-Control, X-Content-Type-Options, or HSTS.
Fix: globalSecurityHeaders middleware applies these headers to all routes (API and UI).
SEC-05 — No Request Body Size Limit on REST API Endpoints (Low)
decodeJSON originally read from r.Body without any size limit.
Fix: http.MaxBytesReader with 1 MiB limit added to decodeJSON. Maximum password length also enforced.
SEC-06 — gRPC Rate Limiter Ignores TrustedProxy (Low)
The gRPC rate limiter originally used peer.FromContext directly, always getting the proxy IP behind a reverse proxy.
Fix: grpcClientIP now reads from gRPC metadata headers when the peer matches the trusted proxy.
SEC-07 — Static File Directory Listing Enabled (Low)
http.FileServerFS served directory listings by default.
Fix: noDirListing wrapper returns 404 for directory requests.
SEC-08 — System Token Issuance Is Not Atomic (Low)
handleTokenIssue originally performed three sequential non-transactional operations.
Fix: IssueSystemToken wraps all operations in a single SQLite transaction.
SEC-09 — Navigation Bar Exposes Admin UI Structure to Non-Admin Users (Informational)
Nav links were rendered for all authenticated users.
Fix: Admin nav links wrapped in {{if .IsAdmin}} conditional.
SEC-10 — No Permissions-Policy Header (Informational)
The security headers middleware did not include Permissions-Policy.
Fix: Permissions-Policy: camera=(), microphone=(), geolocation=(), payment=() added.
SEC-11 — Audit Log Details Use fmt.Sprintf Instead of json.Marshal (Informational)
Audit details were constructed with fmt.Sprintf and %q, which is fragile for JSON.
Fix: audit.JSON and audit.JSONWithRoles helpers use json.Marshal.
SEC-12 — Default Token Expiry Is 30 Days (Informational / Configuration)
Default expiry was 720h (30 days).
Fix: Reduced to 168h (7 days). Combined with SEC-03's renewal proximity check, exposure window is significantly reduced.
Previously Remediated Findings (CRIT/DEF series)
The following findings from the initial audit (2026-03-12) were confirmed fixed in the 2026-03-13 audit:
| ID | Finding | Status |
|---|---|---|
| CRIT-01 | TOTP replay attack — no counter tracking | Fixed — CheckAndUpdateTOTPCounter with atomic SQL, migration 000007 |
| CRIT-02 | gRPC EnrollTOTP called SetTOTP instead of StorePendingTOTP |
Fixed — now calls StorePendingTOTP |
| DEF-01 | No rate limiting on UI login | Fixed — loginRateLimit applied to POST /login |
| DEF-02 | pendingLogins map had no expiry cleanup |
Fixed — cleanupPendingLogins goroutine runs every 5 minutes |
| DEF-03 | Rate limiter ignored X-Forwarded-For |
Fixed — ClientIP() respects TrustedProxy config |
| DEF-04 | Missing nbf claim on tokens |
Fixed — NotBefore: jwt.NewNumericDate(now) added |
| DEF-05 | No max token expiry ceiling | Fixed — upper bounds enforced in config validation |
| DEF-06 | Incorrect case-sensitivity comment | Fixed — comment corrected |
| DEF-07 | SQLite synchronous=NORMAL |
Fixed — changed to PRAGMA synchronous=FULL |
| DEF-08 | gRPC counted TOTP-missing as failure | Fixed — no longer increments lockout counter |
| DEF-09 | Security headers missing on docs endpoints | Fixed — docsSecurityHeaders wrapper added |
| DEF-10 | Role strings not validated | Fixed — model.ValidateRole() with compile-time allowlist |
Positive Findings (Preserved)
These implementation details are exemplary and should be maintained:
| Area | Detail |
|---|---|
| JWT alg confusion | ValidateToken enforces alg=EdDSA in the key function before signature verification |
| Constant-time operations | crypto/subtle.ConstantTimeCompare for password hashes, CSRF tokens; all three TOTP windows evaluated without early exit |
| Timing uniformity | Dummy Argon2 via sync.Once for unknown/inactive users on all login paths |
| Token revocation | Fail-closed: untracked tokens are rejected, not silently accepted |
| Token renewal atomicity | RenewToken wraps revoke+track in a single SQLite transaction |
| TOTP replay prevention | Counter-based replay detection with atomic SQL UPDATE/WHERE |
| TOTP nonce design | 128-bit single-use server-side nonce; password never retransmitted in step 2 |
| CSRF protection | HMAC-SHA256 double-submit cookie, domain-separated key derivation, SameSite=Strict, constant-time validation |
| Credential exclusion | json:"-" on all credential fields; password hash never in API responses |
| Security headers (UI) | CSP (no unsafe-inline), X-Content-Type-Options, X-Frame-Options DENY, HSTS 2yr, Referrer-Policy no-referrer |
| Cookie hardening | HttpOnly + Secure + SameSite=Strict on session cookie |
| Account lockout | 10-attempt rolling window, checked before Argon2, with timing-safe dummy hash |
| Argon2id parameters | Config validator enforces OWASP 2023 minimums; rejects weakening |
| SQL injection | Zero string concatenation — all queries parameterized |
| Input validation | Username regex + length, password min length, account type enum, role allowlist, JSON strict decoder |
| Audit logging | Append-only, no delete path, credentials never logged, actor/target/IP captured |
| Master key hygiene | Env var cleared after read, key zeroed on shutdown, AES-256-GCM at rest |
| TLS | MinVersion TLS 1.2, X25519 preferred, no plaintext listener, read/write/idle timeouts set |
Remediation Status
All findings remediated. No open items remain. Next audit should focus on:
- Any new features added since 2026-03-14
- Dependency updates and CVE review
- Live penetration testing of remediated endpoints