Compare commits

...

7 Commits

Author SHA1 Message Date
MechaCat02
b5f7467c47 refactor: remove unused constant_time_eq and correct the token doc
All checks were successful
deploy / test-backend (push) Successful in 35m33s
deploy / test-frontend (push) Successful in 10m46s
deploy / build-and-push (push) Successful in 11m31s
deploy / deploy (push) Successful in 13s
constant_time_eq (and the `subtle` dependency) was never wired into any
comparison — the module doc wrongly claimed token comparison used it, but lookup
is an indexed DB equality on the 256-bit SHA-256 hash, so there's no in-process
secret compare to time-attack. Delete the dead fn, its test, and the unused
`subtle` dep; rewrite the doc. No behaviour change.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 20:22:39 +02:00
MechaCat02
9148e23da8 fix: reap expired sessions with a periodic background sweep
The sessions table only grew — find_active ignored expired rows but nothing
deleted them, so lapsed sessions accumulated indefinitely.

- repo::session::delete_expired: indexed DELETE (sessions_expires_idx) returning
  the reaped count.
- app::build spawns a detached hourly reaper (SESSION_GC_INTERVAL) that calls it,
  independent of crawler/analysis config.

Test: delete_expired_removes_only_lapsed_sessions.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 20:19:41 +02:00
MechaCat02
f39307232c fix: force download for untyped (octet-stream) served blobs
The /files handler served the application/octet-stream fallback with no
Content-Disposition, letting a browser render it inline — a stored-XSS vector if
the blob is crafted HTML/JS. Add Content-Disposition: attachment for that type
(belt to the existing nosniff); known image types stay inline.

Test (new tests/api_files.rs): octet-stream → attachment; png → inline.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 20:15:23 +02:00
MechaCat02
1b7b8a3038 fix: cap the multipart metadata part instead of buffering it unbounded
The `metadata` JSON part in the manga-create and chapter-upload handlers was
read with `Field::bytes()`, buffering the whole part before any check — a client
could send a multi-hundred-MB metadata blob (up to the 200 MiB request limit) as
one allocation. Image parts were already streamed with a cap; metadata was the
last unbounded read.

Cap it at a shared upload::MAX_METADATA_BYTES (64 KiB) via the read_capped
streaming loop (413 on overflow), and remove the now-unused read_field_bytes
helper so the unbounded path can't return.

Tests: manga + chapter oversized-metadata parts are rejected with 413.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 20:10:05 +02:00
MechaCat02
253d46c7e5 fix: return NotFound when a storage key resolves to a directory
`get` (fs::read → EISDIR) and `get_stream` (File::open succeeds on a Unix
directory, then streams garbage that fails mid-read) both mishandled a key
resolving to a directory. Mirror `size`'s existing guard: `get` maps
IsADirectory to NotFound; `get_stream` checks metadata.is_file() after open.

Test: get_on_directory_is_not_found.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 19:59:51 +02:00
MechaCat02
c570e0cc37 fix: escape LIKE wildcards in user-search ILIKE queries
A `%` or `_` in a search term silently acted as a LIKE wildcard rather than
matching literally (`50%` matched everything, `a_b` matched `axb`). Not
injection — terms are bound — but a search-correctness bug across every
user-facing ILIKE site.

- repo::escape_like: shared pub(crate) escaper (promoted from page_tag), unit-tested.
- Trigram-entangled sites (manga, tag, author) append a separate escaped param
  used only by the ILIKE branch; the trigram/similarity branches keep the raw term.
- Pattern-built sites (admin manga list, admin users, analysis coverage + history,
  crawler search incl. JSONB payload title) escape inside format!("%{}%", ..) and
  pair each ILIKE with ESCAPE '\'.

Tests: escape_like unit tests + integration tests on public manga search, author
autocomplete, and admin user search proving `_` matches literally.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 19:57:06 +02:00
MechaCat02
5784483a57 fix: add CSP and defense-in-depth security response headers
Document responses carried no CSP, X-Frame-Options, Referrer-Policy, or
Permissions-Policy — leaving clickjacking and zero script-injection defense
in depth (security audit T4).

- svelte.config.js: enable kit.csp hash mode. SvelteKit hashes its own inline
  hydration scripts; the inline theme <script> in app.html isn't part of
  %sveltekit.head%, so its sha256 is pinned by hand (csp-config.js) and added
  to script-src alongside object-src 'none', base-uri 'self', frame-ancestors
  'none'. Styles stay unconstrained (Svelte emits dynamic inline style= attrs).
- hooks.server.ts: applySecurityHeaders() sets X-Frame-Options: DENY,
  Referrer-Policy: strict-origin-when-cross-origin, X-Content-Type-Options:
  nosniff, and a Permissions-Policy locking down unused features, only when the
  response hasn't already set them.
- src/csp-theme-hash.test.ts: drift guard against editing the theme script
  without updating THEME_SCRIPT_HASH.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 19:27:27 +02:00
33 changed files with 623 additions and 85 deletions

3
backend/Cargo.lock generated
View File

