Fix bugs found by an Opus 5 audit: races, parsing, and CDATA gaps

- Atomic claim on review-queue approval, closing a double-approve race
  that could grab the same release twice (scheduler.rs)
- Constant-time comparison for the daemon API token, closing a timing
  side channel
- RSS items wrapped in CDATA (common for titles with '&') were
  silently dropped - only Event::Text was ever handled
- Reject malformed apibay info_hash values before building a magnet
  link that extract_btih can't parse back out
- Parse sizes with no space before the unit ("38.1GiB")
- Fix "Season N - NN" episode parsing and stop misreading a
  YYYY-MM-DD date as a bare episode range
- Query embeddings are no longer cached, fixing unbounded cache growth
  over the daemon's lifetime (only library-side candidates need caching)
This commit is contained in:
Breadway 2026-08-03 08:43:29 +08:00
parent 66d323b7f7
commit 0f609aa4cc
9 changed files with 427 additions and 48 deletions

View file

@ -109,13 +109,27 @@ async fn require_api_token(State(state): State<AppState>, req: Request, next: Ne
.get(axum::http::header::AUTHORIZATION)
.and_then(|v| v.to_str().ok())
.and_then(|v| v.strip_prefix("Bearer "))
.is_some_and(|token| token == state.config.daemon.api_token);
.is_some_and(|token| constant_time_eq(token, &state.config.daemon.api_token));
if !authorized {
return (StatusCode::UNAUTHORIZED, "missing or invalid API token").into_response();
}
next.run(req).await
}
/// Byte-wise `==` short-circuits on the first mismatching byte, making
/// comparison time a (weak, but real) signal of how many leading bytes of a
/// guessed token were correct — a classic timing oracle. This always
/// touches every byte of the shorter input regardless of where they first
/// differ. Real-world exposure here is low (loopback-bound by default, a
/// personal single-user daemon), but it costs nothing to close.
fn constant_time_eq(a: &str, b: &str) -> bool {
let (a, b) = (a.as_bytes(), b.as_bytes());
if a.len() != b.len() {
return false;
}
a.iter().zip(b.iter()).fold(0u8, |acc, (x, y)| acc | (x ^ y)) == 0
}
pub fn router(state: AppState) -> Router {
Router::new()
.route("/health", get(routes::health::health))
@ -179,3 +193,28 @@ pub fn router(state: AppState) -> Router {
))
.with_state(state)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn constant_time_eq_matches_identical_strings() {
assert!(constant_time_eq("secret-token", "secret-token"));
}
#[test]
fn constant_time_eq_rejects_different_strings_of_the_same_length() {
assert!(!constant_time_eq("secret-token", "secret-toke1"));
}
#[test]
fn constant_time_eq_rejects_different_lengths() {
assert!(!constant_time_eq("short", "a-much-longer-token"));
}
#[test]
fn constant_time_eq_treats_empty_strings_as_equal() {
assert!(constant_time_eq("", ""));
}
}