Compare commits

...

14 Commits

Author SHA1 Message Date
MechaCat02
f879ce1866 fix: per-IP auth rate limiting instead of one global bucket
All checks were successful
deploy / test-frontend (push) Successful in 10m44s
deploy / build-and-push (push) Successful in 11m35s
deploy / deploy (push) Successful in 12s
deploy / test-backend (push) Successful in 31m21s
A single global token bucket let one attacker at the sustained rate 429
every user's login/register/change-password. Key buckets by client IP: the
SvelteKit proxy now stamps the real peer address onto X-Forwarded-For
(overriding any client-supplied value, anti-spoof), and axum reads it via a
ClientIp extractor — but only when AUTH_TRUSTED_PROXY is set, else it falls
back to the shared bucket (today's behavior). The per-IP map is bounded
(10k IPs, idle buckets pruned) so a spoofed-IP spray can't grow it.

AUTH_TRUSTED_PROXY defaults false (safe for a directly-exposed backend);
compose sets it true since the proxy is the single trusted hop.

Bump to 0.124.12.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-08 07:07:06 +02:00
MechaCat02
bf425cf8e6 fix: opt-in CDP re-validation of headless-browser navigations (SSRF)
The reqwest DNS resolver can't see Chromium navigations, so a scraped page
that redirects the browser to an internal target (302 -> 127.0.0.1:5432, or
a rebinding hostname) was still loaded. Add crawler::intercept: an opt-in CDP
Fetch guard that re-validates every Document navigation/redirect through the
same ensure_public_target + resolved-IP check, failing internal targets.

Gated behind CRAWLER_SSRF_INTERCEPT (default OFF): enabling Fetch is a fragile
critical-path hook and the CDP wiring is not CI-verifiable (no Chromium in
CI). When off, open_page is byte-identical to browser.new_page; when on, a
guard-install failure falls back to an unguarded navigation rather than
wedging the crawl. The pure decision logic (verdict/is_blocked) is unit-tested;
an #[ignore] smoke test covers the CDP path. Validate with a manual crawl
before enabling in production.

Bump to 0.124.11.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-08 06:29:17 +02:00
MechaCat02
ff4ca964f5 fix: reject private IPs after DNS resolution (SSRF/DNS-rebinding)
The SSRF guard only checked the host string, so an attacker-owned domain
resolving to 169.254.169.254 / 10.x (DNS rebinding, TOCTOU) bypassed it.
Add a reqwest dns::Resolve (SafeResolver) that drops resolved addresses in
private ranges, wired into all four crawler/analysis clients — it fires per
connection so it also covers redirect hops. Also close the is_private_ip
IPv6-embedding gaps (IPv4-compatible ::/96, NAT64 64:ff9b::/96, 6to4
2002::/16 all now unwrap to the embedded IPv4).

Bump to 0.124.10.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-07 21:52:30 +02:00
MechaCat02
61669aac3f fix: don't mark private-mode blobs as publicly cacheable
The immutable Cache-Control added for the reader preload work was
`public` unconditionally. Under PRIVATE_MODE, /files is auth-gated, so a
shared cache/CDN would store the blob and serve it to unauthenticated
clients, defeating the gate. Emit `private, ...` when private_mode is on,
`public, ...` otherwise.

Found in the security audit; regression from 0.124.3. Bump to 0.124.9.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-07 21:39:20 +02:00
MechaCat02
795eb76f9a fix: document and wire the DB pool env vars into compose
The new DB_MAX_CONNECTIONS / DB_ACQUIRE_TIMEOUT_SECS knobs were read by the
backend but never added to .env.example or docker-compose.yml, so an operator
setting them in .env would see no effect (caught by the config meta-tests).
Document both in .env.example and wire them into backend.environment.

Bump to 0.124.8.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-07 21:18:49 +02:00
MechaCat02
ebf0b8289b fix: compute the /mangas total only on the first page
GET /mangas recomputed count(*) over FILTER_WHERE (up to five correlated
subqueries) on every page, so pagination cost scaled with catalog size. The
total doesn't change as a caller walks pages, so compute it once at offset 0
and return null thereafter (the envelope already serialises total as
Option<i64>; the frontend types it number|null and the library route ignores
it).

Bump to 0.124.7.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-07 21:14:15 +02:00
MechaCat02
ce9a727c73 fix: enforce the analysis image cap while reading, not after
The OCR and vision dispatchers read the whole page image into a Vec via
storage.get() and only then checked max_image_bytes, so the cap couldn't
bound the read. Stream via get_stream() + safety::accumulate_capped so the
read bails as soon as the running total exceeds the cap.

Bump to 0.124.6.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-07 21:10:29 +02:00
MechaCat02
46134c8760 fix: close Chromium tabs on crawler fetch error paths
new_page() opened a tab that was only closed on the happy path; a `?` on
wait_for_nav / content() leaked it (chromiumoxide doesn't close on drop),
so errored fetches accumulated tabs in the shared browser. Add a generic,
unit-tested `close_after(close, body)` helper and wrap the three fetch
sites (chapter content, session probe, source navigate) so the tab is
closed on every exit path — including navigate's content()-read branch,
which previously leaked.

Bump to 0.124.5.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-07 21:04:50 +02:00
MechaCat02
b7d8faadf7 fix: size the DB pool and fail fast on saturation
The single Postgres pool (shared by HTTP handlers and the crawler/analysis
daemons) hardcoded max_connections=10 with no acquire_timeout, so under
saturation callers hung on the driver's silent 30s default. Add a DbConfig
(DB_MAX_CONNECTIONS default 20, DB_ACQUIRE_TIMEOUT_SECS default 10, clamped
to >=1) and apply both in PgPoolOptions.

Bump to 0.124.4.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-07 20:56:14 +02:00
MechaCat02
b0500e8e48 fix: cache immutable blobs served from /files
Page and cover blobs are content-addressed by unguessable, immutable keys,
but /files sent no Cache-Control, so the browser refetched on every reader
open — defeating the whole-chapter and next-chapter preloading (and re-
proxying every byte through the node server in prod). Send
`Cache-Control: public, max-age=31536000, immutable`.

Bump to 0.124.3.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-07 20:30:03 +02:00
MechaCat02
379224bee4 fix: lazy-load continuous-reader pages beyond an eager window
Continuous mode rendered every page (up to 2000) with loading="eager",
fetching and decoding the whole chapter up front — enough to OOM a mobile
tab on a long chapter. Eager-load only a leading window ([0..=eagerThrough],
covering the deep-link target + a lead, min 6 pages) and lazy-load the rest;
the browser fetches them as the reader scrolls near. Unloaded lazy pages
reserve height (page width x ~1.4) so they stay distinct scroll targets for
the progress IntersectionObserver, cleared on load.

Keeps the DOM dense so the scroll-to-?page=N effect, progress observer, and
next-chapter preload trigger are all unchanged. Rewrote reader-preload-window
spec to assert the eager/lazy attribute partition + deep-link scroll landing.

Bump to 0.124.2.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-07 20:28:41 +02:00
MechaCat02
1c955458d6 fix: run Argon2 password hashing on the blocking pool
Argon2id hashing (~15-50ms, 19 MiB) ran synchronously inside async
handlers, stalling every task sharing those runtime worker threads under
concurrent auth load. Add spawn_blocking wrappers hash_password_async /
verify_password_async and switch all async call sites; sync primitives
stay for the login timing-equaliser and unit tests. Bump to 0.124.1.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-07 20:19:14 +02:00
MechaCat02
987d1ba235 feat: preload the whole current chapter in continuous mode
Eager-load every page of the current chapter rather than a bounded
rolling window, so pages are fetched up front (the browser paces them
over its per-origin connection limit, top-to-bottom = reading order)
instead of filling in as they're scrolled onto. This drops the
scrollLeadIdx/eagerThrough machinery in favour of a plain
`loading="eager"`.

The first few pages of the *next* chapter are already warmed when the
reader nears the end of the current one (the next-chapter preloader), so
flipping over stays instant.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-07 19:47:48 +02:00
MechaCat02
c69ff502f7 feat: preload the current chapter ahead of the reader
Continuous mode eager-loaded only pages 0..=initialIndex; everything
below stayed `loading="lazy"`, so pages fetched and reflowed in as they
scrolled into view. Now a rolling window eager-loads a few pages ahead of
the reading position (PRELOAD_AHEAD) so they arrive — fetched and sized —
while still below the fold, then swap in without pop-in.

The window rolls forward off `scrollLeadIdx`, the top-of-viewport page
measured from live rects and guarded by `scrollY`. It deliberately does
not use the read-progress high-water mark: before images load they are
0-height and pile into the viewport, which would latch that mark to the
last page and eager-load the whole chapter on open. Pages past the window
stay lazy so opening a long chapter doesn't fetch every page at once.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-07 19:39:50 +02:00
38 changed files with 1446 additions and 193 deletions

View File

@@ -25,6 +25,12 @@ DATABASE_URL=postgres://mangalord:mangalord@postgres:5432/mangalord
BIND_ADDRESS=0.0.0.0:8080 BIND_ADDRESS=0.0.0.0:8080
STORAGE_DIR=/var/lib/mangalord/storage STORAGE_DIR=/var/lib/mangalord/storage
RUST_LOG=info,mangalord=debug,chromiumoxide::conn=off,chromiumoxide::handler=off RUST_LOG=info,mangalord=debug,chromiumoxide::conn=off,chromiumoxide::handler=off
# Postgres connection-pool sizing. One pool serves HTTP handlers and the
# crawler/analysis daemons. DB_MAX_CONNECTIONS caps open connections;
# DB_ACQUIRE_TIMEOUT_SECS is how long a request waits for a free connection
# before failing fast (rather than hanging on the driver's 30s default).
DB_MAX_CONNECTIONS=20
DB_ACQUIRE_TIMEOUT_SECS=10
# ----- Auth / cookies ----- # ----- Auth / cookies -----
# COOKIE_SECURE controls whether the `Secure` flag is set on the session # COOKIE_SECURE controls whether the `Secure` flag is set on the session
@@ -45,6 +51,13 @@ SESSION_TTL_DAYS=30
# rate-limiting reverse proxy that already enforces a budget). # rate-limiting reverse proxy that already enforces a budget).
AUTH_RATE_PER_SEC=5 AUTH_RATE_PER_SEC=5
AUTH_RATE_BURST=10 AUTH_RATE_BURST=10
# Trust a proxy-supplied X-Forwarded-For as the client IP for per-IP auth
# rate limiting. Enable ONLY when the backend sits behind a trusted proxy
# that overrides the header (the compose deploy: SvelteKit forwards the real
# peer IP). When false, the header is ignored and a single shared bucket is
# used — a directly-exposed backend MUST keep this off or clients could spoof
# their IP to dodge the limit.
AUTH_TRUSTED_PROXY=false
# ----- CORS ----- # ----- CORS -----
# Comma-separated origins allowed to call the API with credentials. # Comma-separated origins allowed to call the API with credentials.
@@ -178,6 +191,12 @@ CRAWLER_METADATA_MAX_CONSECUTIVE_FAILURES=10
# exhausted) that trigger an automatic coordinated browser restart. # exhausted) that trigger an automatic coordinated browser restart.
# Default 3. # Default 3.
CRAWLER_BROWSER_RESTART_THRESHOLD=3 CRAWLER_BROWSER_RESTART_THRESHOLD=3
# Opt-in CDP Fetch interception that re-validates every headless-browser
# navigation/redirect against the SSRF check (blocks a scraped page that
# redirects the browser to an internal target). Default false — enabling
# Fetch is a fragile hook in the crawler's critical path and is not
# CI-verified; validate with a manual crawl before turning on.
CRAWLER_SSRF_INTERCEPT=false
# Path to a system Chromium binary. When set, the crawler skips the # Path to a system Chromium binary. When set, the crawler skips the
# bundled-fetcher download. Required on platforms without a usable # bundled-fetcher download. Required on platforms without a usable
# upstream Chromium build (notably Linux_arm64 / Raspberry Pi). On # upstream Chromium build (notably Linux_arm64 / Raspberry Pi). On

2
backend/Cargo.lock generated
View File

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

View File

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

View File

@@ -393,19 +393,20 @@ impl AnalyzeDispatcher for RealAnalyzeDispatcher {
// Page was deleted between enqueue and dispatch — nothing to do. // Page was deleted between enqueue and dispatch — nothing to do.
return Ok(()); return Ok(());
}; };
let bytes = self // Stream through the byte cap so an oversized blob is rejected as it's
// read rather than after it's fully buffered into memory (mirrors the
// OCR dispatcher).
let file = self
.storage .storage
.get(&page.storage_key) .get_stream(&page.storage_key)
.await .await
.map_err(|e| anyhow::anyhow!("read page image {}: {e}", page.storage_key))?; .map_err(|e| anyhow::anyhow!("read page image {}: {e}", page.storage_key))?;
if bytes.len() > self.max_image_bytes { let bytes =
anyhow::bail!( crate::crawler::safety::accumulate_capped(file.stream, self.max_image_bytes)
"page image {} is {} bytes, over the {} cap", .await
page.storage_key, .map_err(|e| {
bytes.len(), anyhow::anyhow!("page image {} over the byte cap: {e}", page.storage_key)
self.max_image_bytes })?;
);
}
let analysis = self.vision.analyze(&bytes, &page.content_type).await?; let analysis = self.vision.analyze(&bytes, &page.content_type).await?;
repo::page_analysis::persist_analysis(&self.db, page_id, &analysis, &self.model).await?; repo::page_analysis::persist_analysis(&self.db, page_id, &analysis, &self.model).await?;
Ok(()) Ok(())

View File

@@ -207,19 +207,21 @@ impl AnalyzeDispatcher for OcrAnalyzeDispatcher {
// Page was deleted between enqueue and dispatch — nothing to do. // Page was deleted between enqueue and dispatch — nothing to do.
return Ok(()); return Ok(());
}; };
let bytes = self // Stream the blob through the byte cap so the read itself bails once
// the running total exceeds `max_image_bytes` — a plain `get()` would
// buffer the whole (possibly huge) image into memory before the cap
// could reject it.
let file = self
.storage .storage
.get(&page.storage_key) .get_stream(&page.storage_key)
.await .await
.map_err(|e| anyhow::anyhow!("read page image {}: {e}", page.storage_key))?; .map_err(|e| anyhow::anyhow!("read page image {}: {e}", page.storage_key))?;
if bytes.len() > self.max_image_bytes { let bytes =
anyhow::bail!( crate::crawler::safety::accumulate_capped(file.stream, self.max_image_bytes)
"page image {} is {} bytes, over the {} cap", .await
page.storage_key, .map_err(|e| {
bytes.len(), anyhow::anyhow!("page image {} over the byte cap: {e}", page.storage_key)
self.max_image_bytes })?;
);
}
// OCR inference is CPU-bound and synchronous — keep it off the async // OCR inference is CPU-bound and synchronous — keep it off the async
// worker's runtime thread, and gate it behind the shared permit pool so // worker's runtime thread, and gate it behind the shared permit pool so
// ANALYSIS_WORKERS > cores can't oversubscribe the blocking pool. // ANALYSIS_WORKERS > cores can't oversubscribe the blocking pool.

