# Audit 07 — HTTP headers, CORS, CSRF, frontend XSS

Scope: Caddy (dev + prod), orchestrator/manager Axum routers, SvelteKit dashboard, file download surface. Date: 2026-06-11.

## Summary
| Severity | Count |
|---|---|
| Critical | 2 |
| High | 3 |
| Medium | 4 |
| Low | 3 |
| Info | 2 |

The platform ships with effectively no defense-in-depth at the HTTP layer: no CSP, no `X-Content-Type-Options`, no `X-Frame-Options`, no HSTS, no Referrer-Policy, no Permissions-Policy, no `Cache-Control: no-store` on admin responses. Both `caddy/Caddyfile` and `caddy/Caddyfile.prod` are bare reverse proxies; the dashboard's inner Caddy (`docker/dashboard.Dockerfile` line 28) is equally bare. This is harmful on its own and compounds the application-layer issues below.

---

## C07-01 (Critical) — Same-origin CSRF on admin API via `SameSite=Lax` cookie + user-route surface
File: `crates/manager-core/src/auth_api.rs:229`, `crates/manager-core/src/auth_middleware.rs:91, 359`, `crates/orchestrator-core/src/api.rs:609–623`.

`/auth/login` sets `picloud_session=<raw_token>; HttpOnly; Secure; SameSite=Lax; Path=/; Max-Age=...`. The auth middleware then accepts the cookie as a fallback to `Authorization: Bearer`. User scripts can return arbitrary HTML on **the same origin** as `/api/v1/admin/*` (user routes are bound on the same host by design, see `Caddyfile` line 62 catch-all). `SameSite=Lax` does NOT block same-origin requests, so:

- A logged-in admin visits an attacker-controlled script at e.g. `https://admin.example.com/evil` (a user route the attacker created — and any owner/admin/member with `AppScriptsWrite` can do so).
- The page issues a `<form method="POST" action="/api/v1/admin/apps/<id>/admins" enctype="text/plain">…</form>` or `fetch('/api/v1/admin/...')` — both fire **same-origin** with the cookie attached.
- The admin endpoint executes the action as the victim.

The dashboard's `lib/api.ts` sends bearer tokens (line 495), but the server still accepts cookies as a parallel auth path. Removing the cookie path on `/api/v1/admin/*` (Authorization-only) is the cleanest fix; alternatively gate cookie auth behind a CSRF token / SameSite=Strict, and reject `Content-Type: text/plain` POSTs. Same-origin attack means *no preflight* and no Origin/Referer check stops it.

Recommendation: drop the cookie auth path on admin endpoints entirely (the dashboard already uses bearer tokens), OR enforce `Sec-Fetch-Site: same-origin` and a CSRF synchronizer token. `SameSite=Lax` is insufficient given the orchestrator co-hosts attacker-controlled HTML on the same origin.

## C07-02 (Critical) — Stored XSS on admin origin via uploaded files served `inline` with no `nosniff`
File: `crates/manager-core/src/files_api.rs:130–164`.

`GET /apps/{id}/files/{collection}/{file_id}` streams blob bytes with `Content-Disposition: inline; filename="<name>"` and the user-supplied `Content-Type`. There is no `X-Content-Type-Options: nosniff`, no CSP, and no forced `attachment` disposition. A member with `AppFilesWrite` (or anyone whose script writes a file in v1.1.5) can upload `evil.html` with `Content-Type: text/html`; opening the download URL — which lives on the admin origin — renders attacker JS as the logged-in admin. Result: full session hijack / privilege escalation.

**Ownership note vs. agent 6:** agent 6 likely covers the upload-side (content-type validation, magic bytes). The **response-side fix is owned here**: serve all download responses with `Content-Disposition: attachment`, `X-Content-Type-Options: nosniff`, `Content-Security-Policy: default-src 'none'; sandbox; frame-ancestors 'none'`, and consider serving file blobs from a distinct hostname (`files.<domain>`). Whoever fixes the storage layer must coordinate; the response headers are non-negotiable.

---

## H07-03 (High) — No Content-Security-Policy anywhere
Files: `caddy/Caddyfile.prod`, `docker/dashboard.Dockerfile:28`.

