mirror of
https://github.com/azaion/admin.git
synced 2026-06-21 18:01:10 +00:00
1bdbe8c96d
Step 14 (Security Audit) output for cycle 2. Verdict: FAIL — 2 Critical (F-INFRA-1, F-INFRA-2) + 4 High (F-INFRA-3, F-INFRA-4, F-AUTH-1, F-AUTH-2) block deploy. 13 cycle-2 findings total; cycle-1 closures confirmed for F-6, F-7, F-8, F-13, A09. Files: - security_report_cycle2.md (delta on cycle-1 report; FAIL verdict, tracker follow-ups filed as AZ-552..AZ-557 + 9 deferred Medium/Low) - owasp_review_cycle2.md (A01..A09 delta; 2 FAIL / 2 PASS_W_W / 5 PASS) - static_analysis_cycle2.md (F-AUTH-1..9 with locations + remediation) - infrastructure_review_cycle2.md (F-INFRA-1..6 with locations + remediation) - dependency_scan_cycle2.md (no new CVEs; cycle-1 deprecations re-flagged) Cycle-1 reports remain authoritative for non-cycle-2 surface. Co-authored-by: Cursor <cursoragent@cursor.com>
12 KiB
12 KiB
Static Analysis — Cycle 2 (Auth Modernization, AZ-531..AZ-538)
Date: 2026-05-14
Scope: cycle-2 source files + cross-cutting changes; the cycle-1 surface was already audited in static_analysis.md.
Files reviewed
- New:
Azaion.Services/{Security,RefreshTokenService,SessionService,MfaService,MissionTokenService,JwtSigningKeyProvider,AuditLog}.cs - Modified:
Azaion.Services/{AuthService,UserService}.cs,Azaion.AdminApi/{Program,BusinessExceptionHandler}.cs - Common:
Azaion.Common/{BusinessException,Configs/AuthConfig,Configs/JwtConfig,Configs/SessionConfig,Entities/Session,Entities/AuditEvent,Entities/User,Database/AzaionDb,Database/AzaionDbShemaHolder,Requests/*}.cs - DB:
env/db/{07_auth_lockout_and_audit,08_sessions,09_sessions_logout_and_mission,10_users_mfa}.sql
Findings — cycle 2 (severity-ranked)
F-2026Q2-AUTH-1: User enumeration via login error codes (HIGH)
- Location:
Azaion.Services/UserService.cs:120-148,Azaion.Common/BusinessException.cs:33-52. - Description:
ValidateUserreturnsBusinessException(NoEmailFound)(code 10, "No such email found.") when the email doesn't exist, andBusinessException(WrongPassword)(code 30, "Passwords do not match.") when the email exists but the password is wrong. The two codes (and human-readable messages) are distinguishable to any client. This is a textbook user-enumeration leak. - Status: pre-existing in cycle 1 (not introduced by cycle 2) but the threat model is materially worse now — cycle 2 added per-account lockout that an attacker can selectively trigger only on real accounts.
- Impact: aids credential stuffing (attacker pre-filters their email list), aids targeted phishing, allows opportunistic DoS-via-lockout against known-real accounts.
- Remediation: collapse both branches to a single generic error — either
WrongPasswordfor both, or a newInvalidCredentialscode. Cycle-2 audit recommends: introduceInvalidCredentials(code 70 or similar) and migrate over a deprecation window. - Cross-ref:
_docs/05_security/static_analysis.mdalready captured this for cycle 1 but as Medium; upgraded to High for cycle 2.
F-2026Q2-AUTH-2: MFA brute force not rate-limited per account (HIGH)
- Location:
Azaion.Services/MfaService.cs:247-278. - Description:
VerifyForLoginrecordsaudit_events.mfa_login_failedon a wrong code but does NOT incrementusers.failed_login_countorusers.lockout_until. The per-account sliding-window check inUserService.ValidateUseronly countsevent_type='login_failed'rows, notmfa_login_failed. The per-IP rate limiter on/login/mfais the only throttle. - Impact: an attacker with a leaked password can brute-force the 6-digit TOTP from rotating IPs. ~1M tries per minute saturate the 30s TOTP step before lockout; with rotation across IPs the per-IP limit is bypassed. The account never locks.
- Remediation: either (a) make
CountRecentFailedLoginsalso countmfa_login_failedevents, or (b) callRegisterFailedLogin-equivalent insideVerifyForLoginsofailed_login_countincrements. Recommend (b) because it surfaces the MFA failures into the existing lockout mechanism.
F-2026Q2-AUTH-3: Disabled-account leak via auth ordering (MEDIUM)
-
Location:
Azaion.Services/UserService.cs:144-152. -
Description:
ValidateUserchecksIsEnabledAFTER password verification:var verify = Security.VerifyPassword(...); if (!verify.Valid) throw WrongPassword; if (!user.IsEnabled) throw UserDisabled;An attacker who knows the password of a disabled account learns the password is correct (sees
UserDisabledinstead ofWrongPassword). -
Impact: confirms a credential pair is valid even if the account is disabled — useful for credential-replay against re-enabled accounts and for tracking which disabled accounts to keep targeting.
-
Remediation: check
IsEnabledBEFORE the password verify, return the same generic error as F-AUTH-1.
F-2026Q2-AUTH-4: MFA management endpoints not rate-limited (MEDIUM)
- Location:
Azaion.AdminApi/Program.cs:452-481. - Description:
/users/me/mfa/{enroll,confirm,disable}are mounted withRequireAuthorization()but NOTRequireRateLimiting(LoginPerIpPolicy). Only/loginand/login/mfahave the per-IP limit attached. An attacker with a stolen access token can brute-force the disable-confirm code unbounded. - Impact: stolen-access-token escalation path that ends in MFA being silently turned off; account is then a single-factor target.
- Remediation: add a dedicated
mfa-per-accountpolicy (or attachLoginPerIpPolicy) to all three/users/me/mfa/*endpoints.
F-2026Q2-AUTH-5: Mission tokens missing amr claim (MEDIUM)
- Location:
Azaion.Services/MissionTokenService.cs:103-111. - Description: The mission token claim list omits
amr. The cycle-2 design intent (per_docs/02_document/system-flows.mdF13 and the// TODO (AZ-534)comment in Program.cs:489) isamr=["pwd","mfa"]. Verifiers downstream cannot enforce "missions require MFA-authenticated pilots" because the claim is absent. - Impact: a pilot logged in with
pwdonly can issue mission tokens that look identical to MFA-pilot ones to verifiers. The intended MFA-step-up gating is unenforceable. - Remediation: read the pilot's current access-token AMR (or look it up via
sid→Session.MfaAuthenticated) and stampamr=["pwd"]oramr=["pwd","mfa"]accordingly. Reject if policy requires MFA and the pilot has none.
F-2026Q2-AUTH-6: Mission token issuance does not auto-revoke prior aircraft missions (MEDIUM)
- Location:
Azaion.Services/MissionTokenService.cs:46-87. - Description:
Issueinserts a new mission session row but does NOT callSessionService.RevokeMissionsForAircraft(aircraftId)first. Auto-revoke happens only when the aircraft itself logs in (Program.cs:347-349). Consequence: an admin can issue two concurrent mission tokens for the same aircraft and both remain valid until the aircraft re-connects. - Impact: violates AZ-533 AC-4. Two pilots can hold parallel valid mission tokens for the same aircraft, breaking the "one mission per aircraft at a time" invariant. Audit / forensic confusion.
- Remediation: call
SessionService.RevokeMissionsForAircraft(request.AircraftId, ct)immediately before thedb.InsertAsync(new Session ...)for the mission row. - Doc impact: also fix the cycle-2 docs (
system-flows.mdF13,components/03_auth_and_security/description.md) which currently claim this auto-revoke happens at issuance.
F-2026Q2-AUTH-7: Silent fallback to first PEM if ActiveKid is unset (MEDIUM)
- Location:
Azaion.Services/JwtSigningKeyProvider.cs:73-86. - Description: When
JwtConfig.ActiveKidis not configured, the provider falls back to the alphabetically-first PEM and only logs aLogInformation. Adding a new PEM whose filename sorts earlier than the current active kid silently changes the signing key. - Impact: rotation accidents — operator drops
kid-2026-04-aaa.pemthinking it's a side-by-side key, but it becomes the new signer because of name ordering. Tokens minted under the wrong kid; verifiers may reject (depending on JWKS cache state). - Remediation: fail-fast when
ActiveKidis unset and more than one PEM exists. If exactly one PEM exists, accept it but log atWarning(notInformation).
F-2026Q2-AUTH-8: Health-readiness leaks DB exception type to anonymous callers (LOW)
- Location:
Azaion.AdminApi/Program.cs:261-280. - Description:
/health/readyreturns{ status: "not-ready", reason: ex.GetType().Name }to any anonymous caller on DB failure. This leaks the underlying exception type (e.g.,NpgsqlException,TimeoutException). - Impact: minor info leak; no direct exploit but useful for fingerprinting in a compromise chain.
- Remediation: log the exception type internally; return only
{ status: "not-ready" }externally. - Mitigating control: per Program.cs line 252 comment,
/health/*is "expected to be exposed only on the management interface (not via the public Nginx vhost)". If that nginx config is verified, drop this finding.
F-2026Q2-AUTH-9: User cache TTL holds lockout / MFA-state for 4h across instances (MEDIUM, deployment-coupled)
- Location:
Azaion.Services/UserService.GetByEmail(cycle 1) + cycle-2 surface that consumesUser.LockoutUntil/MfaEnabled. - Description:
GetByEmailreturns a 4-hour-cachedUserthat includesLockoutUntil,MfaEnabled,MfaSecret.cache.Invalidateis local to the process. In a multi-instance deploy, instance A's lockout / MFA-disable / password-rehash is invisible to instance B for up to 4 h. - Impact: a locked account can keep authenticating against a cold instance; an MFA-disabled user can keep being challenged for MFA against a stale instance; lazy-rehash race window grows.
- Mitigating control: per
architecture.md§3, deployment is "self-hosted Linux server (single container deployment via deploy.cmd)". Single-instance = no cross-instance staleness today. Risk surfaces if/when the system scales horizontally. - Remediation: bypass the cache for the security-critical fields (
LockoutUntil,FailedLoginCount,MfaEnabled,MfaSecret,IsEnabled) by reading them directly inValidateUserandMfaService, OR move to a shared-cache (Redis) before horizontal scaling.
F-2026Q2-DOCS-1: Documentation drift surfaced during audit (LOW)
- Location:
_docs/02_document/components/03_auth_and_security/description.md,_docs/02_document/data_model.md§Configuration POCOs,_docs/02_document/modules/services_mfa_service.md. - Description: cycle-2 documentation drifted from the actual code on:
AuthConfig.PasswordHashing { TimeCost, MemoryCostKiB, Parallelism }does NOT exist — Argon2id parameters are hardcoded constants inSecurity.cs:16-23.LockoutOptionshas fieldsMaxAttempts(notConsecutiveFailureThreshold) andDurationSeconds(notLockoutSeconds).RateLimitOptionshasPerAccountPermitLimit(notPerAccountFailedThreshold).- Recovery codes are SHA-256-hashed (high-entropy server-issued secrets), not Argon2id as docs claimed.
- Impact: misleads operators when tuning Argon2id; misleads ops when wiring
appsettings.json. - Remediation: docs-only fix in
Step 13follow-up — to be addressed in the next docs sync. Tracked here so it's not forgotten.
Vulnerability patterns scan (clean)
| Pattern | Result |
|---|---|
| SQL injection (string-interpolated SQL) | Clean — only db.Execute("UPDATE ... WHERE id=@id", DataParameter) and SELECT 1. All cycle-2 queries are LinqToDB expression-based. |
Command injection / Process.Start / subprocess |
Clean — no shell-out from C# in the cycle-2 surface. |
| XSS | N/A — JSON-only API; responses serialized via Newtonsoft.Json / Results.Ok. |
| Hardcoded secrets / credentials | Clean — no plaintext credentials in Azaion.{AdminApi,Services,Common}/. The seed admin@azaion.com/uploader@azaion.com rows in env/db/02_structure.sql use SHA-384 hashes; cycle 2 lazy-migrates these on first login. |
| Missing auth on endpoints | Clean — verified each MapGet/MapPost/MapPut/MapDelete carries either RequireAuthorization, RequireAuthorization(<policy>), or explicit AllowAnyonymous with documented justification. |
| Insecure deserialization | Clean — Newtonsoft.Json only deserializes typed DTOs; System.Text.Json deserializes the recovery-code array; jsonb mapping for MfaRecoveryCodes uses DataType.BinaryJson. |
| Sensitive data in logs | Clean — passwords, hashes, secrets, refresh tokens, recovery codes are not logged. BusinessExceptionHandler logs the exception type + message but no payload. |
| Cryptographic regressions | Clean — Argon2id (RFC 9106) for passwords; SHA-256 for high-entropy refresh tokens + recovery codes; ES256 for JWTs; SHA-256 of UTF-8 bytes for refresh-token hashing. No MD5 / SHA-1 / DES anywhere. |
Self-verification
- All cycle-2 source directories scanned
- Each finding has file path and line numbers
- No false positives from test files (test files were excluded except where they confirm a finding)
- Vulnerability patterns swept (SQLi, cmd injection, secrets, missing auth, insecure deserialization, sensitive logs, weak crypto)