View File

@@ -16,7 +16,7 @@ use crate::api::auth::{validate_password, validate_username};
use crate::api::pagination::PagedResponse; use crate::api::pagination::PagedResponse;
use crate::app::AppState; use crate::app::AppState;
use crate::auth::extractor::RequireAdmin; use crate::auth::extractor::RequireAdmin;
use crate::auth::password::hash_password; use crate::auth::password::hash_password_async;
use crate::domain::User; use crate::domain::User;
use crate::error::{AppError, AppResult}; use crate::error::{AppError, AppResult};
use crate::repo; use crate::repo;
@@ -115,7 +115,7 @@ async fn create_user(
// reject (and vice versa). // reject (and vice versa).
validate_username(username)?; validate_username(username)?;
validate_password(&input.password)?; validate_password(&input.password)?;
let pwhash = hash_password(&input.password)?; let pwhash = hash_password_async(input.password.clone()).await?;
let user = repo::user::admin_create_user( let user = repo::user::admin_create_user(
&state.db, &state.db,
actor.id, actor.id,

View File

@@ -17,8 +17,8 @@ use serde::{Deserialize, Serialize};
use uuid::Uuid; use uuid::Uuid;
use crate::app::AppState; use crate::app::AppState;
use crate::auth::extractor::{CurrentUser, SESSION_COOKIE_NAME}; use crate::auth::extractor::{ClientIp, CurrentUser, SESSION_COOKIE_NAME};
use crate::auth::password::{hash_password, verify_password}; use crate::auth::password::{hash_password_async, verify_password_async};
use crate::auth::token::{generate_token, hash_token}; use crate::auth::token::{generate_token, hash_token};
use crate::config::AuthConfig; use crate::config::AuthConfig;
use crate::domain::user_preferences::{READER_GAPS, READER_MODES}; use crate::domain::user_preferences::{READER_GAPS, READER_MODES};
@@ -107,6 +107,7 @@ pub struct CreatedTokenResponse {
async fn register( async fn register(
State(state): State<AppState>, State(state): State<AppState>,
ClientIp(client_ip): ClientIp,
jar: CookieJar, jar: CookieJar,
Json(input): Json<Credentials>, Json(input): Json<Credentials>,
) -> AppResult<impl IntoResponse> { ) -> AppResult<impl IntoResponse> {
@@ -114,7 +115,7 @@ async fn register(
// the toggle can't be probed for the toggle state via timing — // the toggle can't be probed for the toggle state via timing —
// disabled and enabled paths both consume a token, and disabled // disabled and enabled paths both consume a token, and disabled
// returns 403 instead of running argon2. // returns 403 instead of running argon2.
check_auth_rate_limit(&state, "register")?; check_auth_rate_limit(&state, "register", client_ip)?;
// Private mode force-blocks self-registration regardless of // Private mode force-blocks self-registration regardless of
// ALLOW_SELF_REGISTER — operators of locked-down instances mint // ALLOW_SELF_REGISTER — operators of locked-down instances mint
// accounts via `POST /admin/users` instead. // accounts via `POST /admin/users` instead.
@@ -125,7 +126,7 @@ async fn register(
validate_username(username)?; validate_username(username)?;
validate_password(&input.password)?; validate_password(&input.password)?;
let pwhash = hash_password(&input.password)?; let pwhash = hash_password_async(input.password.clone()).await?;
let user = repo::user::create(&state.db, username, &pwhash).await?; let user = repo::user::create(&state.db, username, &pwhash).await?;
let jar = start_session(&state, &user, jar).await?; let jar = start_session(&state, &user, jar).await?;
Ok((StatusCode::CREATED, jar, Json(AuthResponse { user }))) Ok((StatusCode::CREATED, jar, Json(AuthResponse { user })))
@@ -133,10 +134,11 @@ async fn register(
async fn login( async fn login(
State(state): State<AppState>, State(state): State<AppState>,
ClientIp(client_ip): ClientIp,
jar: CookieJar, jar: CookieJar,
Json(input): Json<Credentials>, Json(input): Json<Credentials>,
) -> AppResult<impl IntoResponse> { ) -> AppResult<impl IntoResponse> {
check_auth_rate_limit(&state, "login")?; check_auth_rate_limit(&state, "login", client_ip)?;
let username = input.username.trim(); let username = input.username.trim();
if username.is_empty() || input.password.is_empty() { if username.is_empty() || input.password.is_empty() {
return Err(AppError::InvalidInput( return Err(AppError::InvalidInput(
@@ -154,10 +156,11 @@ async fn login(
// response time matches the wrong-password branch — otherwise // response time matches the wrong-password branch — otherwise
// an attacker can enumerate usernames by timing the no-user // an attacker can enumerate usernames by timing the no-user
// 401 against the wrong-password 401. // 401 against the wrong-password 401.
let _ = verify_password(&input.password, dummy_password_hash()); let _ = verify_password_async(input.password.clone(), dummy_password_hash().to_string())
.await;
return Err(AppError::Unauthenticated); return Err(AppError::Unauthenticated);
}; };
if !verify_password(&input.password, &user.password_hash) { if !verify_password_async(input.password.clone(), user.password_hash.clone()).await {
return Err(AppError::Unauthenticated); return Err(AppError::Unauthenticated);
} }
@@ -213,19 +216,20 @@ async fn me(CurrentUser(user): CurrentUser) -> AppResult<Json<AuthResponse>> {
async fn change_password( async fn change_password(
State(state): State<AppState>, State(state): State<AppState>,
CurrentUser(user): CurrentUser, CurrentUser(user): CurrentUser,
ClientIp(client_ip): ClientIp,
jar: CookieJar, jar: CookieJar,
Json(input): Json<ChangePassword>, Json(input): Json<ChangePassword>,
) -> AppResult<impl IntoResponse> { ) -> AppResult<impl IntoResponse> {
check_auth_rate_limit(&state, "change_password")?; check_auth_rate_limit(&state, "change_password", client_ip)?;
// Cap current_password before verify_password runs argon2 (same DoS // Cap current_password before verify_password runs argon2 (same DoS
// vector as login). new_password is bounded by validate_password below. // vector as login). new_password is bounded by validate_password below.
reject_oversized_password(&input.current_password)?; reject_oversized_password(&input.current_password)?;
if !verify_password(&input.current_password, &user.password_hash) { if !verify_password_async(input.current_password.clone(), user.password_hash.clone()).await {
return Err(AppError::Unauthenticated); return Err(AppError::Unauthenticated);
} }
validate_password(&input.new_password)?; validate_password(&input.new_password)?;
let new_hash = hash_password(&input.new_password)?; let new_hash = hash_password_async(input.new_password.clone()).await?;
let mut tx = state.db.begin().await?; let mut tx = state.db.begin().await?;
sqlx::query("UPDATE users SET password_hash = $1 WHERE id = $2") sqlx::query("UPDATE users SET password_hash = $1 WHERE id = $2")
@@ -416,9 +420,13 @@ fn build_expired_cookie(cfg: &AuthConfig) -> Cookie<'static> {
/// any one of them in a tight loop should trip the limit. `endpoint` /// any one of them in a tight loop should trip the limit. `endpoint`
/// is included in the rate-limit-hit log line so operators can tell /// is included in the rate-limit-hit log line so operators can tell
/// which endpoint is being probed. /// which endpoint is being probed.
fn check_auth_rate_limit(state: &AppState, endpoint: &'static str) -> AppResult<()> { fn check_auth_rate_limit(
state: &AppState,
endpoint: &'static str,
client_ip: Option<std::net::IpAddr>,
) -> AppResult<()> {
use crate::auth::rate_limit::AcquireResult; use crate::auth::rate_limit::AcquireResult;
match state.auth_limiter.try_acquire() { match state.auth_limiter.try_acquire(client_ip) {
AcquireResult::Allowed => Ok(()), AcquireResult::Allowed => Ok(()),
AcquireResult::Denied { retry_after_secs } => { AcquireResult::Denied { retry_after_secs } => {
tracing::warn!( tracing::warn!(

View File

@@ -49,6 +49,26 @@ async fn serve(State(state): State<AppState>, Path(key): Path<String>) -> AppRes
HeaderName::from_static("x-content-type-options"), HeaderName::from_static("x-content-type-options"),
"nosniff".to_string(), "nosniff".to_string(),
), ),
// Blobs are content-addressed by unguessable, immutable keys (a
// re-upload mints new UUIDs), so a fetched page/cover never changes.
// Cache it for a year and mark it `immutable` so browsers skip
// revalidation entirely — this is what lets the reader's page and
// next-chapter preloading hit cache instead of re-downloading (and
// re-proxying every byte through the SvelteKit node server in prod).
//
// BUT under PRIVATE_MODE these blobs are auth-gated (see
// `private_mode_guard`), so they must NOT be marked `public`: a shared
// cache / CDN in front of the app would store the object and then serve
// it to unauthenticated clients, defeating the gate. Use `private` so
// only the requesting user's browser caches it.
(
header::CACHE_CONTROL,
if state.auth.private_mode {
"private, max-age=31536000, immutable".to_string()
} else {
"public, max-age=31536000, immutable".to_string()
},
),
]; ];
Ok((headers, Body::from_stream(file.stream)).into_response()) Ok((headers, Body::from_stream(file.stream)).into_response())
} }

View File

@@ -157,7 +157,9 @@ async fn list(
order, order,
}; };
let (items, total) = repo::manga::list_cards(&state.db, &q).await?; let (items, total) = repo::manga::list_cards(&state.db, &q).await?;
Ok(Json(PagedResponse::with_total(items, limit, offset, total))) Ok(Json(PagedResponse::with_optional_total(
items, limit, offset, total,
)))
} }
async fn get_one( async fn get_one(

View File

@@ -34,4 +34,18 @@ impl<T> PagedResponse<T> {
page: PageInfo { limit, offset, total: Some(total) }, page: PageInfo { limit, offset, total: Some(total) },
} }
} }
/// For handlers that compute `total` only on some pages (e.g. the first)
/// and leave it `None` elsewhere.
pub fn with_optional_total(
items: Vec<T>,
limit: i64,
offset: i64,
total: Option<i64>,
) -> Self {
Self {
items,
page: PageInfo { limit, offset, total },
}
}
} }

View File

@@ -279,7 +279,8 @@ impl DaemonReloader for Supervisors {
pub async fn build(config: Config) -> anyhow::Result<AppHandle> { pub async fn build(config: Config) -> anyhow::Result<AppHandle> {
let db = PgPoolOptions::new() let db = PgPoolOptions::new()
.max_connections(10) .max_connections(config.db.max_connections)
.acquire_timeout(config.db.acquire_timeout)
.connect(&config.database_url) .connect(&config.database_url)
.await?; .await?;
sqlx::migrate!("./migrations").run(&db).await?; sqlx::migrate!("./migrations").run(&db).await?;
@@ -475,6 +476,10 @@ 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
// address (DNS rebinding) is refused at connect time, not just
// by the string check above.
.dns_resolver(crate::crawler::safety::safe_dns_resolver())
.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);
@@ -501,6 +506,7 @@ 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())
.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 {
@@ -558,6 +564,10 @@ async fn spawn_crawler_daemon(
cfg: &CrawlerConfig, cfg: &CrawlerConfig,
analysis_enabled: Arc<AtomicBool>, analysis_enabled: Arc<AtomicBool>,
) -> anyhow::Result<SpawnedDaemon> { ) -> anyhow::Result<SpawnedDaemon> {
// Publish the opt-in browser SSRF-interception toggle so every headless
// navigation (via `intercept::open_page`) honours it. Off by default.
crate::crawler::intercept::set_enabled(cfg.ssrf_intercept);
// Reqwest client with a shared cookie jar so CDN image fetches include // Reqwest client with a shared cookie jar so CDN image fetches include
// PHPSESSID. The same `Arc<Jar>` is held by the SessionController, so a // PHPSESSID. The same `Arc<Jar>` is held by the SessionController, so a
// runtime session refresh rewrites it in place. Initial value: a // runtime session refresh rewrites it in place. Initial value: a
@@ -585,6 +595,10 @@ 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);

View File

@@ -70,6 +70,45 @@ impl FromRequestParts<AppState> for CurrentUser {
} }
} }
/// Client IP for per-IP auth rate limiting. Resolves to the first hop of
/// `X-Forwarded-For` **only** when [`crate::config::AuthConfig::trusted_proxy`]
/// is set (the backend is behind a proxy that overrides the header — the
/// compose deploy). Otherwise `None`, so the limiter uses its shared bucket.
/// Never fails: a missing or malformed header simply yields `None`.
pub struct ClientIp(pub Option<std::net::IpAddr>);
/// Parse the client IP from an `X-Forwarded-For` value: the left-most hop is
/// the original client (later hops are intermediary proxies). Factored out so
/// the parsing is unit-testable without constructing a request.
pub(crate) fn first_forwarded_ip(header: &str) -> Option<std::net::IpAddr> {
header
.split(',')
.next()
.map(str::trim)
.filter(|s| !s.is_empty())
.and_then(|s| s.parse::<std::net::IpAddr>().ok())
}
#[async_trait]
impl FromRequestParts<AppState> for ClientIp {
type Rejection = std::convert::Infallible;
async fn from_request_parts(
parts: &mut Parts,
state: &AppState,
) -> Result<Self, Self::Rejection> {
if !state.auth.trusted_proxy {
return Ok(ClientIp(None));
}
let ip = parts
.headers
.get("x-forwarded-for")
.and_then(|v| v.to_str().ok())
.and_then(first_forwarded_ip);
Ok(ClientIp(ip))
}
}
/// Cookie-only authentication. Bot/API tokens are explicitly NOT accepted /// Cookie-only authentication. Bot/API tokens are explicitly NOT accepted
/// here — this is the substrate for [`RequireAdmin`] and exists precisely /// here — this is the substrate for [`RequireAdmin`] and exists precisely
/// to keep admin authority out of bearer-token reach. /// to keep admin authority out of bearer-token reach.
@@ -120,3 +159,34 @@ impl FromRequestParts<AppState> for RequireAdmin {
Ok(RequireAdmin(user)) Ok(RequireAdmin(user))
} }
} }
#[cfg(test)]
mod tests {
use super::first_forwarded_ip;
use std::net::IpAddr;
#[test]
fn parses_left_most_client_hop() {
// The client is the first entry; later entries are intermediary proxies.
assert_eq!(
first_forwarded_ip("203.0.113.5, 10.0.0.1, 10.0.0.2"),
Some("203.0.113.5".parse::<IpAddr>().unwrap())
);
assert_eq!(
first_forwarded_ip(" 198.51.100.9 "),
Some("198.51.100.9".parse::<IpAddr>().unwrap())
);
assert_eq!(
first_forwarded_ip("2001:db8::1, 10.0.0.1"),
Some("2001:db8::1".parse::<IpAddr>().unwrap())
);
}
#[test]
fn rejects_empty_or_garbage() {
assert_eq!(first_forwarded_ip(""), None);
assert_eq!(first_forwarded_ip(" "), None);
assert_eq!(first_forwarded_ip("not-an-ip"), None);
assert_eq!(first_forwarded_ip(", 10.0.0.1"), None);
}
}

View File

@@ -28,6 +28,25 @@ pub fn verify_password(plain: &str, phc: &str) -> bool {
.is_ok() .is_ok()
} }
/// Async wrapper around [`hash_password`] that offloads the CPU- and
/// memory-heavy Argon2 work to the blocking pool. Async handlers must call
/// this rather than the sync primitive: at ~15-50 ms per hash, running it
/// inline stalls every other task sharing the runtime worker thread.
pub async fn hash_password_async(plain: String) -> AppResult<String> {
tokio::task::spawn_blocking(move || hash_password(&plain))
.await
.map_err(|e| AppError::Other(anyhow::anyhow!("password hash task join: {e}")))?
}
/// Async wrapper around [`verify_password`]. A join failure (blocking pool
/// gone at shutdown, panic) resolves to `false` — the caller only ever uses
/// the result as a boolean gate, and denying auth is the safe default.
pub async fn verify_password_async(plain: String, phc: String) -> bool {
tokio::task::spawn_blocking(move || verify_password(&plain, &phc))
.await
.unwrap_or(false)
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
@@ -56,4 +75,24 @@ mod tests {
let b = hash_password("same").unwrap(); let b = hash_password("same").unwrap();
assert_ne!(a, b, "two hashes of the same password must differ (salt)"); assert_ne!(a, b, "two hashes of the same password must differ (salt)");
} }
#[tokio::test]
async fn async_hash_then_verify_roundtrip() {
let phc = hash_password_async("correct horse battery staple".to_string())
.await
.unwrap();
assert!(phc.starts_with("$argon2id$"));
assert!(verify_password_async("correct horse battery staple".to_string(), phc).await);
}
#[tokio::test]
async fn async_verify_rejects_wrong_password() {
let phc = hash_password_async("hunter2".to_string()).await.unwrap();
assert!(!verify_password_async("hunter3".to_string(), phc).await);
}
#[tokio::test]
async fn async_verify_rejects_malformed_hash() {
assert!(!verify_password_async("anything".to_string(), "not a real phc".to_string()).await);
}
} }

View File

@@ -15,9 +15,17 @@
//! tests run in isolated buckets and won't bleed across `#[sqlx::test]` //! tests run in isolated buckets and won't bleed across `#[sqlx::test]`
//! cases that share a process. //! cases that share a process.
use std::collections::HashMap;
use std::net::IpAddr;
use std::sync::Mutex; use std::sync::Mutex;
use std::time::Instant; use std::time::Instant;
/// Upper bound on distinct client IPs tracked at once, so a spray from many
/// spoofed/rotating source IPs can't grow the map without limit. When full,
/// idle (refilled-to-burst) buckets are pruned first; if none are idle a new
/// IP falls back to the shared global bucket for that request.
const MAX_TRACKED_IPS: usize = 10_000;
/// Tunable limits. `per_sec == 0` disables the limiter — used by the /// Tunable limits. `per_sec == 0` disables the limiter — used by the
/// test harness and by anyone who wants to opt out via env config. /// test harness and by anyone who wants to opt out via env config.
#[derive(Clone, Copy, Debug)] #[derive(Clone, Copy, Debug)]
@@ -50,6 +58,42 @@ struct Bucket {
last_refill: Instant, last_refill: Instant,
} }
impl Bucket {
fn new(burst: u32) -> Self {
Self {
tokens: f64::from(burst),
last_refill: Instant::now(),
}
}
/// Refill by elapsed time then try to consume one token.
fn try_take(&mut self, cfg: &RateLimitConfig, now: Instant) -> AcquireResult {
let elapsed = now.duration_since(self.last_refill).as_secs_f64();
self.tokens =
(self.tokens + elapsed * f64::from(cfg.per_sec)).min(f64::from(cfg.burst));
self.last_refill = now;
if self.tokens >= 1.0 {
self.tokens -= 1.0;
AcquireResult::Allowed
} else {
let deficit = 1.0 - self.tokens;
let wait_secs = (deficit / f64::from(cfg.per_sec)).ceil() as u64;
AcquireResult::Denied {
retry_after_secs: wait_secs.max(1),
}
}
}
/// Whether this bucket has fully refilled (i.e. the client has been idle).
/// Such buckets carry no state worth keeping — dropping and lazily
/// recreating one yields an identical full bucket — so they're the safe
/// eviction target under memory pressure.
fn is_idle(&self, cfg: &RateLimitConfig, now: Instant) -> bool {
let elapsed = now.duration_since(self.last_refill).as_secs_f64();
(self.tokens + elapsed * f64::from(cfg.per_sec)) >= f64::from(cfg.burst)
}
}
/// Outcome of [`AuthRateLimiter::try_acquire`]. When `Denied`, the /// Outcome of [`AuthRateLimiter::try_acquire`]. When `Denied`, the
/// caller can use `retry_after_secs` for a `Retry-After: N` header /// caller can use `retry_after_secs` for a `Retry-After: N` header
/// (RFC 6585 §4) so well-behaved clients back off correctly rather /// (RFC 6585 §4) so well-behaved clients back off correctly rather
@@ -60,51 +104,64 @@ pub enum AcquireResult {
Denied { retry_after_secs: u64 }, Denied { retry_after_secs: u64 },
} }
/// Single-bucket token-bucket limiter. `try_acquire` is cheap (one /// Token-bucket limiter keyed by client IP, with a shared global bucket for
/// mutex acquire, no allocations) so the auth path doesn't pay a real /// requests whose IP is unknown (no trusted proxy).
/// cost for the check. ///
/// The old design was a single global bucket. That let one attacker at the
/// sustained rate deny auth to *every* user (the bucket stayed drained). With
/// the SvelteKit proxy now forwarding the peer IP (`X-Forwarded-For`, honoured
/// only when `AUTH_TRUSTED_PROXY` is set), each source IP gets its own bucket,
/// so an attacker only throttles themselves. When no trustworthy IP is
/// available the caller passes `None` and the shared `global` bucket applies —
/// exactly the previous behaviour.
pub struct AuthRateLimiter { pub struct AuthRateLimiter {
cfg: RateLimitConfig, cfg: RateLimitConfig,
bucket: Mutex<Bucket>, global: Mutex<Bucket>,
per_ip: Mutex<HashMap<IpAddr, Bucket>>,
} }
impl AuthRateLimiter { impl AuthRateLimiter {
pub fn new(cfg: RateLimitConfig) -> Self { pub fn new(cfg: RateLimitConfig) -> Self {
Self { Self {
cfg, cfg,
bucket: Mutex::new(Bucket { global: Mutex::new(Bucket::new(cfg.burst)),
tokens: cfg.burst as f64, per_ip: Mutex::new(HashMap::new()),
last_refill: Instant::now(),
}),
} }
} }
/// Consume one token if available. Returns `Denied` with a /// Consume one token from the bucket for `key` (per-IP when `Some`, the
/// rounded-up seconds-until-refill so the caller can emit a /// shared global bucket when `None`). Returns `Denied` with a rounded-up
/// `Retry-After` header. /// seconds-until-refill so the caller can emit a `Retry-After` header.
pub fn try_acquire(&self) -> AcquireResult { pub fn try_acquire(&self, key: Option<IpAddr>) -> AcquireResult {
if self.cfg.per_sec == 0 { if self.cfg.per_sec == 0 {
return AcquireResult::Allowed; return AcquireResult::Allowed;
} }
let now = Instant::now(); let now = Instant::now();
let mut bucket = self.bucket.lock().expect("rate limiter mutex poisoned"); let Some(ip) = key else {
let elapsed = now.duration_since(bucket.last_refill).as_secs_f64(); return self
bucket.tokens = .global
(bucket.tokens + elapsed * f64::from(self.cfg.per_sec)).min(f64::from(self.cfg.burst)); .lock()
bucket.last_refill = now; .expect("rate limiter mutex poisoned")
if bucket.tokens >= 1.0 { .try_take(&self.cfg, now);
bucket.tokens -= 1.0; };
AcquireResult::Allowed let mut map = self.per_ip.lock().expect("rate limiter mutex poisoned");
} else { if map.len() >= MAX_TRACKED_IPS && !map.contains_key(&ip) {
// ceil((1 - tokens) / per_sec), minimum 1 — a `Retry-After: 0` map.retain(|_, b| !b.is_idle(&self.cfg, now));
// would tell clients to retry immediately, which is what we're if map.len() >= MAX_TRACKED_IPS {
// actively trying to discourage. // Still saturated with active attackers — degrade to the shared
let deficit = 1.0 - bucket.tokens; // bucket rather than grow unbounded. Worst case is the old
let wait_secs = (deficit / f64::from(self.cfg.per_sec)).ceil() as u64; // global-bucket behaviour under an extreme distributed flood.
AcquireResult::Denied { drop(map);
retry_after_secs: wait_secs.max(1), return self
.global
.lock()
.expect("rate limiter mutex poisoned")
.try_take(&self.cfg, now);
} }
} }
map.entry(ip)
.or_insert_with(|| Bucket::new(self.cfg.burst))
.try_take(&self.cfg, now)
} }
} }
@@ -119,7 +176,7 @@ mod tests {
burst: 0, burst: 0,
}); });
for _ in 0..1000 { for _ in 0..1000 {
assert_eq!(rl.try_acquire(), AcquireResult::Allowed); assert_eq!(rl.try_acquire(None), AcquireResult::Allowed);
} }
} }
@@ -130,10 +187,10 @@ mod tests {
per_sec: 1, per_sec: 1,
burst: 3, burst: 3,
}); });
assert_eq!(rl.try_acquire(), AcquireResult::Allowed); assert_eq!(rl.try_acquire(None), AcquireResult::Allowed);
assert_eq!(rl.try_acquire(), AcquireResult::Allowed); assert_eq!(rl.try_acquire(None), AcquireResult::Allowed);
assert_eq!(rl.try_acquire(), AcquireResult::Allowed); assert_eq!(rl.try_acquire(None), AcquireResult::Allowed);
match rl.try_acquire() { match rl.try_acquire(None) {
AcquireResult::Denied { retry_after_secs } => { AcquireResult::Denied { retry_after_secs } => {
// Bucket is at ~0 tokens, refill rate 1/sec → ~1s wait. // Bucket is at ~0 tokens, refill rate 1/sec → ~1s wait.
assert!( assert!(
@@ -152,11 +209,11 @@ mod tests {
per_sec: 10, per_sec: 10,
burst: 1, burst: 1,
}); });
assert_eq!(rl.try_acquire(), AcquireResult::Allowed); assert_eq!(rl.try_acquire(None), AcquireResult::Allowed);
assert!(matches!(rl.try_acquire(), AcquireResult::Denied { .. })); assert!(matches!(rl.try_acquire(None), AcquireResult::Denied { .. }));
std::thread::sleep(std::time::Duration::from_millis(150)); std::thread::sleep(std::time::Duration::from_millis(150));
assert_eq!( assert_eq!(
rl.try_acquire(), rl.try_acquire(None),
AcquireResult::Allowed, AcquireResult::Allowed,
"token should have refilled" "token should have refilled"
); );
@@ -170,10 +227,64 @@ mod tests {
per_sec: 1, per_sec: 1,
burst: 1, burst: 1,
}); });
slow.try_acquire(); slow.try_acquire(None);
match slow.try_acquire() { match slow.try_acquire(None) {
AcquireResult::Denied { retry_after_secs } => assert_eq!(retry_after_secs, 1), AcquireResult::Denied { retry_after_secs } => assert_eq!(retry_after_secs, 1),
_ => panic!("expected Denied"), _ => panic!("expected Denied"),
} }
} }
fn ip(s: &str) -> Option<IpAddr> {
Some(s.parse().unwrap())
}
#[test]
fn per_ip_buckets_are_independent() {
// The whole point of the fix: one IP draining its bucket must not deny
// a different IP.
let rl = AuthRateLimiter::new(RateLimitConfig {
per_sec: 1,
burst: 2,
});
let a = ip("203.0.113.7");
let b = ip("198.51.100.9");
assert_eq!(rl.try_acquire(a), AcquireResult::Allowed);
assert_eq!(rl.try_acquire(a), AcquireResult::Allowed);
assert!(matches!(rl.try_acquire(a), AcquireResult::Denied { .. }));
// b is untouched.
assert_eq!(rl.try_acquire(b), AcquireResult::Allowed);
assert_eq!(rl.try_acquire(b), AcquireResult::Allowed);
assert!(matches!(rl.try_acquire(b), AcquireResult::Denied { .. }));
}
#[test]
fn none_key_shares_the_global_bucket_independent_of_per_ip() {
let rl = AuthRateLimiter::new(RateLimitConfig {
per_sec: 1,
burst: 2,
});
// Drain the global (None) bucket.
assert_eq!(rl.try_acquire(None), AcquireResult::Allowed);
assert_eq!(rl.try_acquire(None), AcquireResult::Allowed);
assert!(matches!(rl.try_acquire(None), AcquireResult::Denied { .. }));
// A real IP is on its own bucket, unaffected by the drained global one.
assert_eq!(rl.try_acquire(ip("203.0.113.1")), AcquireResult::Allowed);
}
#[test]
fn tracked_ip_map_is_bounded() {
let rl = AuthRateLimiter::new(RateLimitConfig {
per_sec: 1,
burst: 1,
});
// Distinct IPs beyond the cap must not grow the map without bound —
// excess requests fall back to the shared bucket instead.
for i in 0..(MAX_TRACKED_IPS as u64 + 50) {
let octet_a = (i >> 8) as u8;
let octet_b = (i & 0xff) as u8;
let addr = format!("10.20.{octet_a}.{octet_b}");
let _ = rl.try_acquire(Some(addr.parse().unwrap()));
}
assert!(rl.per_ip.lock().unwrap().len() <= MAX_TRACKED_IPS);
}
} }

