Compare commits
14 Commits
51ea254dde
...
f879ce1866
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f879ce1866 | ||
|
|
bf425cf8e6 | ||
|
|
ff4ca964f5 | ||
|
|
61669aac3f | ||
|
|
795eb76f9a | ||
|
|
ebf0b8289b | ||
|
|
ce9a727c73 | ||
|
|
46134c8760 | ||
|
|
b7d8faadf7 | ||
|
|
b0500e8e48 | ||
|
|
379224bee4 | ||
|
|
1c955458d6 | ||
|
|
987d1ba235 | ||
|
|
c69ff502f7 |
19
.env.example
19
.env.example
@@ -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
2
backend/Cargo.lock
generated
@@ -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",
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|
||||||
|
|||||||
@@ -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(())
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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!(
|
||||||
|
|||||||
@@ -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())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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 },
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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());
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
257
backend/src/crawler/intercept.rs
Normal file
257
backend/src/crawler/intercept.rs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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));
|
||||||
|
|||||||
@@ -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");
|
||||||
|
|||||||
@@ -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)]
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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))
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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 →
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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 }) => {
|
||||||
|
|||||||
119
frontend/e2e/reader-preload-window.spec.ts
Normal file
119
frontend/e2e/reader-preload-window.spec.ts
Normal 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();
|
||||||
|
});
|
||||||
@@ -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": {
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user