8.4 KiB
Review — Security remediation fix/audit-2026-06-11 (Tier 1+2)
Reviewer: independent audit pass (not the implementing agent).
Date: 2026-06-12.
Branch: fix/audit-2026-06-11 (11 commits off main, unpushed).
Scope reviewed: the 2 Criticals + 8 Highs claimed closed in the handback.
Verdict: APPROVE — ready to ff-merge into main.
All 10 Tier-1+2 findings are correctly implemented, the security logic is sound, and the production test suites are green. The single failing test is pre-existing and out of scope (disclosed honestly in the handback). The findings below are non-blocking follow-ups.
What I verified (independently, not from the handback)
Build gates
cargo clippy --all-targets --all-features -- -D warnings→ clean (exit 0).cargo fmt --all --check→ clean.
Tests (run against throwaway Postgres 16 containers)
| Suite | Result | Notes |
|---|---|---|
picloud --test api |
53 passed | in-process server boot |
picloud --test authz |
28 passed, 1 failed | the 1 failure is pre-existing — see below |
picloud --test email_inbound |
8 passed | directly exercises H-B2 |
manager-core --test schema_snapshot |
1 passed | migration 0042 applies; hand-edited expected_schema.txt matches the real migrated DB exactly |
picloud-cli --test cli |
75 passed (fresh DB) | see "test-harness artifacts" |
| unit tests (manager-core 343, executor-core 74, shared, orchestrator, …) | all passed | |
| executor-core doctests | ignored doctest correctly skipped under normal cargo test |
Net: every suite the handback claimed green is green when run correctly.
Per-finding code verification
- C-1 (cookie CSRF) —
extract_tokencookie path fully removed;SESSION_COOKIEconst deleted; login response no longer emitsSet-Cookie(build_cookie+SET_COOKIEgone). Tests now assert cookie rejection. Dashboard already uses Bearer. ✅ - C-2 (stored XSS) — download forces
Content-Disposition: attachment+X-Content-Type-Options: nosniff+default-src 'none'; sandbox; frame-ancestors 'none'CSP +Referrer-Policy: no-referrer; content-type re-sanitized through an allowlist at serve time; write path coerces dangerous types atcreate/update. Allowlist correctly coercestext/html,image/svg+xml,application/javascript,application/xhtml+xml, flash, xml. ✅ (one residual edge below) - H-A1/A2/A3 (headers) — Caddy
headerlayer adds CSP/X-Frame-Options→frame-ancestors/Permissions-Policy/no-store/HSTS;?-operator lets the C-2 per-download CSP win on file routes. ✅ - H-B1 (login DoS) — per-
(ip, username)+ per-usernamebucket gates before acquiring a bounded Argon2 semaphore (correct ordering — attackers can't queue), fails closed on a closed semaphore. ✅ - H-B2 (email webhook) — bad-signature limiter checked before verification; no-secret path fails closed (401) as defense-in-depth; new triggers require a secret; email-trigger secret correctly stays on the
open_legacy(v0, no-AAD) path so the AAD migration didn't break it. ✅ - H-C1 (runaway script) — thread-local
DeadlineGuardconsulted byengine.on_progress, returningSome(Dynamic::UNIT)to halt eval; bothspawn_blockingsites passSome(deadline); invoke re-entries inherit the deadline; defaultNonepreserves test/validation behavior. ✅ - H-D1 (AES-GCM AAD) —
encrypt_with_aad/decrypt_with_aadadded with round-trip + AAD-mismatch tests;secrets/app_secretsseal/open bindapp_id(+name) AAD; read path dispatches on theversioncolumn (v0 → no-AAD legacy decrypt, v1 → AAD, elseCorrupted); migration 0042 addsversion SMALLINT NOT NULL DEFAULT 0so existing rows keep decrypting. The dev correctly pinned the subtle RustCrypto fact that v0 == empty-AAD-v1, so version-column dispatch (not ABI) is the discriminator. ✅ - H-E1 (password change) — now
delete_for_user+expire_all_for_useron password change, mirroring the deactivation path. ✅ (best-effort caveat below) - H-F1 (dispatcher cross-app) — queue arm dead-letters on
script.app_id != claimed.app_id(observable, doesn't execute); HTTP arm returnsResolveTriggererror. Mirrorsbuild_invoke_request. ✅ - H-H1 (email::send) —
send()enforces a per-message recipient cap (TooManyRecipients) + per-(app_id, recipient)rate limiter (RateLimited). Per-app, as the audit asked. ✅
The one failing test is genuinely pre-existing
picloud --test authz deactivating_user_revokes_their_api_keys asserts 401 but gets 200
(authz.rs:651). This is the PrincipalCache revocation-lag Medium the audit itself flagged
as deferred (expire_all_for_user flips the DB row, but the in-process cache serves the stale
principal until TTL). Confirmed pre-existing via git: the branch leaves both authz.rs
(the test) and the deactivation revocation path byte-identical to main — H-E1 only added
invalidation to the password-change path, not deactivation. The handback's disclosure is
accurate.
Test-harness artifacts in my combined run (not code defects)
When I ran cargo test --workspace --no-fail-fast -- --include-ignored against a single shared
DB, I saw 77 "failures." All but the one above are harness artifacts:
- 75 × picloud-cli — the fixture spawns the
picloudbinary as a subprocess; bootstrap only seeds the fixture admin whenadmin_usersis empty, but the earlier suites had already populated that table in the shared DB → fixture login 401 → poisonedLazyLockcascade. Against a fresh DB, all 75 pass.server.rsis unchanged by the branch. - 1 × engine doctest (
set_self_weak, line 172) — a```ignorepseudo-code example.--include-ignoredforced it to compile; under a normalcargo testit is correctly skipped. Unchanged by the branch.
Non-blocking findings (recommend as follow-ups, not merge blockers)
-
C-2 sanitizer lets CRLF through an allowlisted prefix.
sanitize_stored_content_type("image/png\r\nX-Injected: yes")matches theimage/prefix and returns the original string (CRLF intact).NewFile::validateis length-only, so such a value can be stored; on download it would reach.header(CONTENT_TYPE, …)→.expect(...)and panic the task (Axum catches it per-task → 500, file's download poisoned). This is the pre-existing agent-4 Medium — not introduced by C-2, and C-2 narrows the surface — but the function namedsanitize_*gives false confidence. One-line fix: reject/strip bytes< 0x20/0x7finsanitize_stored_content_type(or invalidate). -
H-E1 invalidation is best-effort.
delete_for_user/expire_all_for_usererrors are logged but don't fail the password-change response. Ifdelete_for_usererrors, the password rotates while sessions survive — the exact gap being closed. Consider surfacing the failure (or retrying) rather than onlytracing::error!. Same shape exists on the pre-existing deactivation path, so it's a consistency point, not a regression. -
H-B2 lockout is trigger-global. An attacker flooding bad signatures locks out legitimate inbound email to that trigger for the 60s window. Inherent to lockout-style limiters and acceptable for single-node MVP; worth a comment so it's a known trade-off.
-
Operational breaking change (intended). Existing email triggers with a null
inbound_secretnow return 401 (the H-B2 fix). Operators must add a secret to any pre-audit secret-less email trigger. Call this out in release notes. -
Cosmetic.
bearer_and_cookie_produce_same_principalno longer tests cookies (its body usesAuthorization: Bearer). Rename to avoid implying cookie auth still exists. -
Confirm
remote_ipsource for H-B1. Behind Caddy the socket peer is Caddy's IP (so the per-IP bucket degrades to global; the per-username bucket still protects). If it instead trusts an unvalidatedX-Forwarded-For, that's spoofable. Worth a one-line check of howremote_ipis derived. Not a blocker either way.
Recommendation
ff-merge fix/audit-2026-06-11 into main. The Tier-1+2 security work is correct, tested, and
honestly documented. File the six items above as Tier-3 follow-ups alongside the already-deferred
work (per-app quotas, body limits, disable_symbol("debug"), anon error-scrub, email-trigger AAD
upgrade, PrincipalCache eviction).