View File

@@ -126,6 +126,8 @@ 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);
@@ -136,6 +138,13 @@ async fn main() -> anyhow::Result<()> {
} }
let http = http_builder.build().context("build http client")?; let http = http_builder.build().context("build http client")?;
// Opt-in browser SSRF interception (default off), mirroring the daemon.
mangalord::crawler::intercept::set_enabled(
std::env::var("CRAWLER_SSRF_INTERCEPT")
.map(|v| matches!(v.as_str(), "1" | "true" | "TRUE" | "yes"))
.unwrap_or(false),
);
let mut options = LaunchOptions::from_env(); let mut options = LaunchOptions::from_env();
if let Some(proxy) = &proxy_url { if let Some(proxy) = &proxy_url {
let chromium_proxy = mangalord::crawler::url_utils::chromium_proxy_arg(proxy); let chromium_proxy = mangalord::crawler::url_utils::chromium_proxy_arg(proxy);

View File

@@ -27,6 +27,14 @@ pub struct AuthConfig {
/// so a private instance is locked down with a single switch. /// so a private instance is locked down with a single switch.
/// Defaults to `false` (current public behaviour). /// Defaults to `false` (current public behaviour).
pub private_mode: bool, pub private_mode: bool,
/// Whether to trust a proxy-supplied `X-Forwarded-For` header as the
/// client IP for per-IP auth rate limiting. Enable ONLY when the backend
/// sits behind a trusted reverse proxy that overrides the header (the
/// compose deploy: SvelteKit's hooks.server.ts sets it from the real peer
/// address). When `false` (default), the header is ignored and the auth
/// limiter uses a single shared bucket — a directly-exposed backend must
/// keep this off or clients could spoof their IP to dodge the limit.
pub trusted_proxy: bool,
} }
impl Default for AuthConfig { impl Default for AuthConfig {
@@ -42,6 +50,7 @@ impl Default for AuthConfig {
rate_limit: crate::auth::rate_limit::RateLimitConfig::default(), rate_limit: crate::auth::rate_limit::RateLimitConfig::default(),
allow_self_register: true, allow_self_register: true,
private_mode: false, private_mode: false,
trusted_proxy: false,
} }
} }
} }
@@ -72,6 +81,45 @@ impl Default for UploadConfig {
} }
} }
/// Postgres connection-pool sizing. One pool backs every HTTP handler plus
/// the crawler/analysis daemons, so it must be large enough not to starve
/// interactive reads and fail fast (rather than hang on the driver's silent
/// 30 s default) when genuinely saturated.
#[derive(Clone, Debug)]
pub struct DbConfig {
/// `DB_MAX_CONNECTIONS`. Upper bound on open connections.
pub max_connections: u32,
/// `DB_ACQUIRE_TIMEOUT_SECS`. How long a caller waits for a free
/// connection before erroring — short so overload surfaces as a fast
/// 500 instead of a 30 s hang.
pub acquire_timeout: Duration,
}
impl Default for DbConfig {
fn default() -> Self {
Self {
max_connections: 20,
acquire_timeout: Duration::from_secs(10),
}
}
}
impl DbConfig {
pub fn from_env() -> Self {
let default = Self::default();
Self {
// `.max(1)`: a zero-size pool can never hand out a connection and
// would deadlock every query — clamp to at least one.
max_connections: env_u64("DB_MAX_CONNECTIONS", default.max_connections.into())
.max(1) as u32,
acquire_timeout: Duration::from_secs(env_u64(
"DB_ACQUIRE_TIMEOUT_SECS",
default.acquire_timeout.as_secs(),
)),
}
}
}
/// How the worker asks the model to constrain its output. OpenAI-compatible /// How the worker asks the model to constrain its output. OpenAI-compatible
/// servers disagree here: LM Studio accepts only `json_schema` or `text` /// servers disagree here: LM Studio accepts only `json_schema` or `text`
/// (NOT `json_object`); vanilla OpenAI/vLLM accept `json_object` too. The /// (NOT `json_object`); vanilla OpenAI/vLLM accept `json_object` too. The
@@ -371,6 +419,7 @@ pub struct Config {
pub database_url: String, pub database_url: String,
pub bind_address: String, pub bind_address: String,
pub storage_dir: PathBuf, pub storage_dir: PathBuf,
pub db: DbConfig,
pub auth: AuthConfig, pub auth: AuthConfig,
pub upload: UploadConfig, pub upload: UploadConfig,
pub cors_allowed_origins: Vec<String>, pub cors_allowed_origins: Vec<String>,
@@ -471,6 +520,13 @@ pub struct CrawlerConfig {
/// exhausted) that trigger an automatic coordinated browser restart. /// exhausted) that trigger an automatic coordinated browser restart.
/// Defaults to 3. `CRAWLER_BROWSER_RESTART_THRESHOLD`. /// Defaults to 3. `CRAWLER_BROWSER_RESTART_THRESHOLD`.
pub browser_restart_threshold: u32, pub browser_restart_threshold: u32,
/// Opt-in CDP `Fetch` interception that re-validates every headless-browser
/// navigation/redirect against the SSRF check (blocks a scraped page that
/// redirects the browser to an internal target). Default `false`: enabling
/// `Fetch` is a fragile hook in the crawler's critical path and the wiring
/// is not CI-verifiable (no Chromium in CI) — validate with a manual crawl
/// before turning on. `CRAWLER_SSRF_INTERCEPT`.
pub ssrf_intercept: bool,
} }
impl Default for CrawlerConfig { impl Default for CrawlerConfig {
@@ -503,6 +559,7 @@ impl Default for CrawlerConfig {
job_timeout: Duration::from_secs(600), job_timeout: Duration::from_secs(600),
metadata_max_consecutive_failures: 10, metadata_max_consecutive_failures: 10,
browser_restart_threshold: 3, browser_restart_threshold: 3,
ssrf_intercept: false,
} }
} }
} }
@@ -517,6 +574,7 @@ impl Config {
storage_dir: std::env::var("STORAGE_DIR") storage_dir: std::env::var("STORAGE_DIR")
.unwrap_or_else(|_| "./data/storage".to_string()) .unwrap_or_else(|_| "./data/storage".to_string())
.into(), .into(),
db: DbConfig::from_env(),
auth: AuthConfig { auth: AuthConfig {
cookie_secure: env_bool("COOKIE_SECURE", true), cookie_secure: env_bool("COOKIE_SECURE", true),
cookie_domain: std::env::var("COOKIE_DOMAIN") cookie_domain: std::env::var("COOKIE_DOMAIN")
@@ -535,6 +593,7 @@ impl Config {
}, },
allow_self_register: env_bool("ALLOW_SELF_REGISTER", true), allow_self_register: env_bool("ALLOW_SELF_REGISTER", true),
private_mode: env_bool("PRIVATE_MODE", false), private_mode: env_bool("PRIVATE_MODE", false),
trusted_proxy: env_bool("AUTH_TRUSTED_PROXY", false),
}, },
upload: UploadConfig { upload: UploadConfig {
max_request_bytes: env_usize("MAX_REQUEST_BYTES", 200 * 1024 * 1024), max_request_bytes: env_usize("MAX_REQUEST_BYTES", 200 * 1024 * 1024),
@@ -666,6 +725,7 @@ impl CrawlerConfig {
) as u32, ) as u32,
browser_restart_threshold: env_u64("CRAWLER_BROWSER_RESTART_THRESHOLD", 3).max(1) browser_restart_threshold: env_u64("CRAWLER_BROWSER_RESTART_THRESHOLD", 3).max(1)
as u32, as u32,
ssrf_intercept: env_bool("CRAWLER_SSRF_INTERCEPT", false),
}) })
} }
} }
@@ -813,6 +873,37 @@ mod tests {
assert_eq!(cfg.browser_restart_threshold, 7); assert_eq!(cfg.browser_restart_threshold, 7);
} }
#[test]
fn db_pool_defaults_when_unset() {
let _g = ENV_GUARD.lock().unwrap_or_else(|p| p.into_inner());
std::env::remove_var("DB_MAX_CONNECTIONS");
std::env::remove_var("DB_ACQUIRE_TIMEOUT_SECS");
let cfg = DbConfig::from_env();
assert_eq!(cfg.max_connections, 20);
assert_eq!(cfg.acquire_timeout, Duration::from_secs(10));
}
#[test]
fn db_pool_parses_from_env() {
let _g = ENV_GUARD.lock().unwrap_or_else(|p| p.into_inner());
std::env::set_var("DB_MAX_CONNECTIONS", "50");
std::env::set_var("DB_ACQUIRE_TIMEOUT_SECS", "3");
let cfg = DbConfig::from_env();
std::env::remove_var("DB_MAX_CONNECTIONS");
std::env::remove_var("DB_ACQUIRE_TIMEOUT_SECS");
assert_eq!(cfg.max_connections, 50);
assert_eq!(cfg.acquire_timeout, Duration::from_secs(3));
}
#[test]
fn db_pool_max_connections_clamps_to_at_least_one() {
let _g = ENV_GUARD.lock().unwrap_or_else(|p| p.into_inner());
std::env::set_var("DB_MAX_CONNECTIONS", "0");
let cfg = DbConfig::from_env();
std::env::remove_var("DB_MAX_CONNECTIONS");
assert_eq!(cfg.max_connections, 1);
}
#[test] #[test]
fn analysis_config_defaults_when_unset() { fn analysis_config_defaults_when_unset() {
let _g = ENV_GUARD.lock().unwrap_or_else(|p| p.into_inner()); let _g = ENV_GUARD.lock().unwrap_or_else(|p| p.into_inner());

View File

@@ -118,26 +118,34 @@ async fn fetch_chapter_html_once(
) -> anyhow::Result<String> { ) -> anyhow::Result<String> {
guard_nav_url(source_url)?; guard_nav_url(source_url)?;
rate.wait_for(source_url).await?; rate.wait_for(source_url).await?;
let page = browser let page = crate::crawler::intercept::open_page(browser, source_url)
.new_page(source_url)
.await .await
.with_context(|| format!("open chapter page {source_url}"))?; .with_context(|| format!("open chapter page {source_url}"))?;
crate::crawler::nav::wait_for_nav(&page) // Close the tab on every exit path — a `?` on wait_for_nav / content()
.await // would otherwise leak it (chromiumoxide doesn't close on drop).
.context("wait for chapter nav")?; let closer = page.clone();
// Best-effort wait for the reader marker — same partial-render crate::crawler::nav::close_after(
// race that bit the chapter-list parser can hit here. Timeout is async move {
// not an error; the chapter probe + parser sentinels still catch closer.close().await.ok();
// real failures. },
let _ = crate::crawler::nav::wait_for_selector( async {
&page, crate::crawler::nav::wait_for_nav(&page)
"a#pic_container", .await
crate::crawler::nav::SELECTOR_TIMEOUT, .context("wait for chapter nav")?;
// Best-effort wait for the reader marker — same partial-render
// race that bit the chapter-list parser can hit here. Timeout is
// not an error; the chapter probe + parser sentinels still catch
// real failures.
let _ = crate::crawler::nav::wait_for_selector(
&page,
"a#pic_container",
crate::crawler::nav::SELECTOR_TIMEOUT,
)
.await;
page.content().await.context("read chapter html")
},
) )
.await; .await
let html = page.content().await.context("read chapter html")?;
page.close().await.ok();
Ok(html)
} }
/// Pure-over-IO loop: fetch + classify, up to `max_attempts` total /// Pure-over-IO loop: fetch + classify, up to `max_attempts` total

