fix: don't apply the SSRF DNS resolver to proxied/internal clients
All checks were successful
deploy / test-backend (push) Successful in 39m39s
deploy / test-frontend (push) Successful in 10m52s
deploy / build-and-push (push) Successful in 12m5s
deploy / deploy (push) Successful in 13s

ff4ca96 wired SafeResolver (drops any host resolving to a private IP) into
all four crawler/analysis reqwest clients. That broke every proxied fetch:
with a socks5h:// proxy the target is resolved by Tor, so reqwest only ever
resolves the proxy's OWN host — `tor`, on a private Docker IP (172.x) — which
the resolver then rejects ("SOCKS error: failed to create underlying
connection"). 100% of crawler image downloads failed. The vision clients
broke the same way against `mangalord-vision` (latent; analysis was off).

Fix: attach the resolver only on the crawler's direct (unproxied) path, where
it genuinely guards DNS rebinding. Behind a proxy it adds no protection (Tor
can't reach internal ranges and the target is invisible to reqwest) so it's
omitted. Drop it from the vision clients entirely — that endpoint is an
operator-configured internal service that must resolve to a private IP.

Security posture is >= the pre-ff4ca96 baseline (which had no resolver at all).
is_private_ip / retain_public_addrs and their tests are unchanged.

Bump 0.124.13.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-10 22:20:25 +02:00
parent f879ce1866
commit 134ab54b34
5 changed files with 29 additions and 14 deletions

2
backend/Cargo.lock generated
View File

@@ -1558,7 +1558,7 @@ checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4"
[[package]] [[package]]
name = "mangalord" name = "mangalord"
version = "0.124.12" version = "0.124.13"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"argon2", "argon2",

View File

@@ -1,6 +1,6 @@
[package] [package]
name = "mangalord" name = "mangalord"
version = "0.124.12" version = "0.124.13"
edition = "2021" edition = "2021"
default-run = "mangalord" default-run = "mangalord"

View File

@@ -476,10 +476,12 @@ async fn spawn_analysis_daemon(
// endpoint is a single admin-configured URL), so the policy // endpoint is a single admin-configured URL), so the policy
// enforces scheme + private-IP only. // enforces scheme + private-IP only.
.redirect(crate::crawler::safety::public_redirect_policy()) .redirect(crate::crawler::safety::public_redirect_policy())
// Filter resolved IPs: a hostname that resolves to an internal // NOTE: deliberately no `.dns_resolver(safe_dns_resolver())`.
// address (DNS rebinding) is refused at connect time, not just // The vision endpoint is a single operator-configured internal
// by the string check above. // service (e.g. `mangalord-vision`) that legitimately resolves
.dns_resolver(crate::crawler::safety::safe_dns_resolver()) // to a private Docker IP; a private-IP-rejecting resolver drops
// every call. Redirect hops stay guarded by the policy above,
// and the URL is operator-set, not attacker-controlled input.
.build() .build()
.context("build analysis http client")?; .context("build analysis http client")?;
let vision = crate::analysis::vision::VisionClient::new(http, cfg); let vision = crate::analysis::vision::VisionClient::new(http, cfg);
@@ -506,7 +508,8 @@ async fn spawn_analysis_daemon(
// uptime + vision health. // uptime + vision health.
.no_proxy() .no_proxy()
.redirect(crate::crawler::safety::public_redirect_policy()) .redirect(crate::crawler::safety::public_redirect_policy())
.dns_resolver(crate::crawler::safety::safe_dns_resolver()) // No private-IP resolver: same operator-configured
// internal vision host as the analysis client above.
.build() .build()
.context("build vision readiness http client")?; .context("build vision readiness http client")?;
Some(Arc::new(crate::analysis::daemon::HttpVisionReadiness { Some(Arc::new(crate::analysis::daemon::HttpVisionReadiness {
@@ -595,10 +598,6 @@ async fn spawn_crawler_daemon(
.redirect(crate::crawler::safety::safe_redirect_policy( .redirect(crate::crawler::safety::safe_redirect_policy(
cfg.download_allowlist.clone(), cfg.download_allowlist.clone(),
)) ))
// Reject any host that resolves to a private/internal IP (DNS
// rebinding), complementing the string-level allowlist/private-IP
// check which can't see post-resolution addresses.
.dns_resolver(crate::crawler::safety::safe_dns_resolver())
.cookie_provider(Arc::clone(&cookie_jar)); .cookie_provider(Arc::clone(&cookie_jar));
if let Some(ua) = &cfg.user_agent { if let Some(ua) = &cfg.user_agent {
http_builder = http_builder.user_agent(ua); http_builder = http_builder.user_agent(ua);
@@ -606,6 +605,17 @@ async fn spawn_crawler_daemon(
if let Some(proxy) = &cfg.proxy { if let Some(proxy) = &cfg.proxy {
http_builder = http_builder http_builder = http_builder
.proxy(reqwest::Proxy::all(proxy).with_context(|| format!("parse proxy: {proxy}"))?); .proxy(reqwest::Proxy::all(proxy).with_context(|| format!("parse proxy: {proxy}"))?);
} else {
// DNS-rebinding guard: reject hosts that resolve to a private/internal
// IP, complementing the string-level allowlist check which can't see
// post-resolution addresses. ONLY on the direct (unproxied) path. With
// a `socks5h://` proxy the target hostname is resolved by the proxy —
// reqwest never resolves it — so the only name this resolver would ever
// see is the proxy's OWN host, which legitimately lives on a private
// Docker IP (e.g. `tor` → 172.x). Attaching it there rejected every
// fetch ("SOCKS error: failed to create underlying connection") for
// zero security gain, since Tor can't route to internal ranges anyway.
http_builder = http_builder.dns_resolver(crate::crawler::safety::safe_dns_resolver());
} }
let http = http_builder.build().context("build crawler reqwest")?; let http = http_builder.build().context("build crawler reqwest")?;

View File

@@ -126,8 +126,6 @@ async fn main() -> anyhow::Result<()> {
.redirect(mangalord::crawler::safety::safe_redirect_policy( .redirect(mangalord::crawler::safety::safe_redirect_policy(
(*allowlist).clone(), (*allowlist).clone(),
)) ))
// Reject hosts that resolve to private/internal IPs (DNS rebinding).
.dns_resolver(mangalord::crawler::safety::safe_dns_resolver())
.cookie_provider(cookie_jar); .cookie_provider(cookie_jar);
if let Some(ua) = &user_agent { if let Some(ua) = &user_agent {
http_builder = http_builder.user_agent(ua); http_builder = http_builder.user_agent(ua);
@@ -135,6 +133,13 @@ async fn main() -> anyhow::Result<()> {
if let Some(proxy) = &proxy_url { if let Some(proxy) = &proxy_url {
http_builder = http_builder http_builder = http_builder
.proxy(reqwest::Proxy::all(proxy).with_context(|| format!("parse proxy URL: {proxy}"))?); .proxy(reqwest::Proxy::all(proxy).with_context(|| format!("parse proxy URL: {proxy}"))?);
} else {
// DNS-rebinding guard, direct (unproxied) path only — see the matching
// note in `spawn_crawler_daemon` (app.rs). With a `socks5h://` proxy the
// target is resolved by the proxy, so this resolver would only ever
// reject the proxy's own private-IP host (e.g. `tor`), breaking every
// fetch for no security gain.
http_builder = http_builder.dns_resolver(mangalord::crawler::safety::safe_dns_resolver());
} }
let http = http_builder.build().context("build http client")?; let http = http_builder.build().context("build http client")?;

View File

@@ -1,6 +1,6 @@
{ {
"name": "mangalord-frontend", "name": "mangalord-frontend",
"version": "0.124.12", "version": "0.124.13",
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {