Files
PiCloud/security_audit/HANDBACK_tier3.md
2026-06-12 19:09:37 +02:00

136 lines
8.4 KiB
Markdown
Raw 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.
# Handback — Security remediation Tier-3 "safety batch" (`fix/audit-2026-06-11-tier3`)
Implementer: assistant (inline, with iterative testing).
Date: 2026-06-12.
Branch: `fix/audit-2026-06-11-tier3` (8 commits off `main`, unpushed).
Scope: the user-selected "Safety batch" from the post-merge review of `fix/audit-2026-06-11`.
## Verdict from my own testing: green, ready for your review.
- `cargo fmt --all --check` → clean.
- `cargo clippy --all-targets --all-features -- -D warnings` → clean (exit 0).
- Full workspace `--include-ignored` against a fresh Postgres 16: **824 passed**; the
previously-failing `authz::deactivating_user_revokes_their_api_keys` now **passes**.
- Full `picloud-cli` suite in isolation (fresh DB + dev key): **76 passed**.
- Two "failures" in the *combined* `--no-fail-fast` run are the same harness artifacts the
original REVIEW.md documented — see "Test-harness artifacts" below. Neither is a code defect.
## Commits (one finding per commit)
| Commit | Finding | What landed |
|---|---|---|
| `31ecfae` | PrincipalCache revocation-lag (+ H-E1, test rename) | `evict_user`/`evict_token` on the cache; one shared cache Arc across AuthState/AdminsState/ApiKeysState; evict on deactivation, password change, logout, single API-key delete. Greens the failing test. |
| `95fddf3` | C-2 sanitizer + F-SE-H-03 | `sanitize_stored_content_type` rejects control chars up-front (was passable via an `image/` prefix → header-injection/panic); `disable_symbol("debug")` to match `print`. |
| `e676eb9` | F-SE-H-04 | Scrub `ExecError::Runtime` detail from public data-plane responses; log full text under a correlation id. |
| `d9fb0e7` | F-FS-002 | `guard_collection` on repo `head/get/list/delete`; `validate_files_collection` on the three admin endpoints (clean 422). |
| `3ac1022` | H-1 | Caddy `request_body { max_size 12MB }` (both Caddyfiles, validated); explicit `DefaultBodyLimit::max(1MB)` on the public email-inbound webhook. |
| `bdcc9a6` | H2 (+ M2) | Reject inline `--password`/`--token` (stdin-only); `rpassword` no-echo prompt for admins. |
| `e8cc3af` | H-B1 follow-up | `extract_remote_ip` uses the **last** X-Forwarded-For hop (Caddy-appended real client) instead of the spoofable first hop. |
| `ec4a2aa` | — | `cargo fmt`. |
## Detail per item
### 1. PrincipalCache revocation-lag (`31ecfae`) — greens the failing test
The cache (`token-hash → Principal`, 60s TTL) had no eviction hook, so deactivation /
password change / API-key revocation flipped the DB rows while the cache kept serving the
stale principal until TTL. Added `PrincipalCache::evict_user(user_id)` and `evict_token(hash)`.
Wired one **shared** cache `Arc` (hoisted in `lib.rs`) into `AuthState`, `AdminsState`, and
`ApiKeysState` — a per-state cache would let the middleware's own copy keep authenticating a
revoked principal. Evict points: deactivation + password change (`evict_user`), logout
(`evict_token`, precise), single API-key delete (`evict_user`). Unit tests for both methods
plus the existing `authz::deactivating_user_revokes_their_api_keys` integration test now pass.
**H-E1 note (folded in):** the DB-write invalidation stays best-effort by design (the
deactivation path deliberately doesn't undo on a DB blip; I kept password-change consistent).
The cache evict is what now makes revocation take effect on the *next* request rather than
after the TTL. I did **not** convert it to a hard abort — that would contradict the existing
deliberate design and isn't clearly better.
### 2. C-2 sanitizer + F-SE-H-03 (`95fddf3`)
`sanitize_stored_content_type("image/png\r\nX-Injected: 1")` previously matched the `image/`
prefix and returned the original (CRLF intact) → response-header injection / `.expect()` panic
on download. Now any control byte (`<0x20` or `0x7f`) coerces to `application/octet-stream`
first. Also `disable_symbol("debug")` (the comment already claimed both were disabled).
### 3. F-SE-H-04 (`e676eb9`)
`ExecError::Runtime` strings (FS paths, `"blocked by SSRF policy: link-local"`, pool-exhaustion
timing, panic fragments, attacker-thrown strings) were returned verbatim in the public
`/api/v1/execute` + user-route 502 bodies. This handler only serves anonymous data-plane
callers (the authenticated script-test path is in manager-core and keeps verbose errors), so
the full text is logged under a `correlation_id` and the client gets `"script execution error
(ref: <uuid>)"`. New test asserts a thrown marker string doesn't leak.
### 4. F-FS-002 (`d9fb0e7`)
Admin files endpoints hit `FsFilesRepo` directly (not via the validating `FilesServiceImpl`).
Added `guard_collection` to repo `head/get/list/delete` (create/update already had it) and
`validate_files_collection` to the three admin endpoints for a clean 422 instead of the repo
guard surfacing as a 500.
### 5. H-1 (`3ac1022`)
Caddy `request_body { max_size 12MB }` in both Caddyfiles (12MB clears the orchestrator's
10 MiB user-route read; replaces Caddy's multi-GB default). Both validated with
`caddy validate`. Email-inbound webhook gets an explicit `DefaultBodyLimit::max(1MB)`. Admin /
execute keep Axum's 2MB extractor default; user routes keep their 10 MiB manual read.
### 6. H2 + M2 (`bdcc9a6`)
`--password`/`--token` reject inline values (leak into shell history / `ps` / `/proc`); only
`-` (stdin) is accepted, `PICLOUD_TOKEN` env still covers CI for login. Interactive admins
prompt switched to `rpassword` (true no-echo; the old `read_line` echoed despite the claim).
New CLI test asserts inline `--password hunter2` is rejected.
### 7. H-B1 follow-up (`e8cc3af`)
`extract_remote_ip` took the **first** X-Forwarded-For entry; Caddy appends the real client as
the **last** hop, so earlier entries are client-spoofable — an attacker could rotate the first
entry to evade the per-IP login limiter. Now takes the last hop. Three unit tests (spoof,
single-hop, missing-header).
## Deferred (with rationale)
- **Bootstrap sentinel (H-E2)** — deliberately NOT done. The API can't empty `admin_users`
(the last-active-admin guard blocks delete *and* deactivate of the final active admin), so
`count_active() > 0` always holds via normal operation. The only way to `count_active() == 0`
is direct SQL deletion, which already implies DB-write compromise (at which point an attacker
just inserts their own admin row — bootstrap re-seed is moot). A persistent sentinel would
only defend that niche **and** needs a durable marker = a schema migration, which the chosen
"no new schema" batch scope excludes. Re-fold into the v1.2 schema batch if desired.
Everything else in the audit's Tier-3/4 (per-app quotas, audit-log table, AAD re-encryption
sweep, absolute session expiry, outbox GC, per-execution SDK counters, per-app trigger/cron
caps, etc.) remains deferred per the chosen scope.
## Operational notes / behaviour changes
- `pic admins create/set --password <inline>` and `pic login --token <inline>` now **error**.
Scripts must use `--password -` / `--token -` (piped) or `PICLOUD_TOKEN`.
- Public-route runtime errors no longer echo detail; operators correlate via the logged
`correlation_id`.
- Caddy now rejects request bodies > 12 MB at the proxy.
## Test-harness artifacts (NOT regressions — same as the original review)
A combined `cargo test --workspace --no-fail-fast -- --include-ignored` against one DB shows 77
"failures":
- **76 × picloud-cli** — the CLI fixture spawns the `picloud` binary and bootstraps an admin
into the shared base DB; run concurrently with the rest of the workspace it collides and the
fixture `LazyLock` poisons. **In isolation against a fresh DB the full suite is 76/76 green.**
- **1 × engine doctest** (`set_self_weak`, line 172) — an `ignore`d pseudo-code example that
`--include-ignored` forces to compile. Under a normal `cargo test` it's skipped.
How to reproduce a clean run:
```
# fresh DB
docker run -d --rm --name pg -p 127.0.0.1:15495:5432 -e POSTGRES_USER=picloud \
-e POSTGRES_PASSWORD=picloud -e POSTGRES_DB=picloud postgres:16-alpine
export DATABASE_URL=postgres://picloud:picloud@127.0.0.1:15495/picloud
export PICLOUD_DEV_MODE=true PICLOUD_DEV_INSECURE_KEY=i-understand-this-is-insecure
# everything except CLI (CLI needs its own DB):
cargo test --workspace --exclude picloud-cli -- --include-ignored
# CLI on its own fresh DB:
cargo test -p picloud-cli --test cli -- --include-ignored
```
## Suggested next step
ff-merge `fix/audit-2026-06-11-tier3` into `main` after your review. The branch is off the
current `main` (which already has the Tier-1+2 work) and fast-forwards cleanly.