View File

@@ -0,0 +1,257 @@
//! Optional CDP-level SSRF guard for headless-browser navigations.
//!
//! The reqwest clients get a DNS-filtering resolver (see
//! [`crate::crawler::safety::SafeResolver`]) so an image/API host that
//! resolves to an internal IP is refused at connect time. Chromium, however,
//! does its **own** DNS and connection handling, so that resolver can't see
//! browser navigations. Without a second guard, a scraped chapter page that
//! `302`-redirects the browser to `http://127.0.0.1:5432/` (or an
//! attacker-owned hostname that resolves to `169.254.169.254`) would be
//! loaded and parsed as if it were catalog content.
//!
//! This module installs CDP `Fetch` interception on a page so every
//! **Document** (main-frame) request and redirect is re-validated through the
//! same [`ensure_public_target`] + resolved-IP check the reqwest paths use,
//! failing any that target an internal address.
//!
//! **Opt-in / default-off.** Enabling `Fetch` means every intercepted request
//! *must* be resolved by a live handler or the navigation hangs, so this is a
//! fragile hook in the crawler's critical path. It ships behind
//! `CRAWLER_SSRF_INTERCEPT` (default `false`) and the wiring has **not** been
//! exercised against a real Chromium in CI — validate with a manual crawl
//! before enabling in production. When disabled, [`open_page`] is byte-for-byte
//! the previous `browser.new_page(url)` behavior. Even when enabled, a failure
//! to install the guard falls back to an unguarded navigation rather than
//! failing the crawl.
use std::net::IpAddr;
use std::sync::atomic::{AtomicBool, Ordering};
use chromiumoxide::browser::Browser;
use chromiumoxide::cdp::browser_protocol::fetch::{
ContinueRequestParams, EnableParams, EventRequestPaused, FailRequestParams, RequestPattern,
RequestStage,
};
use chromiumoxide::cdp::browser_protocol::network::{ErrorReason, ResourceType};
use chromiumoxide::error::Result as CdpResult;
use chromiumoxide::Page;
use futures_util::StreamExt;
use reqwest::Url;
use crate::crawler::safety::{ensure_public_target, is_private_ip};
/// Process-wide toggle, set once at startup from `CRAWLER_SSRF_INTERCEPT`.
/// A single boot-time flag (rather than threading a param through every
/// crawler navigation signature) keeps the off-path a no-op.
static ENABLED: AtomicBool = AtomicBool::new(false);
pub fn set_enabled(on: bool) {
ENABLED.store(on, Ordering::Relaxed);
}
pub fn is_enabled() -> bool {
ENABLED.load(Ordering::Relaxed)
}
/// What to do with a navigation URL, decided without DNS where possible so the
/// security-critical branching is unit-testable.
#[derive(Debug, PartialEq, Eq)]
pub(crate) enum Verdict {
/// Safe to continue (non-network scheme, or a public IP literal).
Allow,
/// Refuse — bad scheme handled elsewhere, localhost, or a private IP literal.
Block,
/// A hostname that must be resolved to decide (DNS-rebinding check).
ResolveHost { host: String, port: u16 },
}
/// Pure decision over a URL string. `data:` / `blob:` / `about:` and other
/// non-http(s) schemes are allowed (not a network-SSRF vector); http(s) with a
/// private/loopback/localhost literal is blocked; an http(s) hostname needs
/// resolution.
pub(crate) fn verdict(url: &str) -> Verdict {
let Ok(parsed) = Url::parse(url) else {
// Unparseable — let Chromium reject it; not our call to make.
return Verdict::Allow;
};
match parsed.scheme() {
"http" | "https" => {}
_ => return Verdict::Allow,
}
// Literal private IP / localhost / missing host → block (string check).
if ensure_public_target(url).is_err() {
return Verdict::Block;
}
match parsed.host_str() {
Some(host) if !is_ip_literal(host) => Verdict::ResolveHost {
host: host.to_string(),
port: parsed.port_or_known_default().unwrap_or(80),
},
// Public IP literal (ensure_public_target already passed it).
_ => Verdict::Allow,
}
}
/// Whether `host` (as reqwest's `host_str()` yields it) is an IP literal.
/// IPv6 literals arrive bracketed (`[::1]`), which don't parse as `IpAddr`
/// directly — strip the brackets first.
fn is_ip_literal(host: &str) -> bool {
let unbracketed = host
.strip_prefix('[')
.and_then(|s| s.strip_suffix(']'))
.unwrap_or(host);
unbracketed.parse::<IpAddr>().is_ok()
}
/// Async decision: resolves hostnames and blocks if any resolved address is
/// private (DNS rebinding). A resolution failure is *not* treated as blocked —
/// Chromium won't be able to connect either, so there's nothing to exfiltrate.
pub(crate) async fn is_blocked(url: &str) -> bool {
match verdict(url) {
Verdict::Allow => false,
Verdict::Block => true,
Verdict::ResolveHost { host, port } => match tokio::net::lookup_host((host.as_str(), port))
.await
{
Ok(addrs) => addrs.map(|a| a.ip()).any(|ip| is_private_ip(&ip)),
Err(_) => false,
},
}
}
/// Open a page for navigation. With interception off, this is exactly
/// `browser.new_page(url)`. With it on, the page is created blank (no network),
/// the navigation guard is installed, and only then does it navigate — so the
/// initial request and any redirects pass through the guard.
pub async fn open_page(browser: &Browser, url: &str) -> CdpResult<Page> {
if !is_enabled() {
return browser.new_page(url).await;
}
let page = browser.new_page("about:blank").await?;
if let Err(e) = install_navigation_guard(&page).await {
// Fail open on install error: navigate unguarded rather than wedge the
// crawl. The reqwest-layer resolver still covers image downloads.
tracing::warn!(url = %url, error = %e, "SSRF navigation guard failed to install; navigating unguarded");
}
page.goto(url).await?;
Ok(page)
}
/// Enable `Fetch` for Document requests on `page` and spawn a task that
/// continues/fails each paused request per [`is_blocked`].
async fn install_navigation_guard(page: &Page) -> CdpResult<()> {
// Intercept only main-frame Document requests at the request stage — a
// navigation plus its redirects, nothing else. Keeps the paused-request
// volume tiny so the handler can't become a page-load bottleneck.
let pattern = RequestPattern::builder()
.resource_type(ResourceType::Document)
.request_stage(RequestStage::Request)
.build();
page.execute(EnableParams {
patterns: Some(vec![pattern]),
handle_auth_requests: None,
})
.await?;
let mut paused = page.event_listener::<EventRequestPaused>().await?;
let handler_page = page.clone();
tokio::spawn(async move {
while let Some(ev) = paused.next().await {
let request_id = ev.request_id.clone();
let outcome = if is_blocked(&ev.request.url).await {
tracing::warn!(
url = %ev.request.url,
"SSRF guard blocked browser navigation to an internal target"
);
handler_page
.execute(FailRequestParams::new(request_id, ErrorReason::BlockedByClient))
.await
.map(|_| ())
} else {
handler_page
.execute(ContinueRequestParams::new(request_id))
.await
.map(|_| ())
};
if let Err(e) = outcome {
// The page/session is gone (page closed) — the stream will end
// too; stop handling so the task exits rather than spins.
tracing::debug!(error = %e, "fetch interceptor: continue/fail failed, ending handler");
break;
}
}
});
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn verdict_allows_non_network_schemes() {
for url in ["about:blank", "data:text/html,hi", "blob:abc", "chrome://version"] {
assert_eq!(verdict(url), Verdict::Allow, "{url} should be allowed");
}
}
#[test]
fn verdict_blocks_private_ip_literals_and_localhost() {
for url in [
"http://127.0.0.1/",
"http://10.0.0.1/",
"http://192.168.1.1:5432/",
"http://169.254.169.254/latest/meta-data/",
"http://[::1]/",
"http://[::ffff:127.0.0.1]/",
"http://localhost/",
"https://[fd00::1]/",
] {
assert_eq!(verdict(url), Verdict::Block, "{url} should be blocked");
}
}
#[test]
fn verdict_allows_public_ip_literals() {
for url in ["http://93.184.216.34/", "https://[2606:4700:4700::1111]/"] {
assert_eq!(verdict(url), Verdict::Allow, "{url} should be allowed");
}
}
#[test]
fn verdict_defers_hostnames_to_resolution() {
assert_eq!(
verdict("https://cdn.example.com/img.jpg"),
Verdict::ResolveHost {
host: "cdn.example.com".to_string(),
port: 443
}
);
assert_eq!(
verdict("http://catalog.test:8080/list"),
Verdict::ResolveHost {
host: "catalog.test".to_string(),
port: 8080
}
);
}
#[tokio::test]
async fn is_blocked_true_for_private_literal_no_dns() {
assert!(is_blocked("http://169.254.169.254/").await);
assert!(!is_blocked("http://93.184.216.34/").await);
// Non-network scheme is always allowed through.
assert!(!is_blocked("about:blank").await);
}
#[test]
fn enabled_toggle_roundtrips() {
// Global; restore afterwards so other tests see the default.
let prev = is_enabled();
set_enabled(true);
assert!(is_enabled());
set_enabled(false);
assert!(!is_enabled());
set_enabled(prev);
}
}

