fix(admin-security): SSRF defence + CSRF fail-closed + analysis .no_proxy() (0.87.2)

Four high/medium findings from the audit, plus their tests:

1. **SSRF on admin-editable URLs.** `CrawlerSettings::start_url` and
   `AnalysisSettings::endpoint` validated only with `Url::parse`. A
   hostile or CSRF-able admin could repoint at `169.254.169.254`
   (cloud metadata), `127.0.0.1:5432` (postgres), or any RFC1918 host
   — and the vision worker bearer-attaches an env-managed API key to
   every call. Extract `ensure_public_target` from `safety::is_safe_url`
   (allowlist-free public-host check: scheme + private-IP literal +
   localhost) and wire it into both fields. Docker DNS names
   (`mangalord-vision`) keep passing because they're hostnames, not
   IP literals. The analysis check is gated on `enabled=true` so the
   dev-default `localhost:8000` stays usable until the worker is
   actually turned on (toggling enabled=true later re-runs the gate).

2. **Admin CSRF fail-open default.** `ADMIN_ALLOWED_ORIGINS=` empty
   silently skipped the entire CSRF check, so an operator forgetting
   the env var shipped an unguarded admin surface. Cookie-auth POSTs
   now fail-closed when the allowlist is empty AND when neither
   `Origin` nor `Referer` accompanies the request. Two carve-outs:
   `Authorization: Bearer …` callers bypass (bots can't be CSRF'd),
   and requests with NO session cookie at all bypass to let the auth
   extractor return a clean 401 instead of a confusing 403.

3. **Analysis HTTP clients didn't `.no_proxy()`.** The crawler client
   already did. Ambient `HTTP_PROXY`/`HTTPS_PROXY` in container env
   would exfiltrate page-image bytes + the bearer key through an
   upstream proxy. Same for the readiness probe.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
MechaCat02
2026-06-22 21:04:50 +02:00
parent b14ed02670
commit dd25f073cd
10 changed files with 364 additions and 49 deletions

View File

@@ -10,7 +10,7 @@ use serde_json::json;
use sqlx::PgPool;
use tower::ServiceExt;
use common::{body_json, get_with_cookie, harness, register_user};
use common::{body_json, get_with_cookie, harness, put_json_with_cookie, register_user};
async fn seed_admin(pool: &PgPool, app: &Router) -> String {
let (username, cookie) = register_user(app).await;
@@ -25,16 +25,6 @@ async fn seed_admin(pool: &PgPool, app: &Router) -> String {
}
/// PUT helper (the common module exposes POST helpers; build a PUT here).
fn put_json_with_cookie(uri: &str, body: serde_json::Value, cookie: &str) -> axum::http::Request<axum::body::Body> {
axum::http::Request::builder()
.method("PUT")
.uri(uri)
.header(axum::http::header::COOKIE, cookie)
.header(axum::http::header::CONTENT_TYPE, "application/json")
.body(axum::body::Body::from(body.to_string()))
.unwrap()
}
fn valid_thresholds() -> serde_json::Value {
json!({
"disk_pct": 85.0,