# 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