View File

@@ -19,6 +19,7 @@ pub mod content;
pub mod daemon; pub mod daemon;
pub mod detect; pub mod detect;
pub mod diff; pub mod diff;
pub mod intercept;
pub mod jobs; pub mod jobs;
pub mod nav; pub mod nav;
pub mod pipeline; pub mod pipeline;

View File

@@ -85,6 +85,28 @@ pub async fn wait_for_selector(
/// under a second. /// under a second.
pub const SELECTOR_TIMEOUT: Duration = Duration::from_secs(10); pub const SELECTOR_TIMEOUT: Duration = Duration::from_secs(10);
/// Run `body` to completion, then run `close` — on *every* exit path,
/// including an early `?` / `return` inside `body`. chromiumoxide's `Page`
/// does **not** close its CDP target on drop, so a fetch helper that opens a
/// page with `browser.new_page(...)` and then bails via `?` on a nav /
/// content-read error would leak a browser tab for the process's lifetime.
/// Wrapping the fallible work in `close_after` — with `close` built from a
/// clone of the page — guarantees the tab is closed regardless of how `body`
/// returns.
///
/// Both arguments are pre-built futures, so this stays generic over the
/// body's return type (the anyhow and `PageError` fetch paths both use it)
/// and references no browser types — which also lets it be unit-tested
/// without standing up a real `Page`.
pub(crate) async fn close_after<R>(
close: impl std::future::Future<Output = ()>,
body: impl std::future::Future<Output = R>,
) -> R {
let result = body.await;
close.await;
result
}
impl NavError { impl NavError {
/// Does this navigation error indicate the underlying Chromium /// Does this navigation error indicate the underlying Chromium
/// process has died or its CDP connection has dropped? Used by the /// process has died or its CDP connection has dropped? Used by the
@@ -150,6 +172,41 @@ mod tests {
assert!(result.is_err(), "expected Elapsed on a hung future"); assert!(result.is_err(), "expected Elapsed on a hung future");
} }
#[tokio::test]
async fn close_after_runs_close_and_returns_value_on_ok() {
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
let closed = Arc::new(AtomicBool::new(false));
let c = closed.clone();
let out: anyhow::Result<i32> = close_after(
async move { c.store(true, Ordering::SeqCst) },
async { Ok(7) },
)
.await;
assert_eq!(out.unwrap(), 7);
assert!(closed.load(Ordering::SeqCst), "close must run on the happy path");
}
#[tokio::test]
async fn close_after_runs_close_even_when_body_errs() {
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
let closed = Arc::new(AtomicBool::new(false));
let c = closed.clone();
// This is the leak the fix targets: the body bails before it could
// close the page itself, and `close_after` must still close it.
let out: anyhow::Result<()> =
close_after(async move { c.store(true, Ordering::SeqCst) }, async {
anyhow::bail!("nav failed")
})
.await;
assert!(out.is_err());
assert!(
closed.load(Ordering::SeqCst),
"the page must be closed even when the body returns Err"
);
}
#[test] #[test]
fn nav_error_timeout_message_includes_duration() { fn nav_error_timeout_message_includes_duration() {
let e = NavError::Timeout(Duration::from_secs(30)); let e = NavError::Timeout(Duration::from_secs(30));

View File

@@ -31,11 +31,13 @@
//! URL string, and the byte accumulator is keyed off a generic stream. //! URL string, and the byte accumulator is keyed off a generic stream.
//! Easy to unit-test without a live network or browser. //! Easy to unit-test without a live network or browser.
use std::net::IpAddr; use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr};
use std::sync::Arc;
use anyhow::{bail, Context}; use anyhow::{bail, Context};
use bytes::BytesMut; use bytes::BytesMut;
use futures_util::StreamExt; use futures_util::StreamExt;
use reqwest::dns::{Addrs, Name, Resolve, Resolving};
use reqwest::Url; use reqwest::Url;
/// Default per-image download cap. A page image is generally <2 MiB; /// Default per-image download cap. A page image is generally <2 MiB;
@@ -179,7 +181,7 @@ fn ensure_public_target_inner(raw_url: &str) -> Result<Url, UrlSafetyError> {
Ok(url) Ok(url)
} }
fn is_private_ip(ip: &IpAddr) -> bool { pub(crate) fn is_private_ip(ip: &IpAddr) -> bool {
match ip { match ip {
IpAddr::V4(v4) => { IpAddr::V4(v4) => {
v4.is_loopback() v4.is_loopback()
@@ -193,11 +195,14 @@ fn is_private_ip(ip: &IpAddr) -> bool {
|| v4.octets()[0] == 0 || v4.octets()[0] == 0
} }
IpAddr::V6(v6) => { IpAddr::V6(v6) => {
// IPv4-mapped IPv6 (::ffff:0:0/96): unwrap to the embedded // Any IPv6 form that *embeds* an IPv4 address (mapped
// IPv4 and recurse so `::ffff:127.0.0.1` is caught by the // `::ffff:0:0/96`, compatible `::/96`, NAT64 `64:ff9b::/96`,
// IPv4 loopback check rather than passing through. // 6to4 `2002::/16`) is unwrapped and re-checked as its IPv4 —
// `Ipv6Addr::is_loopback()` only matches `::1` exactly. // otherwise `::127.0.0.1` / `2002:7f00:1::` / `64:ff9b::7f00:1`
if let Some(v4) = v6.to_ipv4_mapped() { // would smuggle an internal IPv4 past the check (the audit's
// IPv6-embedding gap). `Ipv6Addr::is_loopback()` only matches
// `::1` exactly, so these embeddings need explicit handling.
if let Some(v4) = embedded_ipv4(v6) {
return is_private_ip(&IpAddr::V4(v4)); return is_private_ip(&IpAddr::V4(v4));
} }
v6.is_loopback() v6.is_loopback()
@@ -210,6 +215,80 @@ fn is_private_ip(ip: &IpAddr) -> bool {
} }
} }
/// Extract the IPv4 address embedded in an IPv6 literal, for every
/// transitional encoding that can carry one: IPv4-mapped (`::ffff:0:0/96`),
/// IPv4-compatible (`::/96`, deprecated but still routable via some stacks),
/// NAT64 (`64:ff9b::/96`), and 6to4 (`2002::/16`). Returns `None` for a
/// native IPv6 address. Callers recurse into [`is_private_ip`] on the result
/// so a private IPv4 can't hide inside an IPv6 literal.
fn embedded_ipv4(v6: &Ipv6Addr) -> Option<Ipv4Addr> {
let seg = v6.segments();
let low32 = |a: u16, b: u16| Ipv4Addr::new((a >> 8) as u8, (a & 0xff) as u8, (b >> 8) as u8, (b & 0xff) as u8);
// ::ffff:0:0/96 (mapped) and ::/96 (compatible) — top 96 bits zero,
// except mapped which has 0xffff at seg[5]. to_ipv4() covers both.
if seg[0..5] == [0, 0, 0, 0, 0] && (seg[5] == 0 || seg[5] == 0xffff) {
return Some(low32(seg[6], seg[7]));
}
// NAT64 64:ff9b::/96
if seg[0] == 0x0064 && seg[1] == 0xff9b && seg[2..6] == [0, 0, 0, 0] {
return Some(low32(seg[6], seg[7]));
}
// 6to4 2002::/16 — embedded IPv4 is bits 16..48 (seg[1], seg[2]).
if seg[0] == 0x2002 {
return Some(low32(seg[1], seg[2]));
}
None
}
/// A `reqwest::dns::Resolve` that performs normal system resolution, then
/// drops any resolved address in a private / loopback / link-local / metadata
/// range. Installed on every crawler + analysis reqwest client so a hostname
/// that resolves to an internal IP (DNS rebinding: an attacker-owned domain
/// with an `A` record for `169.254.169.254` or `10.x`) can never be connected
/// to — closing the TOCTOU gap that the string-only `ensure_public_target`
/// check leaves open. Fires per connection, so it also guards redirect hops.
#[derive(Debug, Default)]
pub struct SafeResolver;
/// Partition resolved addresses into the public ones, rejecting when nothing
/// survives. Split out from the async `resolve` so the security-critical
/// filter is unit-testable without real DNS.
fn retain_public_addrs(
host: &str,
addrs: impl Iterator<Item = SocketAddr>,
) -> Result<Vec<SocketAddr>, BlockedResolution> {
let public: Vec<SocketAddr> = addrs.filter(|a| !is_private_ip(&a.ip())).collect();
if public.is_empty() {
return Err(BlockedResolution(host.to_string()));
}
Ok(public)
}
#[derive(Debug, thiserror::Error)]
#[error("host {0} resolved only to private/blocked addresses")]
struct BlockedResolution(String);
impl Resolve for SafeResolver {
fn resolve(&self, name: Name) -> Resolving {
Box::pin(async move {
let host = name.as_str().to_string();
// Port 0: reqwest overrides it with the URL's port after
// resolution (same convention as reqwest's default GaiResolver).
let resolved = tokio::net::lookup_host((host.as_str(), 0))
.await
.map_err(|e| Box::new(e) as Box<dyn std::error::Error + Send + Sync>)?;
let public = retain_public_addrs(&host, resolved)
.map_err(|e| Box::new(e) as Box<dyn std::error::Error + Send + Sync>)?;
Ok(Box::new(public.into_iter()) as Addrs)
})
}
}
/// Shared [`SafeResolver`] for wiring into `ClientBuilder::dns_resolver`.
pub fn safe_dns_resolver() -> Arc<SafeResolver> {
Arc::new(SafeResolver)
}
#[derive(Debug, thiserror::Error, PartialEq, Eq)] #[derive(Debug, thiserror::Error, PartialEq, Eq)]
pub enum UrlSafetyError { pub enum UrlSafetyError {
#[error("URL is not parseable")] #[error("URL is not parseable")]
@@ -609,6 +688,61 @@ mod tests {
assert!(matches!(err, UrlSafetyError::PrivateIp(_))); assert!(matches!(err, UrlSafetyError::PrivateIp(_)));
} }
#[test]
fn is_private_ip_unwraps_embedded_ipv4_encodings() {
// Every IPv6 encoding that can smuggle an internal IPv4 must be
// caught. The audit flagged compatible ::/96, NAT64, and 6to4 as
// gaps past the original mapped-only handling.
for s in [
"::ffff:127.0.0.1", // IPv4-mapped loopback
"::127.0.0.1", // IPv4-compatible loopback (was a gap)
"::ffff:10.1.2.3", // mapped RFC1918
"::10.1.2.3", // compatible RFC1918 (was a gap)
"64:ff9b::7f00:1", // NAT64 of 127.0.0.1 (was a gap)
"64:ff9b::a01:203", // NAT64 of 10.1.2.3
"2002:7f00:1::", // 6to4 of 127.0.0.1 (was a gap)
"2002:a01:203::", // 6to4 of 10.1.2.3
"2002:a9fe:a9fe::", // 6to4 of 169.254.169.254 (metadata)
] {
let ip: IpAddr = s.parse().unwrap();
assert!(is_private_ip(&ip), "{s} must be flagged private");
}
}
#[test]
fn is_private_ip_allows_public_embedded_and_native_ipv6() {
// A public IPv4 embedded in IPv6, and a native public IPv6, must
// NOT be flagged — the unwrap only blocks when the embedded v4 is
// itself private.
for s in [
"::ffff:8.8.8.8", // mapped public
"2002:808:808::", // 6to4 of 8.8.8.8 (public)
"2606:4700:4700::1111", // native public (Cloudflare)
] {
let ip: IpAddr = s.parse().unwrap();
assert!(!is_private_ip(&ip), "{s} must be allowed");
}
}
#[test]
fn retain_public_addrs_drops_private_and_errors_when_all_private() {
use std::net::{Ipv4Addr, SocketAddr};
let pub_addr = SocketAddr::from((Ipv4Addr::new(93, 184, 216, 34), 0));
let loopback = SocketAddr::from((Ipv4Addr::new(127, 0, 0, 1), 0));
let metadata = SocketAddr::from((Ipv4Addr::new(169, 254, 169, 254), 0));
// Mixed result keeps only the public address.
let kept =
retain_public_addrs("mixed.example", [pub_addr, loopback, metadata].into_iter())
.expect("public address survives");
assert_eq!(kept, vec![pub_addr]);
// All-private (DNS rebinding to internal) is rejected outright.
let err =
retain_public_addrs("rebind.attacker", [loopback, metadata].into_iter()).unwrap_err();
assert!(err.to_string().contains("rebind.attacker"));
}
#[test] #[test]
fn safe_url_blocks_non_http_schemes() { fn safe_url_blocks_non_http_schemes() {
let allow = allow_just("anywhere"); let allow = allow_just("anywhere");

View File

@@ -291,25 +291,33 @@ async fn fetch_probe_html(browser: &Browser, probe_url: &str) -> anyhow::Result<
// the URL ever becomes attacker-influenced. // the URL ever becomes attacker-influenced.
crate::crawler::safety::ensure_public_target(probe_url) crate::crawler::safety::ensure_public_target(probe_url)
.with_context(|| format!("refuse to navigate unsafe probe URL {probe_url}"))?; .with_context(|| format!("refuse to navigate unsafe probe URL {probe_url}"))?;
let page = browser let page = crate::crawler::intercept::open_page(browser, probe_url)
.new_page(probe_url)
.await .await
.with_context(|| format!("open probe page {probe_url}"))?; .with_context(|| format!("open probe page {probe_url}"))?;
crate::crawler::nav::wait_for_nav(&page) // Close the tab on every exit path — a `?` on wait_for_nav / content()
.await // would otherwise leak it (chromiumoxide doesn't close on drop).
.context("wait for nav on probe")?; let closer = page.clone();
// Best-effort wait for the layout marker. Timeout is fine — the crate::crawler::nav::close_after(
// probe classifier handles a missing `#logo` as Transient anyway, async move {
// and the verify loop retries on Transient. closer.close().await.ok();
let _ = crate::crawler::nav::wait_for_selector( },
&page, async {
"#logo", crate::crawler::nav::wait_for_nav(&page)
crate::crawler::nav::SELECTOR_TIMEOUT, .await
.context("wait for nav on probe")?;
// Best-effort wait for the layout marker. Timeout is fine — the
// probe classifier handles a missing `#logo` as Transient anyway,
// and the verify loop retries on Transient.
let _ = crate::crawler::nav::wait_for_selector(
&page,
"#logo",
crate::crawler::nav::SELECTOR_TIMEOUT,
)
.await;
page.content().await.context("read probe html")
},
) )
.await; .await
let html = page.content().await.context("read probe html")?;
page.close().await.ok();
Ok(html)
} }
#[cfg(test)] #[cfg(test)]

View File

@@ -20,7 +20,7 @@ use super::{
use crate::crawler::detect::{ use crate::crawler::detect::{
has_logo_sentinel, is_broken_page_body, retry_on_transient_with_hook, PageError, has_logo_sentinel, is_broken_page_body, retry_on_transient_with_hook, PageError,
}; };
use crate::crawler::nav::{wait_for_nav, wait_for_selector, NavError, SELECTOR_TIMEOUT}; use crate::crawler::nav::{close_after, wait_for_nav, wait_for_selector, NavError, SELECTOR_TIMEOUT};
use crate::crawler::safety::ensure_public_target; use crate::crawler::safety::ensure_public_target;
/// `sources.id` value for this Source impl. Exposed as a const so the /// `sources.id` value for this Source impl. Exposed as a const so the
@@ -253,31 +253,37 @@ async fn navigate(
) -> Result<String, PageError> { ) -> Result<String, PageError> {
guard_navigate_url(url)?; guard_navigate_url(url)?;
ctx.rate.wait_for(url).await?; ctx.rate.wait_for(url).await?;
let page = ctx let page = crate::crawler::intercept::open_page(ctx.browser, url)
.browser
.new_page(url)
.await .await
.map_err(|e| PageError::Other(anyhow::Error::from(e)))?; .map_err(|e| PageError::Other(anyhow::Error::from(e)))?;
match wait_for_nav(&page).await { // Close the tab on every exit path — the previous code closed on the two
Ok(()) => {} // nav-error branches but leaked it on a content() read error.
Err(NavError::Timeout(_)) => { let closer = page.clone();
page.close().await.ok(); close_after(
return Err(PageError::transient("nav timeout")); async move {
} closer.close().await.ok();
Err(NavError::Cdp(e)) => { },
page.close().await.ok(); async {
return Err(PageError::Other(anyhow::Error::from(e))); match wait_for_nav(&page).await {
} Ok(()) => {}
} Err(NavError::Timeout(_)) => {
// Best-effort wait for the page-type marker. We deliberately return Err(PageError::transient("nav timeout"));
// discard a timeout here — see fn-level doc. }
let _ = wait_for_selector(&page, marker, SELECTOR_TIMEOUT).await; Err(NavError::Cdp(e)) => {
let html = page return Err(PageError::Other(anyhow::Error::from(e)));
.content() }
.await }
.map_err(|e| PageError::Other(anyhow::Error::from(e)))?; // Best-effort wait for the page-type marker. We deliberately
page.close().await.ok(); // discard a timeout here — see fn-level doc.
classify_navigate_html(html) let _ = wait_for_selector(&page, marker, SELECTOR_TIMEOUT).await;
let html = page
.content()
.await
.map_err(|e| PageError::Other(anyhow::Error::from(e)))?;
classify_navigate_html(html)
},
)
.await
} }
/// Classify a fetched body. The broken-page template is universal across /// Classify a fetched body. The broken-page template is universal across

View File

@@ -163,7 +163,7 @@ const FILTER_WHERE: &str = r#"
/// (`mangas_created_at_idx`, `mangas_updated_at_idx`, `mangas_title_lower_idx`) /// (`mangas_created_at_idx`, `mangas_updated_at_idx`, `mangas_title_lower_idx`)
/// and the trigram GIN indexes (0005_search.sql, 0009_manga_metadata.sql) keep /// and the trigram GIN indexes (0005_search.sql, 0009_manga_metadata.sql) keep
/// the search filter cheap as the library grows. /// the search filter cheap as the library grows.
pub async fn list(pool: &PgPool, query: &ListQuery) -> AppResult<(Vec<Manga>, i64)> { pub async fn list(pool: &PgPool, query: &ListQuery) -> AppResult<(Vec<Manga>, Option<i64>)> {
// Both `col` and `dir` are interpolated from hard-coded enums, never from // Both `col` and `dir` are interpolated from hard-coded enums, never from
// request input, so this is not a SQL injection seam. The trailing `id` is // request input, so this is not a SQL injection seam. The trailing `id` is
// a stable tie-break that keeps pagination deterministic across rows with // a stable tie-break that keeps pagination deterministic across rows with
@@ -223,22 +223,33 @@ pub async fn list(pool: &PgPool, query: &ListQuery) -> AppResult<(Vec<Manga>, i6
.fetch_all(pool) .fetch_all(pool)
.await?; .await?;
let count_sql = format!( // The count reuses FILTER_WHERE — up to five correlated NOT EXISTS/unnest
r#" // subqueries (plus a page_content_warnings join under CW filters) over the
SELECT count(*) FROM mangas // whole filtered set. Recomputing it on every page made pagination scale
WHERE {FILTER_WHERE} // with catalog size. It doesn't change as the caller walks pages, so
"# // compute it once on the first page (offset 0) and return None thereafter;
); // the pagination envelope serialises that as `total: null`.
let (total,): (i64,) = sqlx::query_as(&count_sql) let total = if query.offset == 0 {
.bind(search) let count_sql = format!(
.bind(status) r#"
.bind(&query.author_ids) SELECT count(*) FROM mangas
.bind(&query.genre_ids) WHERE {FILTER_WHERE}
.bind(&query.tag_ids) "#
.bind(&query.cw_include) );
.bind(&query.cw_exclude) let (total,): (i64,) = sqlx::query_as(&count_sql)
.fetch_one(pool) .bind(search)
.await?; .bind(status)
.bind(&query.author_ids)
.bind(&query.genre_ids)
.bind(&query.tag_ids)
.bind(&query.cw_include)
.bind(&query.cw_exclude)
.fetch_one(pool)
.await?;
Some(total)
} else {
None
};
Ok((rows, total)) Ok((rows, total))
} }
@@ -249,7 +260,7 @@ pub async fn list(pool: &PgPool, query: &ListQuery) -> AppResult<(Vec<Manga>, i6
pub async fn list_cards( pub async fn list_cards(
pool: &PgPool, pool: &PgPool,
query: &ListQuery, query: &ListQuery,
) -> AppResult<(Vec<MangaCard>, i64)> { ) -> AppResult<(Vec<MangaCard>, Option<i64>)> {
let (rows, total) = list(pool, query).await?; let (rows, total) = list(pool, query).await?;
let cards = cards_from_rows(pool, rows).await?; let cards = cards_from_rows(pool, rows).await?;
Ok((cards, total)) Ok((cards, total))

View File

@@ -381,7 +381,7 @@ pub async fn bootstrap_admin(
.await?; .await?;
} }
None => { None => {
let hash = crate::auth::password::hash_password(password)?; let hash = crate::auth::password::hash_password_async(password.to_string()).await?;
sqlx::query("INSERT INTO users (username, password_hash, is_admin) VALUES ($1, $2, true)") sqlx::query("INSERT INTO users (username, password_hash, is_admin) VALUES ($1, $2, true)")
.bind(username) .bind(username)
.bind(&hash) .bind(&hash)

View File

@@ -269,6 +269,7 @@ impl CrawlerSettings {
tor_control_cookie_path: base.tor_control_cookie_path.clone(), tor_control_cookie_path: base.tor_control_cookie_path.clone(),
tor_recircuit_max_attempts: base.tor_recircuit_max_attempts, tor_recircuit_max_attempts: base.tor_recircuit_max_attempts,
browser: base.browser.clone(), browser: base.browser.clone(),
ssrf_intercept: base.ssrf_intercept,
}) })
} }
} }

View File

@@ -99,6 +99,36 @@ async fn dispatch_persists_ocr_lines_and_search_doc(pool: PgPool) {
assert!(has_doc, "search_doc must be populated from OCR text"); assert!(has_doc, "search_doc must be populated from OCR text");
} }
#[sqlx::test(migrations = "./migrations")]
async fn dispatch_rejects_page_image_over_the_byte_cap(pool: PgPool) {
let dir = TempDir::new().unwrap();
let storage: Arc<dyn Storage> = Arc::new(LocalStorage::new(dir.path()));
let key = "mangas/x/big.png";
// 4 KiB on disk, 1 KiB cap — the streamed read must bail on the cap
// rather than buffering the whole blob and OCR-ing it.
storage.put(key, &vec![0u8; 4096]).await.unwrap();
let page_id = seed_page(&pool, key).await;
let dispatcher = OcrAnalyzeDispatcher {
db: pool.clone(),
storage: Arc::clone(&storage),
engine: StubOcrEngine::new(&["should not run"]),
max_image_bytes: 1024,
ocr_permits: Arc::new(tokio::sync::Semaphore::new(1)),
};
let err = dispatcher.dispatch(page_id).await.unwrap_err();
assert!(
err.chain().any(|c| c.to_string().contains("cap")),
"expected an over-cap error, got: {err:#}"
);
// A rejected page must not land an analysis row.
assert!(repo::page_analysis::load(&pool, page_id)
.await
.unwrap()
.is_none());
}
#[sqlx::test(migrations = "./migrations")] #[sqlx::test(migrations = "./migrations")]
async fn dispatch_missing_page_is_noop(pool: PgPool) { async fn dispatch_missing_page_is_noop(pool: PgPool) {
let dir = TempDir::new().unwrap(); let dir = TempDir::new().unwrap();

View File

@@ -99,6 +99,42 @@ async fn list_returns_total_count_independent_of_pagination(pool: PgPool) {
assert_eq!(body["page"]["total"], 3); assert_eq!(body["page"]["total"], 3);
} }
#[sqlx::test(migrations = "./migrations")]
async fn list_total_is_computed_only_on_the_first_page(pool: PgPool) {
let h = common::harness(pool);
let (_, cookie) = common::register_user(&h.app).await;
for title in ["One Piece", "Berserk", "Vinland Saga"] {
seed(&h.app, &cookie, title).await;
}
// Page 1 (offset 0): total is the full population.
let body0 = common::body_json(
h.app
.clone()
.oneshot(common::get("/api/v1/mangas?limit=2&offset=0"))
.await
.unwrap(),
)
.await;
assert_eq!(body0["page"]["total"], 3);
// Page 2 (offset 2): total is omitted (null) — the correlated count is
// not recomputed on every page. Items still paginate correctly.
let body1 = common::body_json(
h.app
.oneshot(common::get("/api/v1/mangas?limit=2&offset=2"))
.await
.unwrap(),
)
.await;
assert!(
body1["page"]["total"].is_null(),
"total should be null past the first page, got {}",
body1["page"]["total"]
);
assert_eq!(body1["items"].as_array().unwrap().len(), 1);
}
#[sqlx::test(migrations = "./migrations")] #[sqlx::test(migrations = "./migrations")]
async fn search_via_trigram_tolerates_typos(pool: PgPool) { async fn search_via_trigram_tolerates_typos(pool: PgPool) {
let h = common::harness(pool); let h = common::harness(pool);

View File

@@ -149,6 +149,56 @@ async fn private_mode_blocks_register_even_when_self_register_enabled(pool: PgPo
assert_eq!(body["error"]["code"], "forbidden"); assert_eq!(body["error"]["code"], "forbidden");
} }
#[sqlx::test(migrations = "./migrations")]
async fn private_mode_serves_files_as_private_not_public_cacheable(pool: PgPool) {
// In private mode /files is auth-gated, so a served blob must NOT carry a
// `public` Cache-Control: a shared cache / CDN would otherwise store it and
// hand it to anonymous clients, defeating the gate. It should be `private`.
//
// Register via a public harness on the shared pool so the session exists,
// then upload + fetch a cover through the private harness (same storage).
let public = common::harness(pool.clone());
let (_, cookie) = common::register_user(&public.app).await;
let private = common::harness_with_private_mode(pool);
let form = common::MultipartBuilder::new()
.add_json("metadata", json!({ "title": "Secret Library" }))
.add_file("cover", "cover.png", "image/png", &common::fake_png_bytes());
let created = private
.app
.clone()
.oneshot(common::post_multipart_with_cookie(
"/api/v1/mangas",
form,
&cookie,
))
.await
.unwrap();
assert_eq!(created.status(), StatusCode::CREATED);
let body = common::body_json(created).await;
let key = body["cover_image_path"].as_str().unwrap().to_string();
let resp = private
.app
.oneshot(common::get_with_cookie(
&format!("/api/v1/files/{key}"),
&cookie,
))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let cc = resp
.headers()
.get(axum::http::header::CACHE_CONTROL)
.unwrap()
.to_str()
.unwrap();
assert!(
cc.starts_with("private") && !cc.contains("public"),
"private-mode blobs must be `private`, not `public`; got: {cc}"
);
}
#[sqlx::test(migrations = "./migrations")] #[sqlx::test(migrations = "./migrations")]
async fn auth_config_reports_private_mode_and_effective_self_register(pool: PgPool) { async fn auth_config_reports_private_mode_and_effective_self_register(pool: PgPool) {
let h = common::harness_with_private_mode(pool); let h = common::harness_with_private_mode(pool);

View File

@@ -172,6 +172,13 @@ async fn files_endpoint_streams_in_multiple_frames(pool: PgPool) {
resp.headers().get("x-content-type-options").unwrap(), resp.headers().get("x-content-type-options").unwrap(),
"nosniff" "nosniff"
); );
// Blobs are content-addressed by unguessable, immutable keys, so they're
// safe to cache forever — this is what makes the reader's page + next-
// chapter preloading actually hit cache instead of re-downloading.
assert_eq!(
resp.headers().get(header::CACHE_CONTROL).unwrap(),
"public, max-age=31536000, immutable"
);
let mut body = resp.into_body(); let mut body = resp.into_body();
let mut frames = 0usize; let mut frames = 0usize;

View File

@@ -62,6 +62,43 @@ async fn headless_browser_can_navigate_and_read_title() {
handle.close().await.expect("close cleanly"); handle.close().await.expect("close cleanly");
} }
/// Smoke-test the opt-in SSRF navigation guard (`CRAWLER_SSRF_INTERCEPT`).
/// With interception ON, a normal (allowed) navigation must still complete —
/// i.e. enabling CDP `Fetch` and installing the request handler must NOT wedge
/// page loads. This is the regression the interceptor's fragility risks; it
/// can only be exercised with a real Chromium, hence `#[ignore]`.
///
/// (The private-target *blocking* path is covered by the pure-logic unit tests
/// in `crawler::intercept` — `verdict` / `is_blocked` — which don't need a
/// browser.)
#[tokio::test]
#[ignore = "downloads Chromium; run with --ignored"]
async fn ssrf_interception_does_not_wedge_allowed_navigation() {
use mangalord::crawler::intercept;
const PAGE: &str =
"data:text/html,<html><head><title>Guarded%20OK</title></head><body></body></html>";
intercept::set_enabled(true);
let handle = browser::launch(LaunchOptions::headless())
.await
.expect("launch headless chromium");
// Route through the guarded opener (blank page -> Fetch.enable -> handler
// -> goto). If the handler failed to continue the navigation, this would
// hang until the test harness times out.
let page = intercept::open_page(handle.browser(), PAGE)
.await
.expect("guarded open_page");
page.wait_for_navigation().await.expect("wait for navigation");
let title = page.get_title().await.expect("get title");
assert_eq!(title.as_deref(), Some("Guarded OK"));
handle.close().await.expect("close cleanly");
intercept::set_enabled(false);
}
/// Live end-to-end: navigate to a real page, get the rendered HTML, and /// Live end-to-end: navigate to a real page, get the rendered HTML, and
/// parse it with `scraper`. ipify.org renders the visitor's public IP /// parse it with `scraper`. ipify.org renders the visitor's public IP
/// into the page DOM, so a successful run proves browser → render → /// into the page DOM, so a successful run proves browser → render →

View File

@@ -71,12 +71,19 @@ services:
BIND_ADDRESS: 0.0.0.0:8080 BIND_ADDRESS: 0.0.0.0:8080
STORAGE_DIR: /var/lib/mangalord/storage STORAGE_DIR: /var/lib/mangalord/storage
RUST_LOG: ${RUST_LOG:-info,mangalord=debug} RUST_LOG: ${RUST_LOG:-info,mangalord=debug}
# Postgres connection-pool sizing — see .env.example for context.
DB_MAX_CONNECTIONS: ${DB_MAX_CONNECTIONS:-20}
DB_ACQUIRE_TIMEOUT_SECS: ${DB_ACQUIRE_TIMEOUT_SECS:-10}
# Auth / cookies — see .env.example for context. # Auth / cookies — see .env.example for context.
COOKIE_SECURE: ${COOKIE_SECURE:-true} COOKIE_SECURE: ${COOKIE_SECURE:-true}
COOKIE_DOMAIN: ${COOKIE_DOMAIN:-} COOKIE_DOMAIN: ${COOKIE_DOMAIN:-}
SESSION_TTL_DAYS: ${SESSION_TTL_DAYS:-30} SESSION_TTL_DAYS: ${SESSION_TTL_DAYS:-30}
AUTH_RATE_PER_SEC: ${AUTH_RATE_PER_SEC:-5} AUTH_RATE_PER_SEC: ${AUTH_RATE_PER_SEC:-5}
AUTH_RATE_BURST: ${AUTH_RATE_BURST:-10} AUTH_RATE_BURST: ${AUTH_RATE_BURST:-10}
# The SvelteKit container is the single trusted hop in front of the
# backend and stamps the real client IP, so per-IP rate limiting is on
# by default here (unlike the backend's safe-by-default `false`).
AUTH_TRUSTED_PROXY: ${AUTH_TRUSTED_PROXY:-true}
# CORS — same-origin by default; populate when serving the API on # CORS — same-origin by default; populate when serving the API on
# a different host than the frontend. # a different host than the frontend.
CORS_ALLOWED_ORIGINS: ${CORS_ALLOWED_ORIGINS:-} CORS_ALLOWED_ORIGINS: ${CORS_ALLOWED_ORIGINS:-}
@@ -105,6 +112,7 @@ services:
CRAWLER_JOB_TIMEOUT_SECS: ${CRAWLER_JOB_TIMEOUT_SECS:-600} CRAWLER_JOB_TIMEOUT_SECS: ${CRAWLER_JOB_TIMEOUT_SECS:-600}
CRAWLER_METADATA_MAX_CONSECUTIVE_FAILURES: ${CRAWLER_METADATA_MAX_CONSECUTIVE_FAILURES:-10} CRAWLER_METADATA_MAX_CONSECUTIVE_FAILURES: ${CRAWLER_METADATA_MAX_CONSECUTIVE_FAILURES:-10}
CRAWLER_BROWSER_RESTART_THRESHOLD: ${CRAWLER_BROWSER_RESTART_THRESHOLD:-3} CRAWLER_BROWSER_RESTART_THRESHOLD: ${CRAWLER_BROWSER_RESTART_THRESHOLD:-3}
CRAWLER_SSRF_INTERCEPT: ${CRAWLER_SSRF_INTERCEPT:-false}
# Crawler daemon schedule + retention. CRAWLER_DAEMON=false keeps # Crawler daemon schedule + retention. CRAWLER_DAEMON=false keeps
# the in-process scheduler off; the dashboard force-resync still works. # the in-process scheduler off; the dashboard force-resync still works.
CRAWLER_DAEMON: ${CRAWLER_DAEMON:-true} CRAWLER_DAEMON: ${CRAWLER_DAEMON:-true}

View File

@@ -170,45 +170,33 @@ test.describe('reader ?page=N deep link', () => {
await expect(page.getByTestId('reader-continuous')).toBeVisible(); await expect(page.getByTestId('reader-continuous')).toBeVisible();
// Pages 1..=4 (1-indexed in the testid, 0-indexed in the // The whole chapter is eager, so the scroll target (page 4) and
// template; initialIndex = 3 means we eager-load 0..=3, i.e. // every page before it have settled heights before the
// testids 1..4). Without this guard, page 2+ would be lazy // scroll-to-`?page=N` effect runs — the target can't be pushed
// and their 0×0 placeholders would let the scroll target // past the viewport by later pages loading in.
// appear far above its final position. for (const n of [1, 2, 3, 4, 5, 6]) {
for (const n of [1, 2, 3, 4]) {
await expect(page.getByTestId(`reader-page-${n}`)).toHaveAttribute( await expect(page.getByTestId(`reader-page-${n}`)).toHaveAttribute(
'loading', 'loading',
'eager' 'eager'
); );
} }
// Pages beyond the target stay lazy.
for (const n of [5, 6]) {
await expect(page.getByTestId(`reader-page-${n}`)).toHaveAttribute(
'loading',
'lazy'
);
}
}); });
test('continuous mode: no ?page= eager-loads only the first two', async ({ test('continuous mode: eager-loads the whole chapter up front', async ({
page page
}) => { }) => {
await mockReader(page, 'continuous'); await mockReader(page, 'continuous');
await page.goto(`/manga/${mangaId}/chapter/${chapterId}`); await page.goto(`/manga/${mangaId}/chapter/${chapterId}`);
await expect(page.getByTestId('reader-continuous')).toBeVisible(); await expect(page.getByTestId('reader-continuous')).toBeVisible();
await expect(page.getByTestId('reader-page-1')).toHaveAttribute( // Every page is eager — the whole current chapter is preloaded so
'loading', // pages don't pop in / reflow as the reader scrolls onto them.
'eager' for (const n of [1, 2, 3, 4, 5, 6]) {
); await expect(page.getByTestId(`reader-page-${n}`)).toHaveAttribute(
await expect(page.getByTestId('reader-page-2')).toHaveAttribute( 'loading',
'loading', 'eager'
'eager' );
); }
await expect(page.getByTestId('reader-page-3')).toHaveAttribute(
'loading',
'lazy'
);
}); });
test('single mode: ?page=N opens at the requested page', async ({ page }) => { test('single mode: ?page=N opens at the requested page', async ({ page }) => {

View File

@@ -0,0 +1,119 @@
import { test, expect, type Page } from './fixtures';
// Continuous mode eager-loads a window of pages ahead of the reading position
// (the deep-linked page + a lead, min 6 pages), so those are fetched and sized
// up front. Pages beyond the window are `loading="lazy"` — the browser fetches
// them natively as the reader scrolls near, so opening a long chapter (up to
// 2000 pages) doesn't fire every request or decode every image at once. Unloaded
// lazy pages reserve height so they stay distinct scroll targets for the
// progress IntersectionObserver.
const MANGA_ID = 'm1';
const CH1 = 'c1';
// Enough pages that the last one sits tens of thousands of px below the fold —
// well beyond any browser's lazy-load distance threshold — so "far pages don't
// prefetch at the top" is unambiguous rather than threshold-brittle.
const PAGE_COUNT = 40;
function pages(prefix: string, n: number) {
return Array.from({ length: n }, (_, i) => ({
id: `${prefix}p${i + 1}`,
chapter_id: prefix,
page_number: i + 1,
storage_key: `${prefix}/${i + 1}`,
content_type: 'image/svg+xml'
}));
}
function chapter(id: string, number: number, count: number) {
return { id, manga_id: MANGA_ID, number, title: null, page_count: count, created_at: '2026-01-01T00:00:00Z', size_bytes: 0 };
}
async function mockReader(page: Page) {
await page.route('**/api/v1/**', async (route) => {
const { pathname } = new URL(route.request().url());
const json = (status: number, body: unknown) =>
route.fulfill({ status, contentType: 'application/json', body: JSON.stringify(body) });
if (pathname.includes('/files/')) {
// A viewBox + rect gives the <img> a real intrinsic size so pages
// get real (tall) heights — offscreen pages then stay below the
// fold instead of collapsing to 0 and piling into the viewport.
return route.fulfill({ status: 200, contentType: 'image/svg+xml', body: '<svg xmlns="http://www.w3.org/2000/svg" width="800" height="1200" viewBox="0 0 800 1200"><rect width="800" height="1200" fill="#888"/></svg>' });
}
if (pathname.endsWith('/auth/config')) return json(200, { self_register_enabled: true, private_mode: false });
if (pathname.endsWith('/auth/me')) return json(401, { error: { code: 'unauthenticated', message: 'no' } });
if (pathname.endsWith('/auth/me/preferences')) return json(401, { error: { code: 'unauthenticated', message: 'no' } });
if (pathname.endsWith(`/chapters/${CH1}/pages`)) return json(200, { pages: pages(CH1, PAGE_COUNT) });
if (pathname.endsWith(`/chapters/${CH1}`)) return json(200, chapter(CH1, 1, PAGE_COUNT));
if (pathname.includes(`/mangas/${MANGA_ID}/chapters`)) {
return json(200, { items: [chapter(CH1, 1, PAGE_COUNT)], page: { limit: 200, offset: 0, total: 1 } });
}
if (pathname.endsWith(`/mangas/${MANGA_ID}`)) return json(200, { id: MANGA_ID, title: 'Berserk', status: 'ongoing', alt_titles: [], description: null, cover_image_path: null, created_at: '2026-01-01T00:00:00Z', updated_at: '2026-01-01T00:00:00Z', authors: [], genres: [], tags: [], content_warnings: [], chapter_storage_bytes: 0 });
if (pathname.includes('/me/read-progress')) return json(401, { error: { code: 'unauthenticated', message: 'no' } });
return json(503, { error: { code: 'e2e_unmocked', message: pathname } });
});
}
async function goContinuous(page: Page, path: string) {
await page.addInitScript(() => localStorage.setItem('mangalord-reader-mode', 'continuous'));
await page.goto(path);
await expect(page.getByTestId('reader-continuous')).toBeVisible();
}
// EAGER_MIN in the reader: opening at page 1 (index 0) eager-loads pages 1..6.
const EAGER_THROUGH = 6;
test('eager-loads a leading window and leaves far pages lazy', async ({ page }) => {
await mockReader(page);
await goContinuous(page, `/manga/${MANGA_ID}/chapter/${CH1}`);
// The leading window is eager — warmed up front so the reader doesn't
// pop-in / reflow as it scrolls onto the next few pages.
for (let n = 1; n <= EAGER_THROUGH; n++) {
await expect(page.getByTestId(`reader-page-${n}`)).toHaveAttribute('loading', 'eager');
}
// Pages beyond the window stay lazy — not fetched until scrolled near, so
// a 2000-page chapter doesn't fire 2000 requests / decodes at once.
for (let n = EAGER_THROUGH + 1; n <= PAGE_COUNT; n++) {
await expect(page.getByTestId(`reader-page-${n}`)).toHaveAttribute('loading', 'lazy');
}
});
test('a windowed page is warmed up front without scrolling', async ({ page }) => {
await mockReader(page);
await goContinuous(page, `/manga/${MANGA_ID}/chapter/${CH1}`);
// A page inside the eager window finishes loading without any scroll — the
// preload payoff (no pop-in as the reader advances a few pages) is kept.
// (We can't assert the far lazy pages stay *unfetched* here: headless
// Chromium prefetches `loading="lazy"` images regardless of distance. The
// attribute partition above is the mechanism that defers them in the real
// OOM target — mobile Safari. Playwright can only pin the attributes.)
await expect(async () => {
const loaded = await page
.getByTestId(`reader-page-${EAGER_THROUGH}`)
.evaluate((img: HTMLImageElement) => img.complete && img.naturalWidth > 0);
expect(loaded).toBe(true);
}).toPass();
expect(await page.evaluate(() => window.scrollY)).toBe(0);
});
test('deep-link ?page=N keeps the target inside the eager window and lands the scroll', async ({
page
}) => {
await mockReader(page);
// initialIndex = 19 → eagerThrough = 22, so pages 1..23 are eager (the
// target and everything above it settle height before the scroll fires).
await goContinuous(page, `/manga/${MANGA_ID}/chapter/${CH1}?page=20`);
await expect(page.getByTestId('reader-page-20')).toHaveAttribute('loading', 'eager');
await expect(page.getByTestId('reader-page-22')).toHaveAttribute('loading', 'eager');
// A page far past the widened window is still lazy.
await expect(page.getByTestId(`reader-page-${PAGE_COUNT}`)).toHaveAttribute('loading', 'lazy');
// The scroll-to-`?page=N` effect lands the target in view (proves the
// eager window settled the heights above it — a short-landing scroll would
// leave page 20 out of the viewport).
await expect(page.getByTestId('reader-page-20')).toBeInViewport();
});

View File

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

View File

@@ -9,6 +9,7 @@ import {
} from 'vitest'; } from 'vitest';
import { import {
handle, handle,
setForwardedFor,
shouldBypassProxyTimeout, shouldBypassProxyTimeout,
stripHopByHopHeaders stripHopByHopHeaders
} from './hooks.server'; } from './hooks.server';
@@ -351,3 +352,29 @@ describe('stripHopByHopHeaders', () => {
expect(src.get('connection')).toBe('close'); expect(src.get('connection')).toBe('close');
}); });
}); });
describe('setForwardedFor', () => {
it('overrides any client-supplied x-forwarded-for with the real address', () => {
const headers = new Headers({ 'x-forwarded-for': '1.2.3.4', 'x-real-ip': '1.2.3.4' });
setForwardedFor(headers, '203.0.113.9');
expect(headers.get('x-forwarded-for')).toBe('203.0.113.9');
// x-real-ip is dropped so it can't be used to spoof either.
expect(headers.get('x-real-ip')).toBeNull();
});
it('strips the header entirely when no client address is available', () => {
const headers = new Headers({ 'x-forwarded-for': '9.9.9.9' });
setForwardedFor(headers, undefined);
expect(headers.get('x-forwarded-for')).toBeNull();
});
it('trims whitespace and treats blank as absent', () => {
const a = new Headers();
setForwardedFor(a, ' 198.51.100.2 ');
expect(a.get('x-forwarded-for')).toBe('198.51.100.2');
const b = new Headers({ 'x-forwarded-for': 'spoofed' });
setForwardedFor(b, ' ');
expect(b.get('x-forwarded-for')).toBeNull();
});
});

