The immutable Cache-Control added for the reader preload work was `public` unconditionally. Under PRIVATE_MODE, /files is auth-gated, so a shared cache/CDN would store the blob and serve it to unauthenticated clients, defeating the gate. Emit `private, ...` when private_mode is on, `public, ...` otherwise. Found in the security audit; regression from 0.124.3. Bump to 0.124.9. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
240 lines
8.2 KiB
Rust
240 lines
8.2 KiB
Rust
//! Site-wide auth gate (`PRIVATE_MODE=true`).
|
|
//!
|
|
//! With private mode on, every API path except a small allowlist
|
|
//! (`/health`, `/auth/config`, `/auth/login`, `/auth/logout`) requires
|
|
//! a valid session cookie or bearer token, and `/auth/register` is
|
|
//! force-blocked regardless of `ALLOW_SELF_REGISTER`. With private mode
|
|
//! off (the default), nothing changes — the `public_mode_*` test
|
|
//! pins that regression guard.
|
|
|
|
mod common;
|
|
|
|
use serde_json::json;
|
|
use sqlx::PgPool;
|
|
use tower::ServiceExt;
|
|
|
|
use axum::http::StatusCode;
|
|
|
|
#[sqlx::test(migrations = "./migrations")]
|
|
async fn private_mode_blocks_anonymous_manga_list(pool: PgPool) {
|
|
let h = common::harness_with_private_mode(pool);
|
|
let resp = h.app.oneshot(common::get("/api/v1/mangas")).await.unwrap();
|
|
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
|
|
}
|
|
|
|
#[sqlx::test(migrations = "./migrations")]
|
|
async fn private_mode_blocks_anonymous_files(pool: PgPool) {
|
|
let h = common::harness_with_private_mode(pool);
|
|
// The path doesn't have to exist — the guard runs before routing,
|
|
// so the response is 401 (not 404). That's the property the test
|
|
// is pinning: nothing leaks via crafted URLs.
|
|
let resp = h
|
|
.app
|
|
.oneshot(common::get("/api/v1/files/anything.png"))
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
|
|
}
|
|
|
|
#[sqlx::test(migrations = "./migrations")]
|
|
async fn private_mode_allows_session_cookie_read(pool: PgPool) {
|
|
// Register through a non-private harness sharing the same DB pool
|
|
// so the session row exists. Then exercise the gate using a fresh
|
|
// private-mode harness against the same DB.
|
|
let public = common::harness(pool.clone());
|
|
let (_, cookie) = common::register_user(&public.app).await;
|
|
|
|
let private = common::harness_with_private_mode(pool);
|
|
let resp = private
|
|
.app
|
|
.oneshot(common::get_with_cookie("/api/v1/mangas", &cookie))
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(resp.status(), StatusCode::OK);
|
|
}
|
|
|
|
#[sqlx::test(migrations = "./migrations")]
|
|
async fn private_mode_allows_bearer_token_read(pool: PgPool) {
|
|
let public = common::harness(pool.clone());
|
|
let (_, cookie) = common::register_user(&public.app).await;
|
|
|
|
let resp = public
|
|
.app
|
|
.clone()
|
|
.oneshot(common::post_json_with_cookie(
|
|
"/api/v1/auth/tokens",
|
|
json!({ "name": "private-mode-bot" }),
|
|
&cookie,
|
|
))
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(resp.status(), StatusCode::CREATED);
|
|
let body = common::body_json(resp).await;
|
|
let bearer = body["bearer"].as_str().unwrap().to_string();
|
|
|
|
let private = common::harness_with_private_mode(pool);
|
|
let resp = private
|
|
.app
|
|
.oneshot(common::get_with_bearer("/api/v1/mangas", &bearer))
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(resp.status(), StatusCode::OK);
|
|
}
|
|
|
|
#[sqlx::test(migrations = "./migrations")]
|
|
async fn private_mode_allows_login_endpoint_anonymous(pool: PgPool) {
|
|
// Seed a user via the public harness so login has credentials to
|
|
// verify against.
|
|
let public = common::harness(pool.clone());
|
|
let _ = public
|
|
.app
|
|
.clone()
|
|
.oneshot(common::post_json(
|
|
"/api/v1/auth/register",
|
|
json!({ "username": "alice", "password": "hunter2hunter2" }),
|
|
))
|
|
.await
|
|
.unwrap();
|
|
|
|
let private = common::harness_with_private_mode(pool);
|
|
let resp = private
|
|
.app
|
|
.oneshot(common::post_json(
|
|
"/api/v1/auth/login",
|
|
json!({ "username": "alice", "password": "hunter2hunter2" }),
|
|
))
|
|
.await
|
|
.unwrap();
|
|
// Reaches the login handler and succeeds — *not* 401 from the
|
|
// gate. That's the property we're pinning.
|
|
assert_eq!(resp.status(), StatusCode::OK);
|
|
}
|
|
|
|
#[sqlx::test(migrations = "./migrations")]
|
|
async fn private_mode_allows_health_and_config_anonymous(pool: PgPool) {
|
|
let h = common::harness_with_private_mode(pool);
|
|
|
|
let r = h
|
|
.app
|
|
.clone()
|
|
.oneshot(common::get("/api/v1/health"))
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(r.status(), StatusCode::OK);
|
|
|
|
let r = h
|
|
.app
|
|
.oneshot(common::get("/api/v1/auth/config"))
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(r.status(), StatusCode::OK);
|
|
}
|
|
|
|
#[sqlx::test(migrations = "./migrations")]
|
|
async fn private_mode_blocks_register_even_when_self_register_enabled(pool: PgPool) {
|
|
// harness_with_private_mode keeps `allow_self_register=true` (the
|
|
// default) — private mode is supposed to force-block register
|
|
// regardless. That's what this test pins.
|
|
let h = common::harness_with_private_mode(pool);
|
|
let resp = h
|
|
.app
|
|
.oneshot(common::post_json(
|
|
"/api/v1/auth/register",
|
|
json!({ "username": "alice", "password": "hunter2hunter2" }),
|
|
))
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(resp.status(), StatusCode::FORBIDDEN);
|
|
let body = common::body_json(resp).await;
|
|
assert_eq!(body["error"]["code"], "forbidden");
|
|
}
|
|
|
|
#[sqlx::test(migrations = "./migrations")]
|
|
async fn private_mode_serves_files_as_private_not_public_cacheable(pool: PgPool) {
|
|
// In private mode /files is auth-gated, so a served blob must NOT carry a
|
|
// `public` Cache-Control: a shared cache / CDN would otherwise store it and
|
|
// hand it to anonymous clients, defeating the gate. It should be `private`.
|
|
//
|
|
// Register via a public harness on the shared pool so the session exists,
|
|
// then upload + fetch a cover through the private harness (same storage).
|
|
let public = common::harness(pool.clone());
|
|
let (_, cookie) = common::register_user(&public.app).await;
|
|
|
|
let private = common::harness_with_private_mode(pool);
|
|
let form = common::MultipartBuilder::new()
|
|
.add_json("metadata", json!({ "title": "Secret Library" }))
|
|
.add_file("cover", "cover.png", "image/png", &common::fake_png_bytes());
|
|
let created = private
|
|
.app
|
|
.clone()
|
|
.oneshot(common::post_multipart_with_cookie(
|
|
"/api/v1/mangas",
|
|
form,
|
|
&cookie,
|
|
))
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(created.status(), StatusCode::CREATED);
|
|
let body = common::body_json(created).await;
|
|
let key = body["cover_image_path"].as_str().unwrap().to_string();
|
|
|
|
let resp = private
|
|
.app
|
|
.oneshot(common::get_with_cookie(
|
|
&format!("/api/v1/files/{key}"),
|
|
&cookie,
|
|
))
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(resp.status(), StatusCode::OK);
|
|
let cc = resp
|
|
.headers()
|
|
.get(axum::http::header::CACHE_CONTROL)
|
|
.unwrap()
|
|
.to_str()
|
|
.unwrap();
|
|
assert!(
|
|
cc.starts_with("private") && !cc.contains("public"),
|
|
"private-mode blobs must be `private`, not `public`; got: {cc}"
|
|
);
|
|
}
|
|
|
|
#[sqlx::test(migrations = "./migrations")]
|
|
async fn auth_config_reports_private_mode_and_effective_self_register(pool: PgPool) {
|
|
let h = common::harness_with_private_mode(pool);
|
|
let resp = h
|
|
.app
|
|
.oneshot(common::get("/api/v1/auth/config"))
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(resp.status(), StatusCode::OK);
|
|
let body = common::body_json(resp).await;
|
|
assert_eq!(body["private_mode"], true);
|
|
// Effective value: `allow_self_register && !private_mode` is false
|
|
// here even though the raw `allow_self_register` is true.
|
|
assert_eq!(body["self_register_enabled"], false);
|
|
}
|
|
|
|
#[sqlx::test(migrations = "./migrations")]
|
|
async fn public_mode_does_not_gate_anonymous_reads(pool: PgPool) {
|
|
// Regression guard: with private_mode off (the default), the gate
|
|
// must be a no-op so existing public deployments stay public.
|
|
let h = common::harness(pool);
|
|
let resp = h.app.oneshot(common::get("/api/v1/mangas")).await.unwrap();
|
|
assert_eq!(resp.status(), StatusCode::OK);
|
|
}
|
|
|
|
#[sqlx::test(migrations = "./migrations")]
|
|
async fn public_mode_reports_private_mode_false(pool: PgPool) {
|
|
let h = common::harness(pool);
|
|
let resp = h
|
|
.app
|
|
.oneshot(common::get("/api/v1/auth/config"))
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(resp.status(), StatusCode::OK);
|
|
let body = common::body_json(resp).await;
|
|
assert_eq!(body["private_mode"], false);
|
|
assert_eq!(body["self_register_enabled"], true);
|
|
}
|