The dashboard has no CSP — any reflected or stored XSS escalates immediately. Recommended starter CSP for `/admin/*`:
```
Content-Security-Policy: default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self'; frame-ancestors 'none'; base-uri 'none'; form-action 'self'
```
For `/api/v1/admin/*` and `/api/v1/execute/*`: `default-src 'none'; frame-ancestors 'none'`. User-route responses should *not* get a CSP injected (scripts control their own headers — that's by design), but the admin SPA absolutely should.

## H07-04 (High) — No `X-Frame-Options` / `frame-ancestors`: dashboard clickjackable
Files: same as above. Combined with the cookie session, an attacker can iframe `/admin/...` and overlay clickjack UI to trigger admin actions. `Content-Security-Policy: frame-ancestors 'none'` (covered by H07-03) is the modern fix; legacy fallback is `X-Frame-Options: DENY`.

## H07-05 (High) — No HSTS in production Caddyfile
File: `caddy/Caddyfile.prod` (lines 11–50). Auto-HTTPS is on but `Strict-Transport-Security` is not emitted. First-visit downgrade and TLS-stripping attacks remain possible. Add `header Strict-Transport-Security "max-age=31536000; includeSubDomains; preload"` inside the prod site block (after carefully scoping the includeSubDomains decision).

---

## M07-06 (Medium) — No `X-Content-Type-Options: nosniff`
Universally missing. Browsers will sniff content types on any response (including JSON responses from `/api/v1/admin/*`). Combined with C07-02 this is acute, but every response surface needs `nosniff`.

## M07-07 (Medium) — No `Referrer-Policy`
Default `strict-origin-when-cross-origin` is okay-ish, but the admin app should be `Referrer-Policy: no-referrer` or `same-origin` to avoid leaking `/admin/scripts/<id>/...` URLs to third parties when an admin clicks an outbound link in a user-supplied log entry.

## M07-08 (Medium) — No `Permissions-Policy`
Geolocation, USB, camera, microphone, payment, etc. are unrestricted. Recommend `Permissions-Policy: geolocation=(), camera=(), microphone=(), payment=(), usb=(), accelerometer=(), gyroscope=()` on `/admin/*`.

## M07-09 (Medium) — Admin API responses are cacheable
No `Cache-Control: no-store` is emitted on `/api/v1/admin/*` JSON responses. Shared proxies / forward caches could retain session-keyed data. Add a layer that injects `Cache-Control: no-store, private` for admin endpoints.

---

## L07-10 (Low) — Logout is CSRF-able
`/auth/logout` accepts the cookie and clears the session with no anti-CSRF token. Effect is forced sign-out — annoyance, not impact.

## L07-11 (Low) — Session cookie not `__Host-` prefixed
`picloud_session` is not `__Host-picloud_session`. The `__Host-` prefix would enforce `Path=/`, `Secure`, no `Domain` (already true) and provide subdomain isolation if a sibling subdomain ever exists.

## L07-12 (Low) — Dashboard token in `localStorage`
`dashboard/src/lib/auth.ts:27,37` keeps the bearer token in `localStorage`. XSS-survivability is poor: any XSS exfiltrates it. With the cookie path also active this is doubly bad. Once C07-01 is resolved by going Authorization-only, consider an in-memory token + refresh approach. Not raising higher because the CSP gap (H07-03) is the upstream issue.

---

## I07-13 (Info) — No `{@html}`, no `innerHTML`, no `eval`/`Function()` in dashboard
Verified by grep across `dashboard/src/`. Email invite `body_template` is server-side `String::replace("{link}", …)` (`crates/manager-core/src/users_service.rs:745`) and emails are plain-text; no HTML rendering pathway. CodeMirror is used purely client-side for the script editor; no widget evaluates Rhai source as JS. No external CDN `<script src=…>` in `app.html`.

## I07-14 (Info) — No CORS configuration emits, but none is needed yet
There is no `tower-http::cors` use anywhere in `crates/`. The dashboard fetches same-origin only. Browsers will block cross-origin reads against `/api/v1/admin/*` by default. This is fine for MVP. **Caveat:** if/when external clients are introduced (the `clients/typescript/` SDK in non-browser contexts is unaffected), introduce a strict allow-list — never reflect `Origin` with `Access-Control-Allow-Credentials: true`. Document this as a v1.2+ requirement.

---

## Cross-references
- Agent 6 (files / path traversal): C07-02 is a duplicate hit. **I own the response-side recommendation** (`Content-Disposition: attachment`, `nosniff`, sandboxed CSP, ideally a separate `files.<domain>` origin). Agent 6 owns upload-side validation (allow-list MIME, sniff content, reject HTML/SVG). Both fixes are required; neither alone suffices.
- Agent 1 (auth/session): SSE handler accepts `?token=<bearer>` (`crates/orchestrator-core/src/realtime_api.rs:97,110`), which will leak into access logs. Out of my scope; flagging for them.
- Agent 4 (injection): `Set-Cookie` header includes raw token value with no escaping (`crates/manager-core/src/auth_api.rs:228`) — the token is ASCII by construction (`generate_session_token`), but if that ever changes, the cookie builder needs hardening.

## Suggested Caddy snippet
Add to both Caddyfiles inside each handle (or once at the top using `header`):
```
header {
    Strict-Transport-Security "max-age=31536000; includeSubDomains"
    X-Content-Type-Options "nosniff"
    Referrer-Policy "no-referrer"
    Permissions-Policy "geolocation=(), camera=(), microphone=(), payment=(), usb=()"
    -Server
}
@admin path /admin*
header @admin {
    Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self'; frame-ancestors 'none'; base-uri 'none'; form-action 'self'"
    Cache-Control "no-store, private"
}
@adminapi path /api/v1/admin/*
header @adminapi {
    Content-Security-Policy "default-src 'none'; frame-ancestors 'none'"
    Cache-Control "no-store, private"
}
```
User-route responses should NOT receive a CSP from Caddy — scripts own their headers (`crates/orchestrator-core/src/api.rs:609`).