View File

@@ -47,6 +47,26 @@ export function stripHopByHopHeaders(src: Headers): Headers {
return out; return out;
} }
/**
* Stamp the real client address onto `x-forwarded-for` for the upstream
* request so axum can key its per-IP auth rate limiter on the actual client
* (the backend only honours this when `AUTH_TRUSTED_PROXY=true`). We
* **override** rather than append, and drop any incoming `x-forwarded-for` /
* `x-real-ip`, so a browser can't spoof its own IP to dodge the limit — this
* proxy is the single trusted hop. A missing/blank address leaves the headers
* untouched (backend then falls back to its shared bucket). Exported for
* unit-test coverage.
*/
export function setForwardedFor(headers: Headers, clientAddress: string | undefined): Headers {
headers.delete('x-real-ip');
if (clientAddress && clientAddress.trim() !== '') {
headers.set('x-forwarded-for', clientAddress.trim());
} else {
headers.delete('x-forwarded-for');
}
return headers;
}
/** /**
* Cap each proxied request at 5 minutes. The bound exists to surface * Cap each proxied request at 5 minutes. The bound exists to surface
* a wedged backend (stuck on a slow DB query, deadlocked, etc.) as a * a wedged backend (stuck on a slow DB query, deadlocked, etc.) as a
@@ -90,6 +110,17 @@ export const handle: Handle = async ({ event, resolve }) => {
const target = `${BACKEND_URL}${event.url.pathname}${event.url.search}`; const target = `${BACKEND_URL}${event.url.pathname}${event.url.search}`;
const headers = stripHopByHopHeaders(event.request.headers); const headers = stripHopByHopHeaders(event.request.headers);
// Forward the real client IP for the backend's per-IP auth rate
// limiter, overriding any client-supplied value (anti-spoof).
// `getClientAddress()` throws if the adapter can't determine it — fall
// back to stripping the header so no spoofed value survives.
let clientAddress: string | undefined;
try {
clientAddress = event.getClientAddress();
} catch {
clientAddress = undefined;
}
setForwardedFor(headers, clientAddress);
// AbortController times the upstream fetch out so a backend // AbortController times the upstream fetch out so a backend
// wedged on a slow DB query doesn't keep the browser request // wedged on a slow DB query doesn't keep the browser request

View File

@@ -90,6 +90,26 @@
// svelte-ignore state_referenced_locally // svelte-ignore state_referenced_locally
let index = $state(initialIndex); let index = $state(initialIndex);
let continuousPageEls: HTMLImageElement[] = $state([]); let continuousPageEls: HTMLImageElement[] = $state([]);
// ---- Continuous-mode eager window ----
//
// Eager-load a leading window of pages, lazy-load the rest. A chapter can
// hold up to 2000 pages; making every one `loading="eager"` (the previous
// behaviour) fired the whole chapter's requests up front and kept every
// decoded image resident — enough to OOM a mobile tab on a long chapter.
//
// The window is `[0 ..= eagerThrough]`. It always covers `0..=initialIndex`
// so the cold-load scroll-to-`?page=N` effect still has settled heights for
// the target and everything above it (the whole reason pages were eager),
// plus a small LEAD so the next pages are warm, and never fewer than
// EAGER_MIN so short chapters stay fully eager. Pages beyond the window are
// `loading="lazy"` and the browser fetches them as the reader scrolls near;
// unloaded lazy pages reserve height via CSS (`.page-image[data-reserve]`)
// so they stay distinct scroll targets for the progress IntersectionObserver.
const EAGER_LEAD = 3;
const EAGER_MIN = 5;
const eagerThrough = $derived(Math.max(initialIndex + EAGER_LEAD, EAGER_MIN));
let chapterBarEl: HTMLElement | undefined = $state(); let chapterBarEl: HTMLElement | undefined = $state();
let readerNavEl: HTMLElement | undefined = $state(); let readerNavEl: HTMLElement | undefined = $state();
@@ -1303,17 +1323,24 @@
{:else} {:else}
<div class="continuous" style:gap="{gapPx}px" data-testid="reader-continuous"> <div class="continuous" style:gap="{gapPx}px" data-testid="reader-continuous">
{#each pages as p, i (p.id)} {#each pages as p, i (p.id)}
<!-- Pages 0..=initialIndex (or at least 0..1) are eager <!-- Pages in the leading eager window (`i <= eagerThrough`) load
so their real heights are settled before the cold- up front — this settles every page's real height at/above the
load scroll-to-`?page=N` effect fires. Without this, cold-load scroll-to-`?page=N` target before that effect fires,
`scrollIntoView(target)` lands while prior pages are so `scrollIntoView(target)` can't land while prior pages are
still 0×0 placeholders and the target gets pushed still 0×0 placeholders. Pages beyond the window are lazy: the
past the viewport as they load. --> browser fetches them as the reader scrolls near, so a long
chapter doesn't fire every request / decode every image at
once. Unloaded lazy pages carry `data-reserve` so CSS gives
them a min-height — keeping them distinct scroll targets for
the progress observer; `onload` clears it once real height
lands. -->
<img <img
src={fileUrl(p.storage_key)} src={fileUrl(p.storage_key)}
alt={`${manga.title} chapter ${chapter.number} page ${i + 1}`} alt={`${manga.title} chapter ${chapter.number} page ${i + 1}`}
class="page-image" class="page-image"
loading={i <= Math.max(1, initialIndex) ? 'eager' : 'lazy'} loading={i <= eagerThrough ? 'eager' : 'lazy'}
data-reserve={i > eagerThrough ? '' : undefined}
onload={(e) => e.currentTarget.removeAttribute('data-reserve')}
oncontextmenu={(e) => onPageContextMenu(e, p.id)} oncontextmenu={(e) => onPageContextMenu(e, p.id)}
onpointerdown={(e) => onPagePointerDown(e, p.id)} onpointerdown={(e) => onPagePointerDown(e, p.id)}
onpointermove={onPagePointerMove} onpointermove={onPagePointerMove}
@@ -1914,6 +1941,16 @@
-webkit-user-select: none; -webkit-user-select: none;
} }
/* A lazy page that hasn't loaded yet has no intrinsic height, so without
a reservation the whole not-yet-loaded tail would collapse to 0 and
pile up at one scroll offset — which both looks broken and gives the
progress IntersectionObserver overlapping 0-height targets. Reserve a
typical page height (page width × ~1.4) so each stays a distinct target;
the `onload` handler drops `data-reserve` once the real height lands. */
.page-image[data-reserve] {
min-height: calc(var(--reader-page-width) * 1.4);
}
/* Continuous mode inherits the width-driven sizing above: every page is /* Continuous mode inherits the width-driven sizing above: every page is
the same width and takes its natural (uncapped) height, so there are the same width and takes its natural (uncapped) height, so there are
no scroll dead-zones inside a single page and widths stay consistent no scroll dead-zones inside a single page and widths stay consistent