diff --git a/backend/Cargo.lock b/backend/Cargo.lock index 0bbae31..f65dd37 100644 --- a/backend/Cargo.lock +++ b/backend/Cargo.lock @@ -1517,7 +1517,7 @@ checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4" [[package]] name = "mangalord" -version = "0.87.1" +version = "0.87.2" dependencies = [ "anyhow", "argon2", diff --git a/backend/Cargo.toml b/backend/Cargo.toml index 36cdf93..b9ad188 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "mangalord" -version = "0.87.1" +version = "0.87.2" edition = "2021" default-run = "mangalord" diff --git a/backend/src/app.rs b/backend/src/app.rs index aadb207..656addf 100644 --- a/backend/src/app.rs +++ b/backend/src/app.rs @@ -413,6 +413,11 @@ fn spawn_analysis_daemon( ) -> anyhow::Result { 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, 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); diff --git a/backend/src/crawler/safety.rs b/backend/src/crawler/safety.rs index b9af92a..2ed3c48 100644 --- a/backend/src/crawler/safety.rs +++ b/backend/src/crawler/safety.rs @@ -124,6 +124,34 @@ impl DownloadAllowlist { /// An empty allowlist rejects everything (the conservative default — /// callers must explicitly allow the catalog and CDN hosts). pub fn is_safe_url(raw_url: &str, allow: &DownloadAllowlist) -> Result<(), UrlSafetyError> { + let url = ensure_public_target_inner(raw_url)?; + let lower_host = url + .host_str() + .expect("host validated by ensure_public_target_inner") + .to_ascii_lowercase(); + if !allow.contains(&lower_host) { + return Err(UrlSafetyError::HostNotAllowed(lower_host)); + } + Ok(()) +} + +/// Validate that an admin-supplied URL points at a publicly-routable target: +/// scheme is http or https, host is present, host isn't `localhost`, and (if +/// the host is an IP literal) it isn't loopback/private/link-local/CGNAT/etc. +/// +/// Used to validate `endpoint`-style admin settings (crawler `start_url`, +/// analysis `endpoint`) where there is no per-deployment allowlist to consult, +/// but where a hostile or careless admin value (e.g. `http://169.254.169.254/`, +/// `http://127.0.0.1:5432/`) would let the worker pivot inside the deployment. +/// +/// Note: DNS hostnames (`mangalord-vision`, `vision.internal.corp`) pass — the +/// check is on *literal* IP private-range strings only, so the documented +/// docker-internal vision endpoint keeps working. +pub fn ensure_public_target(raw_url: &str) -> Result<(), UrlSafetyError> { + ensure_public_target_inner(raw_url).map(|_| ()) +} + +fn ensure_public_target_inner(raw_url: &str) -> Result { let url = Url::parse(raw_url).map_err(|_| UrlSafetyError::Unparseable)?; let scheme = url.scheme(); if scheme != "http" && scheme != "https" { @@ -148,10 +176,7 @@ pub fn is_safe_url(raw_url: &str, allow: &DownloadAllowlist) -> Result<(), UrlSa return Err(UrlSafetyError::PrivateIp(ip)); } } - if !allow.contains(&lower_host) { - return Err(UrlSafetyError::HostNotAllowed(lower_host)); - } - Ok(()) + Ok(url) } fn is_private_ip(ip: &IpAddr) -> bool { @@ -383,6 +408,71 @@ mod tests { )); } + // --- ensure_public_target (allowlist-free admin URL validation) --- + + #[test] + fn public_target_allows_dns_hostnames() { + // The whole point of this helper is to validate admin-supplied + // endpoint URLs (analysis vision endpoint, crawler start_url) + // WITHOUT consulting an allowlist. Docker-internal DNS names + // (which resolve at runtime to private IPs) MUST pass. + assert!(ensure_public_target("http://mangalord-vision:8000/v1/chat/completions").is_ok()); + assert!(ensure_public_target("https://api.openai.com/v1/chat/completions").is_ok()); + assert!(ensure_public_target("https://example.com/").is_ok()); + } + + #[test] + fn public_target_blocks_ip_literal_attacks() { + // Literal IPs in private/loopback/link-local ranges — the routes + // an attacker would actually use (AWS IMDS, postgres on + // 127.0.0.1, RFC1918 inside a corp network). + for url in [ + "http://169.254.169.254/latest/meta-data/", + "http://127.0.0.1:5432/", + "http://10.0.0.1/", + "http://192.168.1.1/", + "http://[::1]/", + "http://[::ffff:127.0.0.1]/", + "http://0.0.0.0/", + ] { + assert!( + matches!( + ensure_public_target(url).unwrap_err(), + UrlSafetyError::PrivateIp(_) + ), + "must reject {url}" + ); + } + } + + #[test] + fn public_target_blocks_localhost_hostname() { + assert!(matches!( + ensure_public_target("http://localhost:5432/").unwrap_err(), + UrlSafetyError::Loopback + )); + } + + #[test] + fn public_target_blocks_non_http_schemes() { + for url in ["file:///etc/passwd", "gopher://x.example/", "ftp://x/"] { + assert!(matches!( + ensure_public_target(url).unwrap_err(), + UrlSafetyError::BadScheme(_) + )); + } + } + + #[test] + fn public_target_rejects_unparseable() { + assert!(matches!( + ensure_public_target("not a url").unwrap_err(), + UrlSafetyError::Unparseable + )); + } + + // --- back to is_safe_url --- + #[test] fn safe_url_blocks_rfc1918() { let allow = allow_just("10.0.0.1"); diff --git a/backend/src/settings.rs b/backend/src/settings.rs index b9c6e2b..925b4ed 100644 --- a/backend/src/settings.rs +++ b/backend/src/settings.rs @@ -153,8 +153,13 @@ impl CrawlerSettings { errs.push("chapter_workers", "must be at least 1"); } if let Some(url) = self.start_url.as_deref().map(str::trim).filter(|s| !s.is_empty()) { - if reqwest::Url::parse(url).is_err() { - errs.push("start_url", "must be a valid absolute URL"); + // SSRF defence: Url::parse alone admits http://169.254.169.254 + // / http://127.0.0.1:5432 etc., and the browser would happily + // navigate there with the admin's session. ensure_public_target + // refuses literal-IP private ranges + localhost while still + // accepting public hosts. + if let Err(e) = crate::crawler::safety::ensure_public_target(url) { + errs.push("start_url", url_safety_message(&e)); } } if self.job_timeout_secs < 1 { @@ -231,6 +236,22 @@ impl CrawlerSettings { } } +/// Render a [`crate::crawler::safety::UrlSafetyError`] as an operator-friendly +/// validation message. The variant detail is preserved so a deliberate +/// `10.0.0.1` value gets "private/loopback IP not allowed" rather than the +/// generic "invalid URL". +fn url_safety_message(e: &crate::crawler::safety::UrlSafetyError) -> &'static str { + use crate::crawler::safety::UrlSafetyError as E; + match e { + E::Unparseable => "must be a valid absolute URL", + E::BadScheme(_) => "must use http:// or https://", + E::NoHost => "must include a host", + E::Loopback => "must not point at localhost", + E::PrivateIp(_) => "must not point at a private/loopback/link-local IP (SSRF risk)", + E::HostNotAllowed(_) => "host is not on the allowlist", + } +} + /// Rebuild a [`DownloadAllowlist`] from the DTO, always seeding the start-url /// and CDN hosts (mirrors `config::build_download_allowlist`). fn build_allowlist( @@ -334,8 +355,29 @@ impl AnalysisSettings { if self.workers < 1 { errs.push("workers", "must be at least 1"); } - if self.endpoint.trim().is_empty() || reqwest::Url::parse(self.endpoint.trim()).is_err() { + let trimmed_endpoint = self.endpoint.trim(); + if trimmed_endpoint.is_empty() { errs.push("endpoint", "must be a valid absolute URL"); + } else if let Err(e) = reqwest::Url::parse(trimmed_endpoint) { + // Always reject obviously-malformed URLs (matches prior behaviour). + let _ = e; + errs.push("endpoint", "must be a valid absolute URL"); + } else if self.enabled { + // SSRF + API-key exfiltration defence — only enforced when the + // worker is actually live. Worker attaches an env-managed bearer + // token to every call; without this check, an admin (or one + // CSRF-able request) could repoint the endpoint at + // http://169.254.169.254 (cloud metadata) or http://postgres:5432 + // (an internal service that would receive the bearer header). + // Docker DNS names (`mangalord-vision`) still pass because + // they're hostnames, not IP literals. + // + // A disabled config is allowed to carry the dev-default + // `http://localhost:8000/...` placeholder; the worker won't dial + // it, and toggling `enabled=true` later re-runs this validator. + if let Err(e) = crate::crawler::safety::ensure_public_target(trimmed_endpoint) { + errs.push("endpoint", url_safety_message(&e)); + } } if self.enabled && self.model.trim().is_empty() { errs.push("model", "required when analysis is enabled"); @@ -525,6 +567,37 @@ mod tests { assert!(!out.download_allowlist.is_allow_any()); } + #[test] + fn crawler_start_url_rejects_ip_literal_attacks() { + let base = CrawlerConfig::default(); + for url in [ + "http://169.254.169.254/", + "http://127.0.0.1/catalog", + "http://localhost:5432/", + "http://10.0.0.1/", + ] { + let dto = CrawlerSettings { + start_url: Some(url.to_string()), + ..CrawlerSettings::from_config(&base) + }; + let errs = dto.to_config(&base).unwrap_err(); + let fields: Vec<_> = errs.errors.iter().map(|e| e.field.as_str()).collect(); + assert!(fields.contains(&"start_url"), "must reject {url}"); + } + } + + #[test] + fn crawler_start_url_accepts_public_targets() { + let base = CrawlerConfig::default(); + for url in ["https://catalog.example.com/", "http://catalog.example.com/"] { + let dto = CrawlerSettings { + start_url: Some(url.to_string()), + ..CrawlerSettings::from_config(&base) + }; + assert!(dto.to_config(&base).is_ok(), "must accept {url}"); + } + } + #[test] fn crawler_allow_any_host_bypasses_list() { let base = CrawlerConfig::default(); @@ -617,6 +690,65 @@ mod tests { } } + #[test] + fn analysis_endpoint_rejects_ip_literal_attacks_when_enabled() { + // The vision worker bearer-attaches an env secret to every call; + // a hostile/CSRF-able admin must NOT be able to point endpoint at + // cloud metadata, loopback services, or RFC1918 hosts — when the + // worker is enabled (toggling enabled=true later re-runs this gate). + let base = AnalysisConfig::default(); + for url in [ + "http://169.254.169.254/v1/chat/completions", + "http://127.0.0.1:5432/", + "http://localhost:11434/v1/chat/completions", + "http://10.0.0.5/v1/chat/completions", + ] { + let dto = AnalysisSettings { + enabled: true, + model: "test-model".to_string(), + endpoint: url.to_string(), + ..AnalysisSettings::from_config(&base) + }; + let errs = dto.to_config(&base).unwrap_err(); + let fields: Vec<_> = errs.errors.iter().map(|e| e.field.as_str()).collect(); + assert!(fields.contains(&"endpoint"), "must reject {url}"); + } + } + + #[test] + fn analysis_endpoint_accepts_docker_dns_and_public_hosts_when_enabled() { + // The documented default — docker DNS name resolving to a private IP + // at runtime — must still validate, because the bearer recipient + // identity is the operator-chosen hostname, not the underlying IP. + let base = AnalysisConfig::default(); + for url in [ + "http://mangalord-vision:8000/v1/chat/completions", + "https://api.openai.com/v1/chat/completions", + ] { + let dto = AnalysisSettings { + enabled: true, + model: "test-model".to_string(), + endpoint: url.to_string(), + ..AnalysisSettings::from_config(&base) + }; + assert!(dto.to_config(&base).is_ok(), "must accept {url}"); + } + } + + #[test] + fn analysis_endpoint_skips_safety_check_when_disabled() { + // Disabled-but-saved bad URL is harmless (worker won't dial it); + // operators get to keep the dev-localhost placeholder until they + // toggle the worker on, when the validator re-runs. + let base = AnalysisConfig::default(); + let dto = AnalysisSettings { + enabled: false, + endpoint: "http://localhost:8000/v1/chat/completions".to_string(), + ..AnalysisSettings::from_config(&base) + }; + assert!(dto.to_config(&base).is_ok()); + } + #[test] fn analysis_requires_model_only_when_enabled() { let base = AnalysisConfig::default(); diff --git a/backend/tests/api_admin_crawler.rs b/backend/tests/api_admin_crawler.rs index 13ecd9c..ef9f44a 100644 --- a/backend/tests/api_admin_crawler.rs +++ b/backend/tests/api_admin_crawler.rs @@ -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); } // --------------------------------------------------------------------------- diff --git a/backend/tests/api_admin_health.rs b/backend/tests/api_admin_health.rs index 6557857..25a5150 100644 --- a/backend/tests/api_admin_health.rs +++ b/backend/tests/api_admin_health.rs @@ -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::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, diff --git a/backend/tests/api_admin_settings.rs b/backend/tests/api_admin_settings.rs index cf92502..392b288 100644 --- a/backend/tests/api_admin_settings.rs +++ b/backend/tests/api_admin_settings.rs @@ -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 diff --git a/backend/tests/common/mod.rs b/backend/tests/common/mod.rs index 5438909..a8475da 100644 --- a/backend/tests/common/mod.rs +++ b/backend/tests/common/mod.rs @@ -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), 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 { + // 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 { .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() } diff --git a/frontend/package.json b/frontend/package.json index 20a8f02..f9b4fa6 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "mangalord-frontend", - "version": "0.87.1", + "version": "0.87.2", "private": true, "type": "module", "scripts": {