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:
@@ -413,6 +413,11 @@ fn spawn_analysis_daemon(
|
||||
) -> anyhow::Result<crate::analysis::daemon::AnalysisDaemonHandle> {
|
||||
let http = reqwest::Client::builder()
|
||||
.timeout(cfg.request_timeout)
|
||||
// Refuse to honour ambient HTTP_PROXY / HTTPS_PROXY container env.
|
||||
// The vision call carries an env-managed bearer token + page image
|
||||
// bytes; a stray upstream proxy would exfiltrate both. Mirrors the
|
||||
// crawler client's `.no_proxy()` (see `spawn_crawler_daemon`).
|
||||
.no_proxy()
|
||||
.build()
|
||||
.context("build analysis http client")?;
|
||||
let vision = crate::analysis::vision::VisionClient::new(http, cfg);
|
||||
@@ -432,6 +437,11 @@ fn spawn_analysis_daemon(
|
||||
Some(url) if !url.is_empty() => {
|
||||
let probe = reqwest::Client::builder()
|
||||
.timeout(std::time::Duration::from_secs(5))
|
||||
// Same reasoning as the main analysis client: do not
|
||||
// honour ambient HTTP_PROXY env. The readiness probe is
|
||||
// unauthenticated but a hostile upstream still gets a
|
||||
// useful side-channel on backend uptime + vision health.
|
||||
.no_proxy()
|
||||
.build()
|
||||
.context("build vision readiness http client")?;
|
||||
Some(Arc::new(crate::analysis::daemon::HttpVisionReadiness {
|
||||
@@ -1028,10 +1038,24 @@ const ADMIN_PATH_PREFIX: &str = "/api/v1/admin/";
|
||||
/// from a malicious page — this middleware rejects such requests by
|
||||
/// comparing the request's `Origin` (with `Referer` as fallback) against
|
||||
/// the configured allowlist. Safe methods (`GET`/`HEAD`/`OPTIONS`) are
|
||||
/// always allowed. Requests with neither `Origin` nor `Referer` are
|
||||
/// allowed (non-browser callers like curl can't be a CSRF vector). When
|
||||
/// the allowlist is empty the check is skipped entirely (operator
|
||||
/// opt-out — documented in `.env.example`).
|
||||
/// always allowed.
|
||||
///
|
||||
/// **Bearer-token requests** (`Authorization: Bearer …`) are bot API
|
||||
/// callers — they can't be a CSRF vector because the browser never
|
||||
/// attaches that header automatically. Skip the check for them.
|
||||
///
|
||||
/// **Cookie-auth requests** must:
|
||||
/// * be from an allowed origin (`Origin` then `Referer`), AND
|
||||
/// * have at least one of those headers present (a browser always
|
||||
/// sends one on a cross-site POST; a missing pair on a cookie-auth
|
||||
/// request is the exact niche an extension / no-referrer-policy
|
||||
/// CSRF would try to exploit).
|
||||
///
|
||||
/// When the allowlist is empty we fail-closed for cookie-auth requests
|
||||
/// — this used to silently let everything through, so an operator who
|
||||
/// forgot to set `ADMIN_ALLOWED_ORIGINS` shipped an unguarded admin
|
||||
/// surface. Operators on a pure-bot-token deploy aren't impacted (their
|
||||
/// requests carry `Authorization: Bearer …` and skip the gate above).
|
||||
async fn admin_csrf_guard(
|
||||
State(state): State<AppState>,
|
||||
req: Request,
|
||||
@@ -1046,16 +1070,56 @@ async fn admin_csrf_guard(
|
||||
) {
|
||||
return Ok(next.run(req).await);
|
||||
}
|
||||
if state.admin_allowed_origins.is_empty() {
|
||||
let headers = req.headers();
|
||||
// Bearer-token requests are non-browser bot callers and bypass the
|
||||
// gate entirely (the Authorization header is never set by an
|
||||
// attacker page).
|
||||
let is_bearer = headers
|
||||
.get(axum::http::header::AUTHORIZATION)
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.is_some_and(|v| v.trim_start().to_ascii_lowercase().starts_with("bearer "));
|
||||
if is_bearer {
|
||||
return Ok(next.run(req).await);
|
||||
}
|
||||
// No session cookie either → let the auth extractor return a clean 401
|
||||
// ("log in first"). A no-auth request can't be a CSRF vector — there's
|
||||
// no authority to ride. Without this, an anonymous curl POST to an
|
||||
// admin endpoint surfaces 403 with our CSRF message instead of the
|
||||
// expected 401, which is confusing for both operators and tests.
|
||||
let has_session_cookie = headers
|
||||
.get(axum::http::header::COOKIE)
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.is_some_and(|raw| raw.split(';').any(|p| {
|
||||
p.trim_start().starts_with("mangalord_session=")
|
||||
}));
|
||||
if !has_session_cookie {
|
||||
return Ok(next.run(req).await);
|
||||
}
|
||||
let headers = req.headers();
|
||||
let origin = headers.get("origin").and_then(|v| v.to_str().ok());
|
||||
let referer = headers.get("referer").and_then(|v| v.to_str().ok());
|
||||
// No Origin AND no Referer → server-to-server / curl / extension.
|
||||
// Browsers always send one or the other on a cross-site POST.
|
||||
let Some(candidate) = origin.or(referer) else {
|
||||
return Ok(next.run(req).await);
|
||||
let candidate = origin.or(referer);
|
||||
|
||||
if state.admin_allowed_origins.is_empty() {
|
||||
// Fail-closed: cookie-auth admin mutations without an allowlist
|
||||
// configuration are refused. Set `ADMIN_ALLOWED_ORIGINS` for the
|
||||
// browser deployment, or call with `Authorization: Bearer …`.
|
||||
tracing::warn!(
|
||||
path = %req.uri().path(),
|
||||
"admin CSRF: ADMIN_ALLOWED_ORIGINS is empty — cookie-auth admin mutations are refused (set the env var for browser-exposed deploys)"
|
||||
);
|
||||
return Err(AppError::Forbidden);
|
||||
}
|
||||
|
||||
// Cookie-auth path with an allowlist configured: a browser always
|
||||
// sends Origin or Referer on a cross-site POST. A missing pair is
|
||||
// either a no-referrer-policy edge case or a tool deliberately
|
||||
// hiding origin — refuse rather than risk it.
|
||||
let Some(candidate) = candidate else {
|
||||
tracing::warn!(
|
||||
path = %req.uri().path(),
|
||||
"admin CSRF: cookie-auth request with neither Origin nor Referer — refusing"
|
||||
);
|
||||
return Err(AppError::Forbidden);
|
||||
};
|
||||
if origin_in_allowlist(candidate, &state.admin_allowed_origins) {
|
||||
return Ok(next.run(req).await);
|
||||
|
||||
Reference in New Issue
Block a user