Compare commits
7 Commits
a44511983d
...
b5f7467c47
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b5f7467c47 | ||
|
|
9148e23da8 | ||
|
|
f39307232c | ||
|
|
1b7b8a3038 | ||
|
|
253d46c7e5 | ||
|
|
c570e0cc37 | ||
|
|
5784483a57 |
3
backend/Cargo.lock
generated
3
backend/Cargo.lock
generated
@@ -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",
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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 /
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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(""), "");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")]
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
57
backend/tests/api_files.rs
Normal file
57
backend/tests/api_files.rs
Normal 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"
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
52
backend/tests/repo_session.rs
Normal file
52
backend/tests/repo_session.rs
Normal 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
10
frontend/csp-config.js
Normal 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='";
|
||||
37
frontend/e2e/security-headers.spec.ts
Normal file
37
frontend/e2e/security-headers.spec.ts
Normal 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);
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "mangalord-frontend",
|
||||
"version": "0.128.4",
|
||||
"version": "0.128.10",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
25
frontend/src/csp-theme-hash.test.ts
Normal file
25
frontend/src/csp-theme-hash.test.ts
Normal 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}'`);
|
||||
});
|
||||
});
|
||||
@@ -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' });
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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']
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user