Implements §9a. The replication surface is four reads and no writes: a change feed, fetch by content_id, a batch have, and a human-facing peer directory — plus a capabilities endpoint carrying the accepted envelope versions, which lets a client discover a schema mismatch in one request instead of a 400 per manifest across a library sweep. Pull, never push: a pulling server chooses what it ingests and when. Push would let any peer inject work into the validation queue — the same abuse surface as anonymous upload, at higher volume. Nothing inherits a peer's judgement. A pulled manifest runs the full §6 stage 1 and 2 validation and this server's own cast check, and the fetched body must hash to the content_id that was asked for — the check that stops an intermediary or a misbehaving peer substituting content under a trusted id. A peer's retraction flags for review rather than delisting, because auto-delisting would hand every peer a remote delete primitive; only the opt-in per-peer abuse channel delists, because a takedown propagating at the speed of manual review is the wrong failure mode for that one case. A test caught a real bug in the first cut: the feed cursor was a ULID, and ULIDs are only monotonic *between* milliseconds — two generated in the same millisecond carry independent random components and can sort opposite to write order. A peer resuming from `seq > cursor` would then silently skip an entry: replication losing manifests with no error anywhere. The cursor is now an AUTOINCREMENT integer, and the test asserts strict monotonicity rather than merely sortedness. Peer administration is deliberately not an API. §9a requires that a peering exist only because an operator typed a URL, so nothing a remote server returns can establish or widen one; there_is_no_endpoint_that_creates_a_peering asserts that absence rather than trusting it. 212 tests. Coverage 25/32 (78%). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> TRACES: UR-008 | PR-006
637 lines
24 KiB
Rust
637 lines
24 KiB
Rust
//! Injection resistance — SQL, JSON and header.
|
|
//!
|
|
//! These are regression tests for properties the design already provides, kept
|
|
//! separate from `api.rs` because their purpose is different: `api.rs` asserts the
|
|
//! spec's behaviour, this asserts that hostile input cannot escape its layer.
|
|
//!
|
|
//! Two distinct defences are at work, and it is worth being precise about which
|
|
//! applies where, because they fail differently:
|
|
//!
|
|
//! 1. **Parameterised queries** (§7, §8). Every value reaches SQLite through
|
|
//! `params![]`; the only `format!`-built SQL interpolates compile-time
|
|
//! constants (a column list and a status literal). So a value carrying SQL
|
|
//! syntax is bound as *data* and simply matches nothing.
|
|
//! 2. **Closed-vocabulary validation** (§5a, §6 stage 2). Identifiers are
|
|
//! regex-constrained and free text is restricted to a closed character class,
|
|
//! so most injection strings are rejected before they reach the database.
|
|
//!
|
|
//! Defence 1 is what actually prevents injection; defence 2 means an attacker
|
|
//! usually cannot even reach it. Testing both matters: if validation were ever
|
|
//! loosened, these tests should still pass on the strength of parameterisation
|
|
//! alone.
|
|
|
|
use std::sync::Arc;
|
|
|
|
use axum::body::Body;
|
|
use axum::http::{Request, StatusCode};
|
|
use http_body_util::BodyExt;
|
|
use jray_server::app;
|
|
use jray_server::config::Config;
|
|
use jray_server::db::Db;
|
|
use jray_server::ratelimit::RateLimiter;
|
|
use jray_server::state::AppState;
|
|
use jray_server::tmdb::TmdbClient;
|
|
use serde_json::{json, Value};
|
|
use tower::ServiceExt;
|
|
|
|
/// Payloads spanning the usual SQL-injection shapes: boolean tautology, statement
|
|
/// termination, stacked statements, UNION exfiltration, comment truncation, and
|
|
/// string-concatenation exfiltration.
|
|
const SQL_PAYLOADS: &[&str] = &[
|
|
"1' OR '1'='1",
|
|
"1'; DROP TABLE manifests;--",
|
|
"1 UNION SELECT token_hash FROM contributors",
|
|
"' OR 1=1--",
|
|
"1'||(SELECT token_hash FROM contributors)||'",
|
|
"1)) OR 1=1 --",
|
|
"'; UPDATE manifests SET status='listed' WHERE 1=1;--",
|
|
"1/**/UNION/**/SELECT/**/1",
|
|
"x' AND (SELECT COUNT(*) FROM sqlite_master)>0 --",
|
|
"\"; DELETE FROM scenes; --",
|
|
];
|
|
|
|
struct TestServer {
|
|
router: axum::Router,
|
|
db: Db,
|
|
_dir: TempDir,
|
|
}
|
|
|
|
struct TempDir(std::path::PathBuf);
|
|
|
|
impl TempDir {
|
|
fn new(tag: &str) -> Self {
|
|
let mut p = std::env::temp_dir();
|
|
p.push(format!("jray-inj-{}-{}", tag, unique()));
|
|
std::fs::create_dir_all(&p).expect("creating temp dir");
|
|
Self(p)
|
|
}
|
|
fn db_path(&self) -> String {
|
|
self.0.join("test.db").to_string_lossy().into_owned()
|
|
}
|
|
}
|
|
|
|
impl Drop for TempDir {
|
|
fn drop(&mut self) {
|
|
let _ = std::fs::remove_dir_all(&self.0);
|
|
}
|
|
}
|
|
|
|
fn unique() -> String {
|
|
use std::sync::atomic::{AtomicU64, Ordering};
|
|
static N: AtomicU64 = AtomicU64::new(0);
|
|
let t = std::time::SystemTime::now()
|
|
.duration_since(std::time::UNIX_EPOCH)
|
|
.map(|d| d.as_nanos())
|
|
.unwrap_or(0);
|
|
format!("{t}-{}", N.fetch_add(1, Ordering::Relaxed))
|
|
}
|
|
|
|
impl TestServer {
|
|
fn new(tag: &str) -> Self {
|
|
let dir = TempDir::new(tag);
|
|
let db = Db::open(&dir.db_path()).expect("opening database");
|
|
let config = Arc::new(Config {
|
|
bind: "127.0.0.1:0".into(),
|
|
db_path: dir.db_path(),
|
|
tmdb_api_key: None,
|
|
tmdb_base_url: "http://127.0.0.1:1".into(),
|
|
trusted_proxies: Vec::new(),
|
|
server_id: "test.example".into(),
|
|
publish_peer_directory: true,
|
|
contact: Some("admin@test.example".into()),
|
|
request_timeout: std::time::Duration::from_secs(30),
|
|
job_batch: 8,
|
|
job_poll_interval: std::time::Duration::from_secs(3600),
|
|
});
|
|
let state = AppState {
|
|
db: db.clone(),
|
|
config: config.clone(),
|
|
limiter: Arc::new(RateLimiter::new()),
|
|
tmdb: Arc::new(TmdbClient::new(config.tmdb_base_url.clone(), None)),
|
|
};
|
|
Self { router: app::router(state), db, _dir: dir }
|
|
}
|
|
|
|
async fn send(&self, req: Request<Body>) -> (StatusCode, Value) {
|
|
let resp = self.router.clone().oneshot(req).await.expect("router call");
|
|
let status = resp.status();
|
|
let bytes = resp.into_body().collect().await.expect("body").to_bytes();
|
|
let body = if bytes.is_empty() {
|
|
Value::Null
|
|
} else {
|
|
serde_json::from_slice(&bytes)
|
|
.unwrap_or(Value::String(String::from_utf8_lossy(&bytes).into_owned()))
|
|
};
|
|
(status, body)
|
|
}
|
|
|
|
async fn get(&self, uri: &str) -> (StatusCode, Value) {
|
|
self.send(Request::builder().uri(uri).body(Body::empty()).unwrap()).await
|
|
}
|
|
|
|
async fn post(&self, uri: &str, token: Option<&str>, body: &Value) -> (StatusCode, Value) {
|
|
let mut b =
|
|
Request::builder().method("POST").uri(uri).header("content-type", "application/json");
|
|
if let Some(t) = token {
|
|
b = b.header("authorization", format!("Bearer {t}"));
|
|
}
|
|
self.send(b.body(Body::from(body.to_string())).unwrap()).await
|
|
}
|
|
|
|
async fn token(&self) -> String {
|
|
let (_, body) = self.post("/api/v1/tokens", None, &json!({})).await;
|
|
body["token"].as_str().expect("token").to_string()
|
|
}
|
|
|
|
/// Confirms the schema is intact and the expected row counts hold.
|
|
///
|
|
/// A successful injection would most likely drop a table or delete rows, so
|
|
/// this is the assertion that actually matters after each payload.
|
|
async fn assert_schema_intact(&self) {
|
|
let tables: Vec<String> = self
|
|
.db
|
|
.read(|conn| {
|
|
let mut stmt = conn
|
|
.prepare("SELECT name FROM sqlite_master WHERE type='table' ORDER BY name")?;
|
|
let rows = stmt
|
|
.query_map([], |r| r.get::<_, String>(0))?
|
|
.collect::<rusqlite::Result<Vec<_>>>()?;
|
|
Ok(rows)
|
|
})
|
|
.await
|
|
.expect("listing tables");
|
|
|
|
for expected in [
|
|
"contributors",
|
|
"jobs",
|
|
"manifest_actors",
|
|
"manifests",
|
|
"people",
|
|
"reports",
|
|
"scenes",
|
|
"titles",
|
|
"tmdb_cache",
|
|
] {
|
|
assert!(
|
|
tables.iter().any(|t| t == expected),
|
|
"table {expected} is missing — an injection may have dropped it. tables: {tables:?}"
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
fn urlencode(s: &str) -> String {
|
|
let mut out = String::new();
|
|
for b in s.bytes() {
|
|
match b {
|
|
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
|
|
out.push(b as char)
|
|
}
|
|
_ => out.push_str(&format!("%{b:02X}")),
|
|
}
|
|
}
|
|
out
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// SQL injection — query parameters
|
|
// ---------------------------------------------------------------------------
|
|
|
|
#[tokio::test]
|
|
async fn sql_payloads_in_query_parameters_are_inert() {
|
|
let s = TestServer::new("query");
|
|
|
|
for payload in SQL_PAYLOADS {
|
|
let enc = urlencode(payload);
|
|
for uri in [
|
|
format!("/api/v1/manifests/exists?tmdb_id={enc}"),
|
|
format!("/api/v1/manifests/exists?imdb_id={enc}"),
|
|
format!("/api/v1/manifests/movie?tmdb_id={enc}"),
|
|
format!("/api/v1/manifests/movie?imdb_id={enc}"),
|
|
format!("/api/v1/manifests/episode?series_tmdb_id={enc}&season=1&episode=1"),
|
|
format!("/api/v1/manifests/series/{enc}"),
|
|
format!("/api/v1/manifests/exists?tmdb_id=1&video_hash={enc}"),
|
|
] {
|
|
let (status, body) = s.get(&uri).await;
|
|
// The payload is bound as data, so it matches nothing. What must never
|
|
// happen is a 5xx, which would mean SQLite saw it as syntax.
|
|
assert!(
|
|
status.is_success()
|
|
|| status == StatusCode::NOT_FOUND
|
|
|| status == StatusCode::BAD_REQUEST,
|
|
"payload {payload:?} on {uri} produced {status} — expected data-not-found, \
|
|
not a server error. body: {body}"
|
|
);
|
|
}
|
|
}
|
|
|
|
s.assert_schema_intact().await;
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn sql_payloads_in_path_parameters_are_inert() {
|
|
let s = TestServer::new("path");
|
|
|
|
for payload in SQL_PAYLOADS {
|
|
let enc = urlencode(payload);
|
|
for uri in [
|
|
format!("/api/v1/manifests/{enc}"),
|
|
format!("/api/v1/manifests/{enc}/status"),
|
|
format!("/api/v1/manifests/series/{enc}"),
|
|
] {
|
|
let (status, body) = s.get(&uri).await;
|
|
assert!(
|
|
!status.is_server_error(),
|
|
"payload {payload:?} on {uri} produced {status}: {body}"
|
|
);
|
|
}
|
|
}
|
|
|
|
s.assert_schema_intact().await;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// SQL injection — JSON body fields
|
|
// ---------------------------------------------------------------------------
|
|
|
|
#[tokio::test]
|
|
async fn sql_payloads_in_identifier_fields_are_rejected() {
|
|
// §6 stage 2 regex-constrains every identifier, so these never even reach the
|
|
// query layer. The response must be a clean 400 naming the field.
|
|
let s = TestServer::new("body-ids");
|
|
let token = s.token().await;
|
|
|
|
for payload in SQL_PAYLOADS {
|
|
let manifest = json!({
|
|
"jmanifest_version": 2,
|
|
"identity": { "type": "movie", "tmdb_id": payload },
|
|
"cut": { "runtime_sec": 100.0 },
|
|
"actors": [ { "tmdb_id": "884", "scenes": [{"start":1.0,"end":2.0}] } ]
|
|
});
|
|
let (status, body) = s.post("/api/v1/manifests", Some(&token), &manifest).await;
|
|
assert_eq!(
|
|
status,
|
|
StatusCode::BAD_REQUEST,
|
|
"payload {payload:?} should be rejected by validation: {body}"
|
|
);
|
|
}
|
|
|
|
s.assert_schema_intact().await;
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn sql_payloads_in_free_text_fields_are_rejected() {
|
|
// The two free-text fields (§5a) are the only place arbitrary strings could
|
|
// arrive. The closed character class excludes quotes, semicolons and digits,
|
|
// which is what makes SQL syntax unrepresentable there.
|
|
let s = TestServer::new("body-text");
|
|
let token = s.token().await;
|
|
|
|
for payload in SQL_PAYLOADS {
|
|
for manifest in [
|
|
json!({
|
|
"jmanifest_version": 2,
|
|
"identity": { "type": "movie", "tmdb_id": "504172", "title": payload },
|
|
"cut": { "runtime_sec": 100.0 },
|
|
"actors": [ { "tmdb_id": "884", "scenes": [{"start":1.0,"end":2.0}] } ]
|
|
}),
|
|
json!({
|
|
"jmanifest_version": 2,
|
|
"identity": { "type": "movie", "tmdb_id": "504172" },
|
|
"cut": { "runtime_sec": 100.0 },
|
|
"actors": [ { "name": payload, "tmdb_id": "884", "scenes": [{"start":1.0,"end":2.0}] } ]
|
|
}),
|
|
] {
|
|
let (status, body) = s.post("/api/v1/manifests", Some(&token), &manifest).await;
|
|
assert_eq!(
|
|
status,
|
|
StatusCode::BAD_REQUEST,
|
|
"free-text payload {payload:?} should be rejected: {body}"
|
|
);
|
|
}
|
|
}
|
|
|
|
s.assert_schema_intact().await;
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn sql_payloads_in_a_report_note_cannot_escape() {
|
|
// `note` is the one field that accepts relatively free text (control
|
|
// characters stripped, length capped) because only the operator reads it. It
|
|
// reaches the database, so it is the strongest test of parameterisation:
|
|
// validation is *not* filtering SQL syntax here.
|
|
let s = TestServer::new("report-note");
|
|
let token = s.token().await;
|
|
|
|
let (_, body) = s
|
|
.post(
|
|
"/api/v1/manifests",
|
|
Some(&token),
|
|
&json!({
|
|
"jmanifest_version": 2,
|
|
"identity": { "type": "movie", "tmdb_id": "504172" },
|
|
"cut": { "runtime_sec": 100.0 },
|
|
"actors": [ { "tmdb_id": "884", "scenes": [{"start":1.0,"end":2.0}] } ]
|
|
}),
|
|
)
|
|
.await;
|
|
let id = body["manifest_id"].as_str().expect("manifest id").to_string();
|
|
|
|
for payload in SQL_PAYLOADS {
|
|
let (status, body) = s
|
|
.post(
|
|
&format!("/api/v1/manifests/{id}/report"),
|
|
None,
|
|
&json!({ "reason": "spam", "note": payload }),
|
|
)
|
|
.await;
|
|
assert!(
|
|
status.is_success() || status == StatusCode::TOO_MANY_REQUESTS,
|
|
"note payload {payload:?} produced {status}: {body}"
|
|
);
|
|
if status.is_success() {
|
|
s.assert_schema_intact().await;
|
|
}
|
|
}
|
|
|
|
// The notes were stored verbatim as *data* — proving they were bound, not
|
|
// executed. Verified by reading them back out.
|
|
let stored: i64 =
|
|
s.db.read(|conn| Ok(conn.query_row("SELECT COUNT(*) FROM reports", [], |r| r.get(0))?))
|
|
.await
|
|
.expect("counting reports");
|
|
assert!(stored > 0, "reports should have been stored as inert data");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn sql_payloads_in_a_bearer_token_are_inert() {
|
|
// The token is hashed before it reaches any query, but a payload arriving via
|
|
// a header must still not produce a 5xx.
|
|
let s = TestServer::new("token-inj");
|
|
|
|
for payload in SQL_PAYLOADS {
|
|
let req = Request::builder()
|
|
.method("POST")
|
|
.uri("/api/v1/manifests")
|
|
.header("content-type", "application/json")
|
|
.header("authorization", format!("Bearer {payload}"))
|
|
.body(Body::from(
|
|
json!({
|
|
"jmanifest_version": 2,
|
|
"identity": { "type": "movie", "tmdb_id": "504172" },
|
|
"cut": { "runtime_sec": 100.0 },
|
|
"actors": [ { "tmdb_id": "884", "scenes": [{"start":1.0,"end":2.0}] } ]
|
|
})
|
|
.to_string(),
|
|
))
|
|
.unwrap();
|
|
let (status, body) = s.send(req).await;
|
|
assert_eq!(
|
|
status,
|
|
StatusCode::UNAUTHORIZED,
|
|
"token payload {payload:?} produced {status}: {body}"
|
|
);
|
|
}
|
|
|
|
s.assert_schema_intact().await;
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn sql_payloads_in_the_batch_exists_body_are_inert() {
|
|
let s = TestServer::new("batch-inj");
|
|
let items: Vec<Value> = SQL_PAYLOADS.iter().map(|p| json!({ "tmdb_id": p })).collect();
|
|
let (status, body) = s.post("/api/v1/manifests/exists", None, &json!({ "items": items })).await;
|
|
assert_eq!(status, StatusCode::OK, "{body}");
|
|
// Each malformed item degrades to "absent" rather than erroring the batch.
|
|
for result in body["results"].as_array().expect("results") {
|
|
assert_eq!(result["exists"], false);
|
|
}
|
|
s.assert_schema_intact().await;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// JSON injection / parser abuse
|
|
// ---------------------------------------------------------------------------
|
|
|
|
#[tokio::test]
|
|
async fn json_structure_abuse_is_rejected_cleanly() {
|
|
// A parser handed hostile structure must fail with 400/413, never 5xx and
|
|
// never a hang (§6 stage 1: "a parser handed an unbounded body is a
|
|
// denial-of-service primitive").
|
|
let s = TestServer::new("json-abuse");
|
|
let token = s.token().await;
|
|
|
|
let cases: Vec<(&str, String)> = vec![
|
|
("deep nesting", format!("{}{}", "[".repeat(20_000), "]".repeat(20_000))),
|
|
("unterminated", "{\"identity\": {\"type\": \"movie\"".to_string()),
|
|
("duplicate keys", r#"{"jmanifest_version":2,"jmanifest_version":2}"#.to_string()),
|
|
("null bytes", "{\"jmanifest_version\":\u{0}1}".to_string()),
|
|
("huge number", format!("{{\"jmanifest_version\":{}}}", "9".repeat(5000))),
|
|
("nan literal", r#"{"jmanifest_version":2,"cut":{"runtime_sec":NaN}}"#.to_string()),
|
|
("bare array", "[1,2,3]".to_string()),
|
|
("bare string", "\"just a string\"".to_string()),
|
|
("empty body", String::new()),
|
|
(
|
|
"prototype-style key",
|
|
r#"{"__proto__":{"admin":true},"jmanifest_version":2}"#.to_string(),
|
|
),
|
|
];
|
|
|
|
for (label, body) in cases {
|
|
let req = Request::builder()
|
|
.method("POST")
|
|
.uri("/api/v1/manifests")
|
|
.header("content-type", "application/json")
|
|
.header("authorization", format!("Bearer {token}"))
|
|
.body(Body::from(body))
|
|
.unwrap();
|
|
let (status, resp) = s.send(req).await;
|
|
assert!(
|
|
status == StatusCode::BAD_REQUEST || status == StatusCode::PAYLOAD_TOO_LARGE,
|
|
"{label} produced {status}, expected a clean rejection: {resp}"
|
|
);
|
|
}
|
|
|
|
s.assert_schema_intact().await;
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn non_finite_scene_times_are_rejected() {
|
|
// §6 explicitly rejects NaN/Infinity. They cannot arrive as JSON literals, but
|
|
// they can arrive as overflowing decimals, which parse to f64 infinity.
|
|
let s = TestServer::new("nonfinite");
|
|
let token = s.token().await;
|
|
|
|
// Sent as raw JSON text rather than via `json!`, because rustc refuses an
|
|
// out-of-range float literal — and the point is to make the *server's* parser
|
|
// handle it, which is the real attack path.
|
|
let raw = r#"{"jmanifest_version":2,
|
|
"identity":{"type":"movie","tmdb_id":"504172"},
|
|
"cut":{"runtime_sec":100.0},
|
|
"actors":[{"tmdb_id":"884","scenes":[[1.0,1e400]]}]}"#;
|
|
|
|
let req = Request::builder()
|
|
.method("POST")
|
|
.uri("/api/v1/manifests")
|
|
.header("content-type", "application/json")
|
|
.header("authorization", format!("Bearer {token}"))
|
|
.body(Body::from(raw))
|
|
.unwrap();
|
|
let (status, body) = s.send(req).await;
|
|
assert_eq!(status, StatusCode::BAD_REQUEST, "{body}");
|
|
|
|
// Likewise an overflowing runtime.
|
|
let raw = r#"{"jmanifest_version":2,
|
|
"identity":{"type":"movie","tmdb_id":"504172"},
|
|
"cut":{"runtime_sec":1e400},
|
|
"actors":[{"tmdb_id":"884","scenes":[{"start":1.0,"end":2.0}]}]}"#;
|
|
let req = Request::builder()
|
|
.method("POST")
|
|
.uri("/api/v1/manifests")
|
|
.header("content-type", "application/json")
|
|
.header("authorization", format!("Bearer {token}"))
|
|
.body(Body::from(raw))
|
|
.unwrap();
|
|
let (status, body) = s.send(req).await;
|
|
assert_eq!(status, StatusCode::BAD_REQUEST, "{body}");
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Header injection
|
|
// ---------------------------------------------------------------------------
|
|
|
|
#[tokio::test]
|
|
async fn crlf_in_a_header_value_cannot_split_the_response() {
|
|
// A CRLF-carrying header value must not appear in the response as new headers.
|
|
// `http` rejects such values at construction, so this asserts the invariant
|
|
// holds at the boundary rather than relying on our own escaping.
|
|
let bad = "1.2.3.4\r\nX-Injected: yes";
|
|
assert!(
|
|
axum::http::HeaderValue::from_str(bad).is_err(),
|
|
"the http crate must refuse CRLF in header values"
|
|
);
|
|
|
|
// And a percent-encoded variant reaching a handler stays inert data.
|
|
let s = TestServer::new("crlf");
|
|
let (status, _) = s.get("/api/v1/manifests/exists?tmdb_id=1%0D%0AX-Injected:%20yes").await;
|
|
assert!(!status.is_server_error());
|
|
s.assert_schema_intact().await;
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn oversized_headers_do_not_take_the_server_down() {
|
|
let s = TestServer::new("big-header");
|
|
let big = "a".repeat(100_000);
|
|
let req = Request::builder()
|
|
.uri("/api/v1/manifests/exists?tmdb_id=1")
|
|
.header("x-filler", big)
|
|
.body(Body::empty())
|
|
.unwrap();
|
|
let (status, _) = s.send(req).await;
|
|
assert!(!status.is_server_error(), "got {status}");
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Path traversal
|
|
// ---------------------------------------------------------------------------
|
|
|
|
#[tokio::test]
|
|
async fn path_traversal_attempts_reach_no_filesystem() {
|
|
// The server serves no files at all, so traversal has nowhere to go. Asserted
|
|
// anyway, because the manifest id is a path segment.
|
|
let s = TestServer::new("traversal");
|
|
for probe in [
|
|
"..%2F..%2F..%2Fetc%2Fpasswd",
|
|
"....%2F%2F....%2F%2Fetc%2Fpasswd",
|
|
"%2e%2e%2f%2e%2e%2fetc%2fshadow",
|
|
"..%5C..%5Cwindows%5Csystem32",
|
|
"%00/etc/passwd",
|
|
] {
|
|
let (status, body) = s.get(&format!("/api/v1/manifests/{probe}")).await;
|
|
assert!(
|
|
status == StatusCode::NOT_FOUND || status == StatusCode::BAD_REQUEST,
|
|
"probe {probe} produced {status}: {body}"
|
|
);
|
|
// Nothing that looks like file content should ever come back.
|
|
let text = body.to_string();
|
|
assert!(!text.contains("root:"), "probe {probe} returned passwd-like content");
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Unicode and encoding tricks against the §5a character class
|
|
// ---------------------------------------------------------------------------
|
|
|
|
#[tokio::test]
|
|
async fn unicode_tricks_cannot_smuggle_text_past_the_character_class() {
|
|
// §5a's class is checked *after* NFC normalisation, so decomposed and
|
|
// compatibility forms must not provide a way in. Fullwidth digits are the
|
|
// sharpest case: NFKC would fold them to ASCII digits, but NFC does not, and
|
|
// they are `Nd` (not a letter), so the class rejects them either way.
|
|
let s = TestServer::new("unicode");
|
|
let token = s.token().await;
|
|
|
|
for payload in [
|
|
"Actor 123", // fullwidth letters and digits
|
|
"Steve\u{FEFF}Buscemi", // zero-width no-break space
|
|
"Ste\u{0301}ve\u{202E}", // combining acute plus bidi override
|
|
"𝐒𝐭𝐞𝐯𝐞", // mathematical bold (compatibility form)
|
|
"Steve\u{2028}Buscemi", // line separator
|
|
"\u{1F600} Actor", // emoji
|
|
"Actor\u{00A0}Name\u{0000}", // nbsp plus NUL
|
|
] {
|
|
let (status, body) = s
|
|
.post(
|
|
"/api/v1/manifests",
|
|
Some(&token),
|
|
&json!({
|
|
"jmanifest_version": 2,
|
|
"identity": { "type": "movie", "tmdb_id": "504172" },
|
|
"cut": { "runtime_sec": 100.0 },
|
|
"actors": [ { "name": payload, "tmdb_id": "884", "scenes": [{"start":1.0,"end":2.0}] } ]
|
|
}),
|
|
)
|
|
.await;
|
|
assert_eq!(
|
|
status,
|
|
StatusCode::BAD_REQUEST,
|
|
"unicode payload {payload:?} should be rejected: {body}"
|
|
);
|
|
}
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn the_audio_signature_field_cannot_carry_arbitrary_bytes() {
|
|
// §3: a variable-length blob would be a payload channel — "precisely what §5a
|
|
// closes". Length is fixed and every byte is structurally constrained.
|
|
let s = TestServer::new("audio-sig");
|
|
let token = s.token().await;
|
|
|
|
for sig in [
|
|
"v1:aGVsbG8gd29ybGQ=", // too short to be a signature
|
|
&format!("v1:{}", "/".repeat(4000)), // high bit set throughout
|
|
&format!("v1:{}", "A".repeat(100_000)), // oversized
|
|
"not-base64-at-all", // missing version prefix
|
|
&"A".repeat(1720), // unprefixed
|
|
] {
|
|
let (status, body) = s
|
|
.post(
|
|
"/api/v1/manifests",
|
|
Some(&token),
|
|
&json!({
|
|
"jmanifest_version": 2,
|
|
"identity": { "type": "movie", "tmdb_id": "504172" },
|
|
"cut": { "runtime_sec": 6420.5, "audio_signature": sig },
|
|
"actors": [ { "tmdb_id": "884", "scenes": [{"start":1.0,"end":2.0}] } ]
|
|
}),
|
|
)
|
|
.await;
|
|
assert_eq!(
|
|
status,
|
|
StatusCode::BAD_REQUEST,
|
|
"signature {:?} should be rejected: {body}",
|
|
&sig.chars().take(40).collect::<String>()
|
|
);
|
|
}
|
|
}
|