@@ -1558,7 +1558,7 @@ checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4"
[[package]]
name = "mangalord"
version = "0.128.4"
version = "0.128.10"
dependencies = [
"anyhow",
"argon2",
@@ -1587,7 +1587,6 @@ dependencies = [
"serde_json",
"sha2",
"sqlx",
"subtle",
"sysinfo",
"tempfile",
"thiserror 1.0.69",

View File

@@ -1,6 +1,6 @@
[package]
name = "mangalord"
version = "0.128.4"
version = "0.128.10"
edition = "2021"
default-run = "mangalord"
@@ -35,7 +35,6 @@ dotenvy = "0.15"
argon2 = "0.5"
rand = "0.8"
sha2 = "0.10"
subtle = "2"
base64 = "0.22"
# Image decode + downscale for the analysis worker (keep the page image
# under the local vision model's token budget). Only the manga page formats.

View File

@@ -13,7 +13,7 @@ use serde::Deserialize;
use serde_json::json;
use uuid::Uuid;
use crate::api::mangas::{next_field, read_field_bytes};
use crate::api::mangas::next_field;
use crate::api::pagination::PagedResponse;
use crate::app::AppState;
use crate::auth::extractor::CurrentUser;
@@ -107,7 +107,12 @@ async fn create(
while let Some(field) = next_field(&mut multipart).await? {
match field.name() {
Some("metadata") => {
let bytes = read_field_bytes(field).await?;
let bytes = crate::upload::read_capped(
field,
crate::upload::MAX_METADATA_BYTES,
"metadata",
)
.await?;
metadata = Some(serde_json::from_slice(&bytes).map_err(|e| {
AppError::ValidationFailed {
message: "metadata is not valid JSON".into(),

View File

@@ -141,7 +141,7 @@ fn image_response(
content_length: String,
body: Body,
) -> Response {
let headers = [
let mut headers = vec![
(header::CONTENT_TYPE, content_type.to_string()),
(header::CONTENT_LENGTH, content_length),
// `nosniff` makes the contract explicit: the browser must trust the
@@ -169,7 +169,17 @@ fn image_response(
},
),
];
(headers, body).into_response()
// Known image types render inline (covers/pages display in the reader). The
// `application/octet-stream` fallback is a blob we couldn't type — it could
// be crafted HTML/JS, so force a download rather than let the browser render
// it inline. Belt to `nosniff`'s braces.
if content_type == "application/octet-stream" {
headers.push((
header::CONTENT_DISPOSITION,
"attachment".to_string(),
));
}
(axum::response::AppendHeaders(headers), body).into_response()
}
/// Parse and clamp a requested thumbnail width. Returns `None` for absent /

View File

@@ -235,7 +235,12 @@ async fn create(
while let Some(field) = next_field(&mut multipart).await? {
match field.name() {
Some("metadata") => {
let bytes = read_field_bytes(field).await?;
let bytes = crate::upload::read_capped(
field,
crate::upload::MAX_METADATA_BYTES,
"metadata",
)
.await?;
metadata = Some(parse_metadata_json(&bytes)?);
}
Some("cover") => {
@@ -677,12 +682,6 @@ pub(crate) async fn next_field(
.map_err(map_multipart_error)
}
pub(crate) async fn read_field_bytes(
field: axum::extract::multipart::Field<'_>,
) -> AppResult<axum::body::Bytes> {
field.bytes().await.map_err(map_multipart_error)
}
pub(crate) fn map_multipart_error(e: axum::extract::multipart::MultipartError) -> AppError {
let status = e.status();
if status == StatusCode::PAYLOAD_TOO_LARGE {

View File

@@ -277,6 +277,11 @@ impl DaemonReloader for Supervisors {
}
}
/// How often the background reaper sweeps expired sessions. Hourly is ample:
/// the sweep is a single indexed DELETE and expired rows are already invisible
/// to auth, so this is purely storage hygiene.
const SESSION_GC_INTERVAL: std::time::Duration = std::time::Duration::from_secs(3600);
pub async fn build(config: Config) -> anyhow::Result<AppHandle> {
let db = PgPoolOptions::new()
.max_connections(config.db.max_connections)
@@ -337,6 +342,27 @@ pub async fn build(config: Config) -> anyhow::Result<AppHandle> {
tracing::info!("analysis worker disabled");
}
// Periodic reaper for lapsed sessions. `find_active` already ignores
// expired rows, so this only reclaims storage — without it the table grows
// unbounded as sessions lapse. Detached and best-effort: a failed sweep is
// logged and retried next tick. Runs regardless of crawler/analysis config
// since sessions exist in every deployment.
{
let db = db.clone();
tokio::spawn(async move {
let mut ticker = tokio::time::interval(SESSION_GC_INTERVAL);
ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
loop {
ticker.tick().await;
match repo::session::delete_expired(&db).await {
Ok(0) => {}
Ok(n) => tracing::info!(reaped = n, "session gc: removed expired sessions"),
Err(e) => tracing::warn!(?e, "session gc sweep failed; retrying next tick"),
}
}
});
}
let auth_limiter = Arc::new(AuthRateLimiter::new(config.auth.rate_limit));
let state = AppState {
db,

View File

@@ -3,15 +3,16 @@
//! `generate_token` draws 32 bytes from the OS CSPRNG, encodes them as
//! URL-safe base64 (no padding), and returns the raw string alongside its
//! SHA-256 hash. Storage holds only the hash; the raw value lives in the
//! cookie or `Authorization` header. Comparison goes through
//! `constant_time_eq` to keep timing side channels off the table.
//! cookie or `Authorization` header. Token lookup is an indexed equality on
//! that 256-bit hash in the database (`WHERE token_hash = $1`), so there's no
//! in-process secret comparison to time-attack: a guess has to match a full
//! SHA-256 digest, and the DB index reveals nothing about how close it came.
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
use base64::Engine as _;
use rand::rngs::OsRng;
use rand::RngCore;
use sha2::{Digest, Sha256};
use subtle::ConstantTimeEq;
pub const TOKEN_BYTES: usize = 32;
pub const HASH_BYTES: usize = 32;
@@ -30,10 +31,6 @@ pub fn hash_token(raw: &str) -> [u8; HASH_BYTES] {
hasher.finalize().into()
}
pub fn constant_time_eq(a: &[u8], b: &[u8]) -> bool {
a.ct_eq(b).into()
}
#[cfg(test)]
mod tests {
use super::*;
@@ -58,11 +55,4 @@ mod tests {
assert_eq!(hash_token("abc"), hash_token("abc"));
assert_ne!(hash_token("abc"), hash_token("abd"));
}
#[test]
fn constant_time_eq_compares_correctly() {
assert!(constant_time_eq(b"abc", b"abc"));
assert!(!constant_time_eq(b"abc", b"abd"));
assert!(!constant_time_eq(b"abc", b"abcd"));
}
}

View File

@@ -214,7 +214,7 @@ pub async fn list_mangas_with_sync_state(
let search_pat = q
.search
.as_ref()
.map(|s| format!("%{}%", s.trim()))
.map(|s| format!("%{}%", crate::repo::escape_like(s.trim())))
.filter(|p| p.len() > 2);
// sqlx::Type → text: bind the snake_case representation manually so
// the SQL can compare it as text without an explicit cast.
@@ -235,7 +235,7 @@ pub async fn list_mangas_with_sync_state(
(SELECT MAX(last_seen_at) FROM manga_sources ms
WHERE ms.manga_id = m.id AND ms.dropped_at IS NULL) AS latest_seen_at
FROM mangas m
WHERE ($1::text IS NULL OR m.title ILIKE $1)
WHERE ($1::text IS NULL OR m.title ILIKE $1 ESCAPE '\')
)
SELECT * FROM classified
WHERE ($2::text IS NULL OR sync_state = $2)
@@ -261,7 +261,7 @@ pub async fn list_mangas_with_sync_state(
WITH classified AS (
SELECT {case} AS sync_state
FROM mangas m
WHERE ($1::text IS NULL OR m.title ILIKE $1)
WHERE ($1::text IS NULL OR m.title ILIKE $1 ESCAPE '\')
)
SELECT COUNT(*) FROM classified
WHERE ($2::text IS NULL OR sync_state = $2)

View File

@@ -81,7 +81,7 @@ pub async fn list(
SELECT id, name, created_at
FROM authors
WHERE $1::text IS NULL
OR name ILIKE '%' || $1 || '%'
OR name ILIKE '%' || $4 || '%' ESCAPE '\'
OR name % $1
ORDER BY CASE WHEN $1::text IS NULL THEN 0 ELSE similarity(name, $1) END DESC,
lower(name) ASC
@@ -91,6 +91,9 @@ pub async fn list(
.bind(search)
.bind(limit)
.bind(offset)
// $4: LIKE-escaped term for the ILIKE branch so `%`/`_` match literally; the
// trigram/similarity branches keep the raw $1.
.bind(search.map(crate::repo::escape_like))
.fetch_all(pool)
.await?;
Ok(rows)

View File

@@ -720,7 +720,7 @@ pub async fn list_dead_jobs(
offset: i64,
) -> sqlx::Result<(Vec<DeadJob>, i64)> {
let search_pat = search
.map(|s| format!("%{}%", s.trim()))
.map(|s| format!("%{}%", crate::repo::escape_like(s.trim())))
.filter(|p| p.len() > 2);
let items: Vec<DeadJob> = sqlx::query_as(
@@ -743,7 +743,7 @@ pub async fn list_dead_jobs(
LEFT JOIN chapters c ON c.id = (cj.payload->>'chapter_id')::uuid
LEFT JOIN mangas m ON m.id = c.manga_id
WHERE cj.state = 'dead'
AND ($1::text IS NULL OR m.title ILIKE $1 OR cj.payload->>'title' ILIKE $1)
AND ($1::text IS NULL OR m.title ILIKE $1 ESCAPE '\' OR cj.payload->>'title' ILIKE $1 ESCAPE '\')
ORDER BY cj.updated_at DESC
LIMIT $2 OFFSET $3
"#,
@@ -761,7 +761,7 @@ pub async fn list_dead_jobs(
LEFT JOIN chapters c ON c.id = (cj.payload->>'chapter_id')::uuid
LEFT JOIN mangas m ON m.id = c.manga_id
WHERE cj.state = 'dead'
AND ($1::text IS NULL OR m.title ILIKE $1 OR cj.payload->>'title' ILIKE $1)
AND ($1::text IS NULL OR m.title ILIKE $1 ESCAPE '\' OR cj.payload->>'title' ILIKE $1 ESCAPE '\')
"#,
)
.bind(&search_pat)
@@ -797,7 +797,7 @@ pub async fn list_active_jobs(
offset: i64,
) -> sqlx::Result<(Vec<ActiveJob>, i64)> {
let search_pat = search
.map(|s| format!("%{}%", s.trim()))
.map(|s| format!("%{}%", crate::repo::escape_like(s.trim())))
.filter(|p| p.len() > 2);
let items: Vec<ActiveJob> = sqlx::query_as(
@@ -817,7 +817,7 @@ pub async fn list_active_jobs(
LEFT JOIN mangas m ON m.id = c.manga_id
WHERE cj.state IN ('pending','running')
AND cj.payload->>'kind' = 'sync_chapter_content'
AND ($1::text IS NULL OR m.title ILIKE $1)
AND ($1::text IS NULL OR m.title ILIKE $1 ESCAPE '\')
ORDER BY (cj.state = 'running') DESC, cj.scheduled_at, cj.created_at
LIMIT $2 OFFSET $3
"#,
@@ -836,7 +836,7 @@ pub async fn list_active_jobs(
LEFT JOIN mangas m ON m.id = c.manga_id
WHERE cj.state IN ('pending','running')
AND cj.payload->>'kind' = 'sync_chapter_content'
AND ($1::text IS NULL OR m.title ILIKE $1)
AND ($1::text IS NULL OR m.title ILIKE $1 ESCAPE '\')
"#,
)
.bind(&search_pat)
@@ -913,7 +913,7 @@ pub async fn list_job_history(
) -> sqlx::Result<(Vec<JobHistoryRow>, i64)> {
let search_pat = filter
.search
.map(|s| format!("%{}%", s.trim()))
.map(|s| format!("%{}%", crate::repo::escape_like(s.trim())))
.filter(|p| p.len() > 2);
// The same FROM/JOIN/WHERE drives both the page slice and the count, so
@@ -951,7 +951,7 @@ pub async fn list_job_history(
) cm ON true
WHERE ($1::text IS NULL OR cj.state = $1)
AND ($2::text IS NULL OR cj.payload->>'kind' = $2)
AND ($3::text IS NULL OR m.title ILIKE $3 OR cj.payload->>'title' ILIKE $3)
AND ($3::text IS NULL OR m.title ILIKE $3 ESCAPE '\' OR cj.payload->>'title' ILIKE $3 ESCAPE '\')
ORDER BY cj.updated_at DESC
LIMIT $4 OFFSET $5
"#,
@@ -973,7 +973,7 @@ pub async fn list_job_history(
LEFT JOIN mangas m ON m.id = COALESCE(c.manga_id, (cj.payload->>'manga_id')::uuid)
WHERE ($1::text IS NULL OR cj.state = $1)
AND ($2::text IS NULL OR cj.payload->>'kind' = $2)
AND ($3::text IS NULL OR m.title ILIKE $3 OR cj.payload->>'title' ILIKE $3)
AND ($3::text IS NULL OR m.title ILIKE $3 ESCAPE '\' OR cj.payload->>'title' ILIKE $3 ESCAPE '\')
"#,
)
.bind(filter.state)
@@ -1018,7 +1018,7 @@ pub async fn list_missing_cover_mangas(
offset: i64,
) -> sqlx::Result<(Vec<MissingCoverRow>, i64)> {
let search_pat = search
.map(|s| format!("%{}%", s.trim()))
.map(|s| format!("%{}%", crate::repo::escape_like(s.trim())))
.filter(|p| p.len() > 2);
let items: Vec<MissingCoverRow> = sqlx::query_as(
@@ -1030,7 +1030,7 @@ pub async fn list_missing_cover_mangas(
SELECT 1 FROM manga_sources ms
WHERE ms.manga_id = m.id AND ms.dropped_at IS NULL
)
AND ($1::text IS NULL OR m.title ILIKE $1)
AND ($1::text IS NULL OR m.title ILIKE $1 ESCAPE '\')
ORDER BY m.updated_at DESC
LIMIT $2 OFFSET $3
"#,
@@ -1049,7 +1049,7 @@ pub async fn list_missing_cover_mangas(
SELECT 1 FROM manga_sources ms
WHERE ms.manga_id = m.id AND ms.dropped_at IS NULL
)
AND ($1::text IS NULL OR m.title ILIKE $1)
AND ($1::text IS NULL OR m.title ILIKE $1 ESCAPE '\')
"#,
)
.bind(&search_pat)

View File

@@ -10,7 +10,7 @@ use uuid::Uuid;
use crate::domain::manga::{Manga, MangaCard, MangaDetail};
use crate::error::{AppError, AppResult};
use crate::repo;
use crate::repo::{self, escape_like};
/// Status values mirror the CHECK constraint in 0009. Centralized so
/// the API layer can validate uploads against the same vocabulary.
@@ -110,13 +110,13 @@ fn manga_cols(alias: &str) -> String {
/// true.
const FILTER_WHERE: &str = r#"
($1::text IS NULL
OR title ILIKE '%' || $1 || '%'
OR title ILIKE '%' || $8 || '%' ESCAPE '\'
OR title % $1
OR EXISTS (
SELECT 1 FROM manga_authors ma
JOIN authors a ON a.id = ma.author_id
WHERE ma.manga_id = mangas.id
AND (a.name ILIKE '%' || $1 || '%' OR a.name % $1)
AND (a.name ILIKE '%' || $8 || '%' ESCAPE '\' OR a.name % $1)
)
)
AND ($2::text IS NULL OR status = $2)
@@ -196,11 +196,15 @@ pub async fn list(pool: &PgPool, query: &ListQuery) -> AppResult<(Vec<Manga>, Op
FROM mangas
WHERE {FILTER_WHERE}
ORDER BY {order_by}
LIMIT $8 OFFSET $9
LIMIT $9 OFFSET $10
"#,
cols = manga_cols(""),
);
// $8 is the LIKE-escaped search term used by the ILIKE branches so `%`/`_`
// in the term match literally; the trigram `%` branches keep the raw $1.
let search_escaped = search.map(escape_like);
let rows = sqlx::query_as::<_, Manga>(&list_sql)
.bind(search)
.bind(status)
@@ -209,6 +213,7 @@ pub async fn list(pool: &PgPool, query: &ListQuery) -> AppResult<(Vec<Manga>, Op
.bind(&query.tag_ids)
.bind(&query.cw_include)
.bind(&query.cw_exclude)
.bind(&search_escaped)
.bind(query.limit)
.bind(query.offset)
.fetch_all(pool)
@@ -235,6 +240,7 @@ pub async fn list(pool: &PgPool, query: &ListQuery) -> AppResult<(Vec<Manga>, Op
.bind(&query.tag_ids)
.bind(&query.cw_include)
.bind(&query.cw_exclude)
.bind(&search_escaped)
.fetch_one(pool)
.await?;
Some(total)

View File

@@ -21,3 +21,45 @@ pub mod tag;
pub mod upload_history;
pub mod user;
pub mod user_preferences;
/// Escape the LIKE/ILIKE metacharacters (`%`, `_`, and the escape char `\`
/// itself) in a user-supplied search term so they match literally rather than
/// as wildcards. Pair the resulting value with `ESCAPE '\'` in the SQL — a
/// single backslash, which under `standard_conforming_strings` (Postgres
/// default) is one backslash in a single-quoted literal.
///
/// This is a search-correctness fix, not an injection fix: every term is
/// already a bound parameter, so `%`/`_` can never break out of the string —
/// they were just silently acting as wildcards (`50%` matching everything,
/// `a_b` matching `axb`). Callers that build a substring pattern wrap the
/// escaped term themselves, e.g. `format!("%{}%", escape_like(term))`.
pub(crate) fn escape_like(s: &str) -> String {
let mut out = String::with_capacity(s.len());
for ch in s.chars() {
if matches!(ch, '\\' | '%' | '_') {
out.push('\\');
}
out.push(ch);
}
out
}
#[cfg(test)]
mod tests {
use super::escape_like;
#[test]
fn escapes_wildcards_and_the_escape_char() {
assert_eq!(escape_like("50%"), r"50\%");
assert_eq!(escape_like("a_b"), r"a\_b");
assert_eq!(escape_like(r"back\slash"), r"back\\slash");
// A already-escaped-looking input is double-escaped so it stays literal.
assert_eq!(escape_like(r"\%"), r"\\\%");
}
#[test]
fn leaves_ordinary_text_untouched() {
assert_eq!(escape_like("naruto"), "naruto");
assert_eq!(escape_like(""), "");
}
}

View File

@@ -241,6 +241,10 @@ pub async fn manga_coverage(
limit: i64,
offset: i64,
) -> AppResult<(Vec<MangaCoverage>, i64)> {
// LIKE-escape so `%`/`_` in the title filter match literally. No trigram
// branch here, so binding the escaped term directly (rather than appending a
// second param) is safe — $1 feeds only the ILIKE.
let search = search.map(crate::repo::escape_like);
let rows = sqlx::query_as::<_, MangaCoverage>(
r#"
SELECT m.id AS manga_id, m.title,
@@ -250,13 +254,13 @@ pub async fn manga_coverage(
JOIN chapters c ON c.manga_id = m.id
JOIN pages p ON p.chapter_id = c.id
LEFT JOIN page_analysis pa ON pa.page_id = p.id
WHERE ($1::text IS NULL OR m.title ILIKE '%' || $1 || '%')
WHERE ($1::text IS NULL OR m.title ILIKE '%' || $1 || '%' ESCAPE '\')
GROUP BY m.id, m.title
ORDER BY lower(m.title), m.id
LIMIT $2 OFFSET $3
"#,
)
.bind(search)
.bind(&search)
.bind(limit)
.bind(offset)
.fetch_all(pool)
@@ -269,12 +273,12 @@ pub async fn manga_coverage(
FROM mangas m
JOIN chapters c ON c.manga_id = m.id
JOIN pages p ON p.chapter_id = c.id
WHERE ($1::text IS NULL OR m.title ILIKE '%' || $1 || '%')
WHERE ($1::text IS NULL OR m.title ILIKE '%' || $1 || '%' ESCAPE '\')
GROUP BY m.id
) x
"#,
)
.bind(search)
.bind(&search)
.fetch_one(pool)
.await?;
Ok((rows, total))
@@ -377,7 +381,7 @@ pub async fn list_history(
) -> AppResult<(Vec<AnalysisHistoryRow>, i64)> {
let search_pat = filter
.search
.map(|s| format!("%{}%", s.trim()))
.map(|s| format!("%{}%", crate::repo::escape_like(s.trim())))
.filter(|p| p.len() > 2);
let items = sqlx::query_as::<_, AnalysisHistoryRow>(
@@ -402,7 +406,7 @@ pub async fn list_history(
WHERE pa.status <> 'pending'
AND ($1::text IS NULL OR pa.status = $1)
AND ($2::bool IS FALSE OR pa.is_nsfw)
AND ($3::text IS NULL OR m.title ILIKE $3)
AND ($3::text IS NULL OR m.title ILIKE $3 ESCAPE '\')
ORDER BY pa.analyzed_at DESC NULLS LAST, pa.page_id
LIMIT $4 OFFSET $5
"#,
@@ -425,7 +429,7 @@ pub async fn list_history(
WHERE pa.status <> 'pending'
AND ($1::text IS NULL OR pa.status = $1)
AND ($2::bool IS FALSE OR pa.is_nsfw)
AND ($3::text IS NULL OR m.title ILIKE $3)
AND ($3::text IS NULL OR m.title ILIKE $3 ESCAPE '\')
"#,
)
.bind(filter.status)

View File

@@ -120,27 +120,11 @@ pub async fn list_for_page(
Ok(rows.into_iter().map(|(t,)| t).collect())
}
/// Escape a string for use as a LIKE pattern fragment: `%`, `_`, and
/// `\` get a leading backslash so they're matched literally rather
/// than as wildcards / escapes. The matching queries below pair this
/// with `ESCAPE '\'` for explicitness — a single backslash, since the
/// SQL lives in a raw string and Postgres treats `\\` in a single-
/// quoted literal as one backslash under `standard_conforming_strings`.
///
/// The public API rejects `%`/`_`/`\` in `normalize_tag` before
/// they reach this repo, so this is defence-in-depth — a future
/// internal caller (worker, CLI) that bypasses the normalizer can't
/// turn a prefix filter into a wildcard search by accident.
fn escape_like_prefix(s: &str) -> String {
let mut out = String::with_capacity(s.len());
for ch in s.chars() {
if matches!(ch, '\\' | '%' | '_') {
out.push('\\');
}
out.push(ch);
}
out
}
// LIKE-escaping the autocomplete prefix is defence-in-depth: the public API
// already rejects `%`/`_`/`\` in `normalize_tag` before they reach this repo, so
// a stray wildcard can only arrive from a future internal caller (worker, CLI)
// that bypasses the normalizer. Shared with the other search sites.
use crate::repo::escape_like as escape_like_prefix;
/// Paged list of `user_id`'s tagged pages, with breadcrumb. When
/// `tag_filter` is `Some(_)`, restrict to that exact tag (used by the

View File

@@ -65,3 +65,15 @@ pub async fn delete_by_token_hash(pool: &PgPool, token_hash: &[u8]) -> AppResult
.await?;
Ok(())
}
/// Delete every session whose `expires_at` has passed, returning the number
/// reaped. `find_active` already refuses expired sessions, so this only
/// reclaims storage — running it on any schedule (or not at all) is safe.
/// Backed by `sessions_expires_idx` (0002). Called by the periodic reaper in
/// `app::build`.
pub async fn delete_expired(pool: &PgPool) -> AppResult<u64> {
let result = sqlx::query("DELETE FROM sessions WHERE expires_at <= now()")
.execute(pool)
.await?;
Ok(result.rows_affected())
}

View File

@@ -124,7 +124,7 @@ pub async fn list(
SELECT id, name, created_at
FROM tags
WHERE $1::text IS NULL
OR name ILIKE '%' || $1 || '%'
OR name ILIKE '%' || $3 || '%' ESCAPE '\'
OR name % $1
ORDER BY CASE WHEN $1::text IS NULL THEN 0 ELSE similarity(name, $1) END DESC,
lower(name) ASC
@@ -133,6 +133,9 @@ pub async fn list(
)
.bind(search)
.bind(limit)
// $3: LIKE-escaped term for the ILIKE branch so `%`/`_` match literally; the
// trigram/similarity branches keep the raw $1.
.bind(search.map(crate::repo::escape_like))
.fetch_all(pool)
.await?;
Ok(rows)

View File

@@ -106,14 +106,14 @@ pub async fn list_with_total(
let pat = q
.search
.as_ref()
.map(|s| format!("%{}%", s.trim()))
.map(|s| format!("%{}%", crate::repo::escape_like(s.trim())))
.filter(|p| p.len() > 2);
let items = sqlx::query_as::<_, User>(
r#"
SELECT id, username, password_hash, created_at, is_admin
FROM users
WHERE ($1::text IS NULL OR username ILIKE $1)
WHERE ($1::text IS NULL OR username ILIKE $1 ESCAPE '\')
ORDER BY username
LIMIT $2 OFFSET $3
"#,
@@ -125,7 +125,7 @@ pub async fn list_with_total(
.await?;
let total: i64 = sqlx::query_scalar(
"SELECT COUNT(*) FROM users WHERE ($1::text IS NULL OR username ILIKE $1)",
"SELECT COUNT(*) FROM users WHERE ($1::text IS NULL OR username ILIKE $1 ESCAPE '\\')",
)
.bind(&pat)
.fetch_one(pool)

View File

@@ -95,6 +95,11 @@ impl Storage for LocalStorage {
match fs::read(&path).await {
Ok(b) => Ok(b),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Err(StorageError::NotFound),
// A key resolving to a directory isn't a stored blob; `fs::read`
// fails with EISDIR. Treat it as absent, mirroring `size`.
Err(e) if e.kind() == std::io::ErrorKind::IsADirectory => {
Err(StorageError::NotFound)
}
Err(e) => Err(e.into()),
}
}
@@ -108,7 +113,14 @@ impl Storage for LocalStorage {
}
Err(e) => return Err(e.into()),
};
let size_bytes = file.metadata().await?.len();
let meta = file.metadata().await?;
// Opening a directory succeeds on Unix, but it isn't a stored blob:
// its inode "size" is meaningless and ReaderStream would fail mid-read.
// Treat it as absent, mirroring `size` and `get`.
if !meta.is_file() {
return Err(StorageError::NotFound);
}
let size_bytes = meta.len();
// 64 KiB chunks: small enough that a few-MB page emits many frames
// (so streaming is observable), large enough to keep syscalls cheap.
let stream = ReaderStream::with_capacity(file, 64 * 1024);
@@ -244,6 +256,21 @@ mod tests {
assert!(matches!(s.size("adir").await, Err(StorageError::NotFound)));
}
#[tokio::test]
async fn get_on_directory_is_not_found() {
// A key resolving to a directory isn't a stored blob. `fs::read` on a
// dir errors with EISDIR (not NotFound), and `File::open` on a dir
// succeeds on Unix then streams garbage — both must surface NotFound.
let dir = tempdir().unwrap();
let s = LocalStorage::new(dir.path());
std::fs::create_dir(dir.path().join("adir")).unwrap();
assert!(matches!(s.get("adir").await, Err(StorageError::NotFound)));
assert!(matches!(
s.get_stream("adir").await.err(),
Some(StorageError::NotFound)
));
}
#[tokio::test]
async fn put_stream_writes_full_body_and_removes_temp_on_error() {
use bytes::Bytes;

View File

@@ -37,6 +37,15 @@ pub struct StagedImage {
/// staged page out of this prefix.
pub const STAGING_PREFIX: &str = "staging";
/// Upper bound on a multipart `metadata` JSON part, enforced as bytes arrive
/// (via [`read_capped`]). Manga/chapter metadata is title + a few short lists +
/// a description — kilobytes at most. Without this, `metadata` was the one
/// remaining part read with the unbounded `Field::bytes()`, letting a client
/// buffer up to the whole 200 MiB request body in memory as a single JSON blob
/// before any validation ran. 64 KiB is generous headroom over any legitimate
/// payload while keeping the worst case tiny.
pub const MAX_METADATA_BYTES: usize = 64 * 1024;
/// Read one multipart image part and write it straight to a staging key,
/// returning only its metadata. The per-file byte cap is enforced as bytes
/// arrive, so an oversized part is rejected (413) without being fully

View File

@@ -147,6 +147,46 @@ async fn list_filters_by_substring_search(pool: PgPool) {
assert_eq!(body["page"]["total"], 1);
}
#[sqlx::test(migrations = "./migrations")]
async fn search_treats_like_wildcards_literally(pool: PgPool) {
let h = common::harness(pool.clone());
let (_admin_name, cookie, _) = seed_admin(&pool, &h.app).await;
// Two usernames differing only at one position (underscores are legal in
// usernames). `_` is a LIKE single-char wildcard: unescaped, `%a_b%` matches
// BOTH; escaped, only the literal "a_b" username. Admin user search has no
// trigram OR, so length/similarity don't matter here.
for username in ["axbfindme", "a_bfindme"] {
let resp = h
.app
.clone()
.oneshot(common::post_json(
"/api/v1/auth/register",
json!({ "username": username, "password": "hunter2hunter2" }),
))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::CREATED);
}
let resp = h
.app
.oneshot(common::get_with_cookie(
"/api/v1/admin/users?search=a_b",
&cookie,
))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let body = common::body_json(resp).await;
let items = body["items"].as_array().unwrap();
assert_eq!(
items.len(),
1,
"the `_` must match literally: only a_bfindme, not axbfindme"
);
assert_eq!(items[0]["username"], "a_bfindme");
}
// ---- self-protection -------------------------------------------------------
#[sqlx::test(migrations = "./migrations")]

View File

@@ -30,6 +30,47 @@ fn first_author_id(manga: &Value) -> String {
manga["authors"][0]["id"].as_str().unwrap().to_string()
}
#[sqlx::test(migrations = "./migrations")]
async fn search_treats_like_wildcards_literally(pool: PgPool) {
let h = common::harness(pool);
let (_, cookie) = common::register_user(&h.app).await;
// Long author names differing only at one position, short search term — so
// the trigram OR (which keeps the raw term by design) stays under threshold
// and the ILIKE branch is what decides. Unescaped `%a_b%` matches both the
// "axb" and "a_b" names; escaped, only the literal "a_b" name.
create_manga(
&h.app,
&cookie,
json!({ "title": "M1", "authors": ["The Quick Brown Fox axb Jumps Over"] }),
)
.await;
create_manga(
&h.app,
&cookie,
json!({ "title": "M2", "authors": ["The Quick Brown Fox a_b Jumps Over"] }),
)
.await;
let resp = h
.app
.oneshot(common::get("/api/v1/authors?search=a_b"))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let body = common::body_json(resp).await;
let names: Vec<&str> = body
.as_array()
.unwrap()
.iter()
.map(|a| a["name"].as_str().unwrap())
.collect();
assert_eq!(
names,
vec!["The Quick Brown Fox a_b Jumps Over"],
"the `_` in the search term must match literally, not as a wildcard"
);
}
#[sqlx::test(migrations = "./migrations")]
async fn get_returns_name_and_manga_count(pool: PgPool) {
let h = common::harness(pool);

View File

@@ -358,3 +358,27 @@ async fn non_owner_can_upload_chapter(pool: PgPool) {
assert_eq!(body["title"], "Contributed");
assert_eq!(body["page_count"], 1);
}
#[sqlx::test(migrations = "./migrations")]
async fn chapter_upload_rejects_oversized_metadata_part(pool: PgPool) {
// The chapter `metadata` JSON part is capped as bytes arrive, just like the
// manga one, so a client can't buffer a huge JSON blob in memory. A ~200 KiB
// title blows the metadata cap before any page is staged.
let h = common::harness(pool);
let (_, cookie) = common::register_user(&h.app).await;
let manga_id = seed_manga(&h, &cookie, "Berserk").await;
let huge = "A".repeat(200 * 1024);
let resp = h
.app
.oneshot(common::post_multipart_with_cookie(
&format!("/api/v1/mangas/{manga_id}/chapters"),
MultipartBuilder::new()
.add_json("metadata", json!({ "number": 1, "title": huge }))
.add_file("page", "1.png", "image/png", &common::fake_png_bytes()),
&cookie,
))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::PAYLOAD_TOO_LARGE);
}

View File

@@ -0,0 +1,57 @@
mod common;
use axum::http::StatusCode;
use tower::ServiceExt;
/// Write a blob straight into the harness storage root at `key`, bypassing the
/// upload handlers — the only way to land a key whose extension resolves to the
/// `application/octet-stream` fallback (uploads always mint image extensions).
fn write_blob(h: &common::Harness, key: &str, bytes: &[u8]) {
let path = h._storage_dir.path().join(key);
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
std::fs::write(path, bytes).unwrap();
}
#[sqlx::test(migrations = "./migrations")]
async fn octet_stream_blobs_are_served_as_attachment(pool: sqlx::PgPool) {
// A blob with an unknown extension serves as application/octet-stream. Such a
// body could be crafted HTML/JS, so it must never render inline: force a
// download with Content-Disposition: attachment (nosniff is already set).
let h = common::harness(pool);
write_blob(&h, "misc/blob.bin", b"\x00\x01not-an-image");
let resp = h
.app
.oneshot(common::get("/api/v1/files/misc/blob.bin"))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
assert_eq!(
resp.headers().get("content-type").unwrap(),
"application/octet-stream"
);
assert_eq!(
resp.headers().get("content-disposition").unwrap(),
"attachment"
);
}
#[sqlx::test(migrations = "./migrations")]
async fn image_blobs_are_served_inline(pool: sqlx::PgPool) {
// Regression guard: known image types keep rendering inline (no attachment
// disposition), so covers/pages still display in the reader.
let h = common::harness(pool);
write_blob(&h, "misc/pic.png", &common::fake_png_bytes());
let resp = h
.app
.oneshot(common::get("/api/v1/files/misc/pic.png"))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
assert_eq!(resp.headers().get("content-type").unwrap(), "image/png");
assert!(
resp.headers().get("content-disposition").is_none(),
"images must render inline, not download"
);
}

View File

@@ -135,6 +135,30 @@ async fn list_total_is_computed_only_on_the_first_page(pool: PgPool) {
assert_eq!(body1["items"].as_array().unwrap().len(), 1);
}
#[sqlx::test(migrations = "./migrations")]
async fn search_treats_like_wildcards_literally(pool: PgPool) {
let h = common::harness(pool);
let (_, cookie) = common::register_user(&h.app).await;
// Two long titles differing only at one position. `_` is a LIKE single-char
// wildcard: unescaped, `%a_b%` matches BOTH ("axb" and "a_b"); escaped, only
// the literal "a_b" title matches. The titles are long and the term short so
// trigram similarity stays under threshold — the ILIKE branch decides.
seed(&h.app, &cookie, "The Quick Brown Fox Jumps axb Over The Lazy Dog").await;
seed(&h.app, &cookie, "The Quick Brown Fox Jumps a_b Over The Lazy Dog").await;
let resp = h
.app
.oneshot(common::get("/api/v1/mangas?search=a_b"))
.await
.unwrap();
let body = common::body_json(resp).await;
assert_eq!(
title_list(&body),
vec!["The Quick Brown Fox Jumps a_b Over The Lazy Dog"],
"the `_` in the search term must match literally, not as a wildcard"
);
}
#[sqlx::test(migrations = "./migrations")]
async fn search_via_trigram_tolerates_typos(pool: PgPool) {
let h = common::harness(pool);

View File

@@ -927,3 +927,25 @@ async fn bearer_authed_admin_cannot_edit_null_uploader(pool: PgPool) {
);
}
#[sqlx::test(migrations = "./migrations")]
async fn create_rejects_oversized_metadata_part(pool: PgPool) {
// The metadata JSON part is capped well below the 200 MiB request limit so a
// client can't force the server to buffer a huge JSON blob in memory before
// any validation runs. A ~200 KiB description blows the metadata cap.
let h = common::harness(pool);
let (_, cookie) = common::register_user(&h.app).await;
let huge = "A".repeat(200 * 1024);
let resp = h
.app
.oneshot(common::post_multipart_with_cookie(
"/api/v1/mangas",
MultipartBuilder::new()
.add_json("metadata", json!({ "title": "Big", "description": huge })),
&cookie,
))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::PAYLOAD_TOO_LARGE);
}

View File

@@ -0,0 +1,52 @@
mod common;
use chrono::{Duration, Utc};
use sqlx::PgPool;
use mangalord::repo;
#[sqlx::test(migrations = "./migrations")]
async fn delete_expired_removes_only_lapsed_sessions(pool: PgPool) {
let user = repo::user::create(&pool, "reaper-subject", "x").await.unwrap();
// One session already lapsed, one still valid.
let expired = repo::session::create(
&pool,
user.id,
b"expired-token-hash-00000000000000",
Utc::now() - Duration::hours(1),
)
.await
.unwrap();
let active = repo::session::create(
&pool,
user.id,
b"active-token-hash-000000000000000",
Utc::now() + Duration::hours(1),
)
.await
.unwrap();
let removed = repo::session::delete_expired(&pool).await.unwrap();
assert_eq!(removed, 1, "exactly the one lapsed session is reaped");
// The active session is untouched and still resolvable; the expired one is
// gone from the table entirely (find_active already ignored it, but now the
// row is reclaimed too).
assert!(
repo::session::find_active(&pool, b"active-token-hash-000000000000000")
.await
.unwrap()
.is_some(),
"active session survives the sweep"
);
let remaining: i64 = sqlx::query_scalar("SELECT count(*) FROM sessions")
.fetch_one(&pool)
.await
.unwrap();
assert_eq!(remaining, 1);
// Sweeping again is a no-op now that nothing is expired.
assert_eq!(repo::session::delete_expired(&pool).await.unwrap(), 0);
let _ = (expired, active);
}

10
frontend/csp-config.js Normal file
View File

@@ -0,0 +1,10 @@
// Shared CSP constants, kept dependency-free so both svelte.config.js (loaded by
// Node when Vite starts) and the vitest drift-guard test (jsdom env) can import
// it without pulling in adapter-node / esbuild.
// sha256 of the inline theme <script> in src/app.html, base64-encoded, wrapped
// for the CSP `script-src` allowlist. SvelteKit's kit.csp hash mode does not
// cover app.html template scripts, so this is pinned by hand. src/csp-theme-hash.test.ts
// recomputes it from app.html and fails if it drifts.
export const THEME_SCRIPT_HASH =
"'sha256-qj6Oim9siqow/Su+v47sZHJeJci+M/RQwGXn53eSULQ='";

View File

@@ -0,0 +1,37 @@
import { test, expect } from './fixtures';
// Defense-in-depth response headers on document navigations (T4 in the security
// audit). The CSP is emitted by SvelteKit's kit.csp config; the clickjacking /
// referrer / permissions headers by hooks.server.ts. We assert on the raw
// response of a document navigation, and separately confirm the inline theme
// script still executes under the CSP (its sha256 is allowlisted) by checking
// the data-theme attribute it sets — a CSP block would leave it unset.
test('document responses carry the security headers', async ({ page }) => {
const response = await page.goto('/');
expect(response, 'navigation returned a response').not.toBeNull();
const headers = response!.headers();
// Clickjacking: both the legacy header and the CSP directive.
expect(headers['x-frame-options']).toBe('DENY');
expect(headers['content-security-policy']).toContain("frame-ancestors 'none'");
// Script-injection surface reduction.
expect(headers['content-security-policy']).toContain("object-src 'none'");
expect(headers['content-security-policy']).toContain('script-src');
// The non-CSP hardening headers.
expect(headers['referrer-policy']).toBe('strict-origin-when-cross-origin');
expect(headers['x-content-type-options']).toBe('nosniff');
expect(headers['permissions-policy']).toContain('geolocation=()');
});
test('the inline theme script executes under the CSP (hash is allowlisted)', async ({
page
}) => {
// If the theme script were CSP-blocked, data-theme would never be set.
// Its presence proves the allowlisted sha256 matches the served script.
await page.goto('/');
const theme = await page.evaluate(() =>
document.documentElement.getAttribute('data-theme')
);
expect(theme === 'light' || theme === 'dark').toBe(true);
});

View File

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

View File

@@ -0,0 +1,25 @@
import { describe, it, expect } from 'vitest';
import { readFileSync } from 'node:fs';
import { createHash } from 'node:crypto';
import { join } from 'node:path';
import { THEME_SCRIPT_HASH } from '../csp-config.js';
// The theme-flash-prevention <script> in app.html runs inline, so the CSP
// script-src must allow it by hash. SvelteKit's kit.csp hash mode does NOT
// cover app.html template scripts (only what it injects into %sveltekit.head%),
// so we pin the hash by hand in svelte.config.js. This test recomputes the hash
// from the actual app.html bytes and fails if THEME_SCRIPT_HASH drifted — i.e.
// someone edited the theme script without updating the CSP, which would silently
// CSP-block it (theme flash returns, only a console error to show for it).
describe('CSP theme-script hash', () => {
it('matches the current inline theme script in app.html', () => {
// vitest runs with cwd at the frontend package root.
const html = readFileSync(join(process.cwd(), 'src/app.html'), 'utf-8');
const match = html.match(/<script>(.*?)<\/script>/s);
expect(match, 'app.html must contain an inline <script>').not.toBeNull();
const digest = createHash('sha256')
.update(match![1], 'utf-8')
.digest('base64');
expect(THEME_SCRIPT_HASH).toBe(`'sha256-${digest}'`);
});
});

View File

@@ -8,6 +8,7 @@ import {
type MockInstance
} from 'vitest';
import {
applySecurityHeaders,
handle,
setForwardedFor,
shouldBypassProxyTimeout,
@@ -353,6 +354,36 @@ describe('stripHopByHopHeaders', () => {
});
});
describe('applySecurityHeaders', () => {
it('sets the clickjacking and defense-in-depth headers', () => {
const h = applySecurityHeaders(new Headers());
// Clickjacking: legacy + modern. CSP frame-ancestors is emitted by
// SvelteKit's kit.csp config; X-Frame-Options is the belt-and-braces
// fallback for older UAs.
expect(h.get('x-frame-options')).toBe('DENY');
expect(h.get('referrer-policy')).toBe('strict-origin-when-cross-origin');
expect(h.get('x-content-type-options')).toBe('nosniff');
// Lock down powerful features we never use.
const pp = h.get('permissions-policy') ?? '';
expect(pp).toContain('camera=()');
expect(pp).toContain('microphone=()');
expect(pp).toContain('geolocation=()');
});
it('does not clobber a header the response already set', () => {
// If a downstream route deliberately set a stricter Referrer-Policy,
// the helper must not override it.
const h = new Headers({ 'referrer-policy': 'no-referrer' });
applySecurityHeaders(h);
expect(h.get('referrer-policy')).toBe('no-referrer');
});
it('returns the same Headers instance it was given (mutates in place)', () => {
const h = new Headers();
expect(applySecurityHeaders(h)).toBe(h);
});
});
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' });

View File

@@ -105,6 +105,38 @@ export function shouldBypassProxyTimeout(headers: Headers): boolean {
return accept.toLowerCase().includes('text/event-stream');
}
/**
* Defense-in-depth response headers for HTML/document responses.
*
* The Content-Security-Policy itself is emitted by SvelteKit's `kit.csp`
* config (see svelte.config.js) so that `script-src` hashes cover both the
* inline theme script in app.html AND SvelteKit's own hydration inline
* scripts — a hand-rolled `script-src` here would break hydration on the next
* build. What CSP can't (or shouldn't) express, we add here:
*
* - `X-Frame-Options: DENY` — clickjacking guard for UAs predating CSP
* `frame-ancestors` (which the kit.csp policy also sets).
* - `Referrer-Policy` — don't leak full URLs to cross-origin destinations.
* - `X-Content-Type-Options: nosniff` — no MIME sniffing on documents.
* - `Permissions-Policy` — deny powerful features the app never uses.
*
* Each header is only set when absent, so a route that deliberately picked a
* stricter value keeps it. Mutates and returns the passed `Headers`. Exported
* for unit-test coverage.
*/
export function applySecurityHeaders(headers: Headers): Headers {
const defaults: Record<string, string> = {
'x-frame-options': 'DENY',
'referrer-policy': 'strict-origin-when-cross-origin',
'x-content-type-options': 'nosniff',
'permissions-policy': 'camera=(), microphone=(), geolocation=(), interest-cohort=()'
};
for (const [name, value] of Object.entries(defaults)) {
if (!headers.has(name)) headers.set(name, value);
}
return headers;
}
export const handle: Handle = async ({ event, resolve }) => {
if (event.url.pathname.startsWith('/api/')) {
const target = `${BACKEND_URL}${event.url.pathname}${event.url.search}`;
@@ -199,5 +231,7 @@ export const handle: Handle = async ({ event, resolve }) => {
headers: stripHopByHopHeaders(upstream.headers)
});
}
return resolve(event);
const response = await resolve(event);
applySecurityHeaders(response.headers);
return response;
};

View File

@@ -1,11 +1,34 @@
import adapter from '@sveltejs/adapter-node';
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
import { THEME_SCRIPT_HASH } from './csp-config.js';
/** @type {import('@sveltejs/kit').Config} */
const config = {
preprocess: vitePreprocess(),
kit: {
adapter: adapter({ out: 'build' })
adapter: adapter({ out: 'build' }),
// Content-Security-Policy. `mode: 'hash'` makes SvelteKit hash the
// inline scripts IT injects (the hydration bootstrap) and append those
// hashes to `script-src`. It does NOT hash the theme <script> in
// app.html — that template script isn't part of `%sveltekit.head%`, so
// we pin its sha256 here by hand (THEME_SCRIPT_HASH). If that script is
// edited, the hash drifts and the theme-flash guard would be silently
// CSP-blocked; `svelte.config.test.js` recomputes the hash from
// app.html and fails if it no longer matches this constant.
// Styles are left unconstrained (Svelte emits dynamic inline `style=`
// attributes); this policy is clickjacking + script-injection defense
// in depth, not a full lockdown. The non-CSP defense-in-depth headers
// (X-Frame-Options, Referrer-Policy, Permissions-Policy) live in
// hooks.server.ts.
csp: {
mode: 'hash',
directives: {
'script-src': ['self', THEME_SCRIPT_HASH],
'object-src': ['none'],
'base-uri': ['self'],
'frame-ancestors': ['none']
}
}
}
};