Files
Mangalord/backend/tests/api_admin_health.rs
MechaCat02 dd25f073cd 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>
2026-06-22 21:04:50 +02:00

198 lines
6.3 KiB
Rust

//! Integration tests for the admin health endpoints: `GET /v1/admin/health`
//! (shape, admin-gating, default checks present without a daemon) and
//! `PUT /v1/admin/health/thresholds` (validation, persistence, audit row).
mod common;
use axum::http::StatusCode;
use axum::Router;
use serde_json::json;
use sqlx::PgPool;
use tower::ServiceExt;
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;
let u = mangalord::repo::user::find_by_username(pool, &username)
.await
.unwrap()
.unwrap();
mangalord::repo::user::set_is_admin_unchecked(pool, u.id, true)
.await
.unwrap();
cookie
}
/// PUT helper (the common module exposes POST helpers; build a PUT here).
fn valid_thresholds() -> serde_json::Value {
json!({
"disk_pct": 85.0,
"mem_pct": 80.0,
"dead_jobs_max": 50,
"crawl_fail_pct": 25.0,
"analysis_fail_pct": 30.0,
"cron_freshness_hours": 12,
"missing_covers_max": 200
})
}
#[sqlx::test(migrations = "./migrations")]
async fn requires_admin(pool: PgPool) {
let h = harness(pool.clone());
let (_u, cookie) = register_user(&h.app).await;
let resp = h
.app
.oneshot(get_with_cookie("/api/v1/admin/health", &cookie))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::FORBIDDEN);
}
#[sqlx::test(migrations = "./migrations")]
async fn report_has_status_checks_and_default_thresholds(pool: PgPool) {
let h = harness(pool.clone());
let cookie = seed_admin(&pool, &h.app).await;
let resp = h
.app
.oneshot(get_with_cookie("/api/v1/admin/health", &cookie))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let body = body_json(resp).await;
// Overall status + a non-empty checks array (memory / dead_jobs /
// missing_covers / fail-rates are always present, daemon or not).
assert!(body["status"].is_string());
let checks = body["checks"].as_array().unwrap();
assert!(!checks.is_empty());
let ids: Vec<&str> = checks.iter().map(|c| c["id"].as_str().unwrap()).collect();
assert!(ids.contains(&"memory"));
assert!(ids.contains(&"dead_jobs"));
assert!(ids.contains(&"crawl_fail_rate"));
// The harness wires no crawler daemon, so session/browser checks are absent.
assert!(!ids.contains(&"crawler_session"));
// Default thresholds echoed back.
assert_eq!(body["thresholds"]["dead_jobs_max"], 100);
assert_eq!(body["thresholds"]["disk_pct"], 90.0);
}
#[sqlx::test(migrations = "./migrations")]
async fn dead_jobs_check_warns_when_over_threshold(pool: PgPool) {
let h = harness(pool.clone());
let cookie = seed_admin(&pool, &h.app).await;
// Lower the threshold to 0, then a single dead job trips the check.
let mut t = valid_thresholds();
t["dead_jobs_max"] = json!(0);
let resp = h
.app
.clone()
.oneshot(put_json_with_cookie(
"/api/v1/admin/health/thresholds",
t,
&cookie,
))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
sqlx::query(
"INSERT INTO crawler_jobs (payload, state) VALUES ($1, 'dead')",
)
.bind(json!({ "kind": "sync_manga", "source_id": "target", "source_manga_key": "k", "url": "http://x", "title": "T" }))
.execute(&pool)
.await
.unwrap();
let resp = h
.app
.clone()
.oneshot(get_with_cookie("/api/v1/admin/health", &cookie))
.await
.unwrap();
let body = body_json(resp).await;
let dead = body["checks"]
.as_array()
.unwrap()
.iter()
.find(|c| c["id"] == "dead_jobs")
.unwrap();
assert_eq!(dead["status"], "warn");
assert_eq!(body["status"], "warn"); // overall rolls up the worst
}
#[sqlx::test(migrations = "./migrations")]
async fn update_thresholds_persists_and_audits(pool: PgPool) {
let h = harness(pool.clone());
let cookie = seed_admin(&pool, &h.app).await;
let resp = h
.app
.clone()
.oneshot(put_json_with_cookie(
"/api/v1/admin/health/thresholds",
valid_thresholds(),
&cookie,
))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let body = body_json(resp).await;
// Response re-evaluates against the new thresholds.
assert_eq!(body["thresholds"]["dead_jobs_max"], 50);
// Persisted to app_settings.
let stored: serde_json::Value =
sqlx::query_scalar("SELECT value FROM app_settings WHERE key = 'health_thresholds'")
.fetch_one(&pool)
.await
.unwrap();
assert_eq!(stored["mem_pct"], 80.0);
// Audit row written.
let action: String =
sqlx::query_scalar("SELECT action FROM admin_audit ORDER BY at DESC LIMIT 1")
.fetch_one(&pool)
.await
.unwrap();
assert_eq!(action, "update_health_thresholds");
}
#[sqlx::test(migrations = "./migrations")]
async fn last_metadata_tick_at_reads_crawler_state(pool: PgPool) {
// Absent → None.
assert!(mangalord::repo::crawler::last_metadata_tick_at(&pool)
.await
.unwrap()
.is_none());
// Stored in the daemon's `{"at": rfc3339}` shape → parsed back.
sqlx::query(
"INSERT INTO crawler_state (key, value) VALUES ('last_metadata_tick_at', $1)",
)
.bind(json!({ "at": "2026-06-18T09:30:00Z" }))
.execute(&pool)
.await
.unwrap();
let got = mangalord::repo::crawler::last_metadata_tick_at(&pool)
.await
.unwrap()
.unwrap();
assert_eq!(got.to_rfc3339(), "2026-06-18T09:30:00+00:00");
}
#[sqlx::test(migrations = "./migrations")]
async fn update_thresholds_rejects_out_of_range(pool: PgPool) {
let h = harness(pool.clone());
let cookie = seed_admin(&pool, &h.app).await;
let mut bad = valid_thresholds();
bad["disk_pct"] = json!(150.0); // > 100
let resp = h
.app
.oneshot(put_json_with_cookie(
"/api/v1/admin/health/thresholds",
bad,
&cookie,
))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
}