Files
PiCloud/security_audit/REVIEW.md
MechaCat02 ec4a2aa24a style: cargo fmt across audit-2026-06-11 tier-3 changes
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 18:38:28 +02:00

110 lines
8.4 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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_token` cookie path fully removed; `SESSION_COOKIE` const deleted; login response no longer emits `Set-Cookie` (`build_cookie` + `SET_COOKIE` gone). 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 at `create`/`update`. Allowlist correctly coerces `text/html`, `image/svg+xml`, `application/javascript`, `application/xhtml+xml`, flash, xml. ✅ (one residual edge below)
- **H-A1/A2/A3 (headers)** — Caddy `header` layer 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-`username` bucket 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 `DeadlineGuard` consulted by `engine.on_progress`, returning `Some(Dynamic::UNIT)` to halt eval; both `spawn_blocking` sites pass `Some(deadline)`; invoke re-entries inherit the deadline; default `None` preserves test/validation behavior. ✅
- **H-D1 (AES-GCM AAD)** — `encrypt_with_aad`/`decrypt_with_aad` added with round-trip + AAD-mismatch tests; `secrets`/`app_secrets` seal/open bind `app_id`(+`name`) AAD; read path **dispatches on the `version` column** (v0 → no-AAD legacy decrypt, v1 → AAD, else `Corrupted`); migration 0042 adds `version SMALLINT NOT NULL DEFAULT 0` so 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_user` on 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 returns `ResolveTrigger` error. Mirrors `build_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 `picloud` binary as a subprocess; bootstrap only
seeds the fixture admin when `admin_users` is empty, but the earlier suites had already
populated that table in the shared DB → fixture login 401 → poisoned `LazyLock` cascade.
Against a **fresh** DB, all 75 pass. `server.rs` is unchanged by the branch.
- **1 × engine doctest** (`set_self_weak`, line 172) — a ` ```ignore ` pseudo-code example.
`--include-ignored` forced it to compile; under a normal `cargo test` it is correctly skipped.
Unchanged by the branch.
---
## Non-blocking findings (recommend as follow-ups, not merge blockers)
1. **C-2 sanitizer lets CRLF through an allowlisted prefix.**
`sanitize_stored_content_type("image/png\r\nX-Injected: yes")` matches the `image/` prefix
and returns the **original** string (CRLF intact). `NewFile::validate` is 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 named `sanitize_*` gives false confidence. One-line fix: reject/strip bytes
`< 0x20`/`0x7f` in `sanitize_stored_content_type` (or in `validate`).
2. **H-E1 invalidation is best-effort.** `delete_for_user` / `expire_all_for_user` errors are
logged but don't fail the password-change response. If `delete_for_user` errors, the password
rotates while sessions survive — the exact gap being closed. Consider surfacing the failure
(or retrying) rather than only `tracing::error!`. Same shape exists on the pre-existing
deactivation path, so it's a consistency point, not a regression.
3. **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.
4. **Operational breaking change (intended).** Existing email triggers with a null `inbound_secret`
now 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.
5. **Cosmetic.** `bearer_and_cookie_produce_same_principal` no longer tests cookies (its body uses
`Authorization: Bearer`). Rename to avoid implying cookie auth still exists.
6. **Confirm `remote_ip` source 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 unvalidated `X-Forwarded-For`, that's spoofable. Worth a one-line check of how `remote_ip`
is 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).