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:
@@ -357,27 +357,35 @@ async fn csrf_allows_mutation_with_allowed_origin(pool: PgPool) {
|
||||
}
|
||||
|
||||
#[sqlx::test(migrations = "./migrations")]
|
||||
async fn csrf_allows_mutation_without_origin_or_referer(pool: PgPool) {
|
||||
// curl/server-to-server callers send neither — they can't be a CSRF
|
||||
// vector since there's no third-party browser context.
|
||||
async fn csrf_rejects_cookie_auth_without_origin_or_referer(pool: PgPool) {
|
||||
// Cookie-auth + neither Origin nor Referer was previously waved through
|
||||
// ("curl bypass"). It's now refused: an extension or no-referrer-policy
|
||||
// page can produce exactly this shape and would otherwise be a CSRF
|
||||
// vector. Real curl/script callers should authenticate with a bearer
|
||||
// token instead — see `csrf_allows_bearer_token_without_origin`.
|
||||
let h = harness_with_admin_origins(
|
||||
pool.clone(),
|
||||
vec!["http://localhost:3000".to_string()],
|
||||
);
|
||||
let cookie = seed_admin(&pool, &h.app).await;
|
||||
// `post_json_with_cookie_origin(..., None, None)` builds the exact "no
|
||||
// Origin, no Referer" cookie-auth shape this test pins.
|
||||
let resp = h
|
||||
.app
|
||||
.clone()
|
||||
.oneshot(post_json_with_cookie(
|
||||
.oneshot(post_json_with_cookie_origin(
|
||||
"/api/v1/admin/crawler/session/clear-expired",
|
||||
json!({}),
|
||||
&cookie,
|
||||
None,
|
||||
None,
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::SERVICE_UNAVAILABLE);
|
||||
assert_eq!(resp.status(), StatusCode::FORBIDDEN);
|
||||
}
|
||||
|
||||
|
||||
#[sqlx::test(migrations = "./migrations")]
|
||||
async fn csrf_falls_back_to_referer_when_origin_missing(pool: PgPool) {
|
||||
let h = harness_with_admin_origins(
|
||||
@@ -422,11 +430,13 @@ async fn csrf_skipped_on_safe_methods(pool: PgPool) {
|
||||
}
|
||||
|
||||
#[sqlx::test(migrations = "./migrations")]
|
||||
async fn csrf_disabled_when_allowlist_empty(pool: PgPool) {
|
||||
// Default harness has admin_allowed_origins = empty → operator
|
||||
// opt-out documented in .env.example. A cross-origin POST passes
|
||||
// through to the handler.
|
||||
let h = harness(pool.clone());
|
||||
async fn csrf_rejects_cookie_auth_when_allowlist_empty(pool: PgPool) {
|
||||
// Previously an empty allowlist silently skipped the CSRF check, so an
|
||||
// operator who forgot to set ADMIN_ALLOWED_ORIGINS shipped an unguarded
|
||||
// admin surface. We now fail-closed for cookie-auth admin mutations
|
||||
// when the allowlist is empty — operators must either set the env var
|
||||
// (browser deploy) or authenticate with `Authorization: Bearer …`.
|
||||
let h = harness_with_admin_origins(pool.clone(), vec![]);
|
||||
let cookie = seed_admin(&pool, &h.app).await;
|
||||
let resp = h
|
||||
.app
|
||||
@@ -440,7 +450,7 @@ async fn csrf_disabled_when_allowlist_empty(pool: PgPool) {
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::SERVICE_UNAVAILABLE);
|
||||
assert_eq!(resp.status(), StatusCode::FORBIDDEN);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -237,7 +237,15 @@ async fn put_analysis_triggers_reload_and_flips_gate(pool: PgPool) {
|
||||
.clone()
|
||||
.oneshot(common::put_json_with_cookie(
|
||||
"/api/v1/admin/settings/analysis",
|
||||
json!({ "enabled": true, "model": "qwen2-vl", "temperature": 0.4 }),
|
||||
// Endpoint must pass `ensure_public_target` whenever
|
||||
// `enabled=true`. Use the documented docker-internal DNS
|
||||
// name; a literal IP/localhost would (rightly) 422.
|
||||
json!({
|
||||
"enabled": true,
|
||||
"endpoint": "http://mangalord-vision:8000/v1/chat/completions",
|
||||
"model": "qwen2-vl",
|
||||
"temperature": 0.4,
|
||||
}),
|
||||
&cookie,
|
||||
))
|
||||
.await
|
||||
|
||||
@@ -22,6 +22,13 @@ use mangalord::storage::{LocalStorage, PutByteStream, Storage, StorageError, Str
|
||||
use async_trait::async_trait;
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
|
||||
/// The CSRF `Origin` value the default test harness allowlists and the
|
||||
/// cookie-bearing helpers auto-attach. Keeping it a const here means every
|
||||
/// non-CSRF-focused test inherits the matching pair without thinking about
|
||||
/// it; the CSRF-focused tests use `post_json_with_cookie_origin` to drive
|
||||
/// specific Origin/Referer combinations.
|
||||
pub const TEST_ORIGIN: &str = "http://test";
|
||||
|
||||
pub struct Harness {
|
||||
pub app: Router,
|
||||
// Kept alive for the lifetime of the test so the temp dir is not dropped.
|
||||
@@ -82,9 +89,13 @@ fn harness_with_auth_config(
|
||||
reloader: None,
|
||||
crawler_base: CrawlerConfig::default(),
|
||||
analysis_base: AnalysisConfig::default(),
|
||||
// Empty allowlist = CSRF check skipped. The CSRF-specific test
|
||||
// harness `harness_with_admin_origins` overrides this.
|
||||
admin_allowed_origins: Arc::new(Vec::new()),
|
||||
// The router's CSRF guard now fails-closed on cookie-auth POSTs
|
||||
// when the allowlist is empty. Seed the default harness with the
|
||||
// sentinel test origin (`TEST_ORIGIN`) so the cookie-auth helpers
|
||||
// below — which auto-attach `Origin: TEST_ORIGIN` — pass through.
|
||||
// The CSRF-specific tests override via
|
||||
// `post_json_with_cookie_origin` and `harness_with_admin_origins`.
|
||||
admin_allowed_origins: Arc::new(vec![TEST_ORIGIN.to_string()]),
|
||||
analysis_events: Arc::new(mangalord::analysis::events::AnalysisEvents::new()),
|
||||
};
|
||||
Harness { app: router(state), _storage_dir: storage_dir }
|
||||
@@ -165,7 +176,7 @@ pub fn harness_with_resync(
|
||||
reloader: None,
|
||||
crawler_base: CrawlerConfig::default(),
|
||||
analysis_base: AnalysisConfig::default(),
|
||||
admin_allowed_origins: Arc::new(Vec::new()),
|
||||
admin_allowed_origins: Arc::new(vec![TEST_ORIGIN.to_string()]),
|
||||
analysis_events: Arc::new(mangalord::analysis::events::AnalysisEvents::new()),
|
||||
};
|
||||
Harness {
|
||||
@@ -198,7 +209,7 @@ pub fn harness_with_analysis(pool: PgPool) -> Harness {
|
||||
reloader: None,
|
||||
crawler_base: CrawlerConfig::default(),
|
||||
analysis_base: AnalysisConfig::default(),
|
||||
admin_allowed_origins: Arc::new(Vec::new()),
|
||||
admin_allowed_origins: Arc::new(vec![TEST_ORIGIN.to_string()]),
|
||||
analysis_events: Arc::new(mangalord::analysis::events::AnalysisEvents::new()),
|
||||
};
|
||||
Harness {
|
||||
@@ -260,7 +271,7 @@ pub fn harness_with_settings_reloader(pool: PgPool) -> (Harness, Arc<StubReloade
|
||||
reloader: Some(Arc::clone(&reloader) as Arc<dyn mangalord::app::DaemonReloader>),
|
||||
crawler_base: CrawlerConfig::default(),
|
||||
analysis_base: AnalysisConfig::default(),
|
||||
admin_allowed_origins: Arc::new(Vec::new()),
|
||||
admin_allowed_origins: Arc::new(vec![TEST_ORIGIN.to_string()]),
|
||||
analysis_events: Arc::new(mangalord::analysis::events::AnalysisEvents::new()),
|
||||
};
|
||||
(
|
||||
@@ -406,11 +417,16 @@ pub fn post_json_with_cookie(
|
||||
body: serde_json::Value,
|
||||
cookie: &str,
|
||||
) -> Request<Body> {
|
||||
// Origin matches the default-harness allowlist (see TEST_ORIGIN) so
|
||||
// cookie-auth POSTs survive the production-grade CSRF guard. CSRF-
|
||||
// focused tests use `post_json_with_cookie_origin` to drive bespoke
|
||||
// Origin/Referer values.
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri(uri)
|
||||
.header(header::CONTENT_TYPE, "application/json")
|
||||
.header(header::COOKIE, cookie)
|
||||
.header(header::ORIGIN, TEST_ORIGIN)
|
||||
.body(Body::from(body.to_string()))
|
||||
.unwrap()
|
||||
}
|
||||
@@ -486,6 +502,7 @@ pub fn patch_json_with_cookie(
|
||||
.uri(uri)
|
||||
.header(header::CONTENT_TYPE, "application/json")
|
||||
.header(header::COOKIE, cookie)
|
||||
.header(header::ORIGIN, TEST_ORIGIN)
|
||||
.body(Body::from(body.to_string()))
|
||||
.unwrap()
|
||||
}
|
||||
@@ -500,6 +517,7 @@ pub fn put_json_with_cookie(
|
||||
.uri(uri)
|
||||
.header(header::CONTENT_TYPE, "application/json")
|
||||
.header(header::COOKIE, cookie)
|
||||
.header(header::ORIGIN, TEST_ORIGIN)
|
||||
.body(Body::from(body.to_string()))
|
||||
.unwrap()
|
||||
}
|
||||
@@ -509,6 +527,7 @@ pub fn delete_with_cookie(uri: &str, cookie: &str) -> Request<Body> {
|
||||
.method("DELETE")
|
||||
.uri(uri)
|
||||
.header(header::COOKIE, cookie)
|
||||
.header(header::ORIGIN, TEST_ORIGIN)
|
||||
.body(Body::empty())
|
||||
.unwrap()
|
||||
}
|
||||
@@ -630,6 +649,7 @@ pub fn post_multipart_with_cookie(
|
||||
format!("multipart/form-data; boundary={boundary}"),
|
||||
)
|
||||
.header(header::COOKIE, cookie)
|
||||
.header(header::ORIGIN, TEST_ORIGIN)
|
||||
.body(Body::from(body))
|
||||
.unwrap()
|
||||
}
|
||||
@@ -648,6 +668,7 @@ pub fn put_multipart_with_cookie(
|
||||
format!("multipart/form-data; boundary={boundary}"),
|
||||
)
|
||||
.header(header::COOKIE, cookie)
|
||||
.header(header::ORIGIN, TEST_ORIGIN)
|
||||
.body(Body::from(body))
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user