Remediate security scan findings SEC-01..SEC-12 #28
Loading…
Reference in a new issue
No description provided.
Delete branch "fix/security-scan-2026-09-23"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Fixes every finding in
tmp/security.md(2026-09-23 scan: 2 high, 5 medium, 5 low).High
Microwaveprop.FieldCrypto(AES-256-GCM, versioned<<1, iv, tag, ct>>blobs, key = HMAC-SHA256(secret_key_base, purpose string), fail-closed decrypt with legacy-plaintext passthrough for rolling deploys). Migration encrypts existing seeds.token_digestfor the unique-indexed lookup. Plaintext stays retrievable on the monitor page (deliberate product decision from PR #4) via a virtualtoken_plaintext; new monitors get a 365-day expiry renewed on regeneration; every display/rotation is audit-logged;redact: true.Medium
/submitenforces a server-side daily quota (10/day anonymous per IP, 60/day per account) on top of the per-LiveView cooldown.INCR+EXPIRE NX) whenVALKEY_URLis set — cluster-wide, restart-surviving; ETS fallback on error/unconfigured.Dockerfile,Dockerfile.base,Dockerfile.ci,rust/prop_grid_rs/Dockerfile, all four k8s deployments, CI postgres service. (Deployment manifests pin:latestdigests — the original build tags were GCed by the registry.)Low
img-src/connect-srcnarrowed to the actual tile/radar/socket hosts.private; session writes skipped when values are unchanged soSet-Cookieis not re-emitted on cacheable responses./metricsbearer compare is constant-time (subtle::ct_eq).readOnlyRootFilesystem: trueon all pods +/tmpemptyDir;RELEASE_TMP=/tmp;backtest-reportsemptyDir for the admin report path.Also fixed
ContactLive.Showinternal-network gate was dead code — it readsession[:remote_ip]with an atom key, but Plug stringifies session keys.CHANGELOG.md(was rendering on/changelog).Carries the focused fixes from
fix/audit-remainder(invalid-token budget, registration parity, POST token consumption, CSP allowlist, private cache policy, monitor-token redaction, metrics fail-closed,get_user!→get_userrename) without that branch's unrelated changes.Deploy notes
encrypt_totp_secrets(in-place) andencrypt_beacon_monitor_tokens(addstoken_digest, encryptstokenin place). Both are reversible given the samesecret_key_base. During a rolling deploy, old pods reading a migrated row will fail TOTP/monitor auth for that row — the window is seconds on this cluster.prop-base/elixir-base, then update the digest ARGs.Verification
make precommitgreen (5038 tests, credo --strict, xref);cargo clippy --all-targets -- -D warnings+cargo test --releasegreen (263 tests).🤖 Skippy PR review
2 findings — 2 blocking before merge.
priv/repo/migrations/20260923153615_encrypt_totp_secrets.exs:15lib/microwaveprop_web/api/auth.ex:100Reviewed
54c8002a6c1d. Commentskippy reviewto re-run.@ -71,0 +97,4 @@# counts: the hit is recorded only when the token actually fails, so# valid-token traffic never drains this budget.defp invalid_token_budget(conn) doif RateLimiter.exhausted?({:invalid_token, ip_string(conn)}, @invalid_token_ip_limit) do🟡 Warning — Invalid-token budget rejects valid tokens from the same IP
invalid_token_budget/1runs beforeAccounts.get_user_by_api_token/1inauthenticate/1, and the bucket is keyed on the client IP. So once any one client behind an address burns 30 misses inside a 60 s window, every request from that address short-circuits to 401 "unauthorized" — valid bearer tokens included — and it stays that way as long as the bad client keeps retrying, because each retry hits the bucket again (a script with a revoked token on a 1 s retry loop keeps the bucket exhausted indefinitely).That is the exact collateral the comment above
invalid_token_budget/1says the separate bucket avoids for the anonymous{:ip, ip}bucket: a shared NAT (office, club, mobile carrier) loses its whole API access to one misbehaving client on the same egress IP.Fix: bucket on the presented token rather than the IP (hash the bearer value) so only the repeated bad value is throttled, or keep the pre-lookup gate but answer 429 with a distinct problem title and a higher limit — the invariant to preserve is that a bucket hit must never deny a request whose token would have authenticated.
@ -0,0 +12,4 @@rows = repo.query!("SELECT id, totp_secret FROM users WHERE totp_secret IS NOT NULL", [])Enum.each(rows.rows, fn [id, secret] ->unless match?(<<1, _::binary>>, secret) do🟠 High — Version-byte check leaves a 1-in-256 plaintext seed behind, and locks that user out of 2FA
match?(<<1, _::binary>>, secret)uses the FieldCrypto version byte to mean "already encrypted", but a pre-migration seed is 20 random bytes (NimbleTOTP.secret/0), so roughly 1 in 256 of them start with 0x01 and are skipped: that row stays plaintext in the column, which is exactly what SEC-01 exists to remove.The skipped row is then worse than unencrypted: the app reads it as a ciphertext blob, the AEAD tag fails, and
totp_secret/1fails closed, so that user can never pass the second factor again (only a saved recovery code gets them back in).down/0alsoraises on that row ("does not decrypt under the current key"), so a rollback of this migration is blocked until someone hand-fixes it. The same ambiguity catches a plaintext row written by an old pod after the migration ran.Fix: make "already a blob" unambiguous, e.g. version byte and a plausible length (
byte_size(secret) >= 29, since the shortest blob is 1 + 12 + 16 bytes, and a seed blob is 49), or addFieldCrypto.blob?/1and use it here and inwith_token_plaintext/1.Resolved 2 of 2 earlier findings.
priv/repo/migrations/20260923153615_encrypt_totp_secrets.exs): each row is now classified by attemptingFieldCrypto.decrypt/1instead of sniffing the version byte, so a plaintext seed can no longer be skipped, left readable at rest, and then unreadable bytotp_secret/1; the unauthenticatable-blob branch is logged and re-encrypted.down/0no longer hits a row it cannot handle.lib/microwaveprop_web/api/auth.ex): the miss bucket no longer short-circuits ahead of the lookup. Once it is spent, lookups are bounded by a separate 300/min per-IP ceiling ({:token_lookup, ip}), so a revoked-token retry loop can no longer deny every valid token sharing its egress IP. New coverage for both paths intest/microwaveprop_web/api/auth_test.exs.Nothing new in
54c8002a6c1d..1b1a0a8d34fd(4 files:auth.ex,rate_limiter.ex, the migration,auth_test.exs).