Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
88 changes: 81 additions & 7 deletions crates/aitesis/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,15 @@ pub trait RequestService: Send + Sync {
) -> Result<MediaRequest, AitesisError>;

/// Returns a single request by ID.
async fn get_request(&self, request_id: RequestId) -> Result<MediaRequest, AitesisError>;
///
/// Authorization: admins may read any request; members may only read
/// their own (a non-owner member is rejected with
/// [`AitesisError::InsufficientPermission`]).
async fn get_request(
&self,
request_id: RequestId,
caller_id: UserId,
) -> Result<MediaRequest, AitesisError>;

/// Lists requests, optionally filtered by user or status, windowed by
/// `limit`/`offset` (newest first).
Expand Down Expand Up @@ -235,16 +243,33 @@ where
approval::deny_request(&self.write, request_id, admin_id, role, reason).await
}

#[instrument(skip(self), fields(request_id = %request_id))]
async fn get_request(&self, request_id: RequestId) -> Result<MediaRequest, AitesisError> {
repo::get_request(&self.read, &request_id)
#[instrument(skip(self), fields(request_id = %request_id, caller_id = %caller_id))]
async fn get_request(
&self,
request_id: RequestId,
caller_id: UserId,
) -> Result<MediaRequest, AitesisError> {
let request = repo::get_request(&self.read, &request_id)
.await?
.ok_or_else(|| {
RequestNotFoundSnafu {
id: request_id.to_string(),
}
.build()
})
})?;

// WHY: same ownership boundary as cancel_request — a member reading
// another user's request by UUID is an IDOR (title, decided_by,
// deny_reason, want_id all leak).
let role = self.user_roles.role_of(caller_id).await?;
let is_owner = request.user_id == caller_id;
let is_admin = role == UserRole::Admin;

if !is_owner && !is_admin {
return InsufficientPermissionSnafu.fail();
}

Ok(request)
}

#[instrument(skip(self), fields(caller_id = %caller_id))]
Expand Down Expand Up @@ -613,7 +638,7 @@ mod tests {

svc.cancel_request(req.id, user_id).await.unwrap();

let result = svc.get_request(req.id).await;
let result = svc.get_request(req.id, user_id).await;
assert!(matches!(result, Err(AitesisError::RequestNotFound { .. })));
}

Expand Down Expand Up @@ -1126,7 +1151,56 @@ mod tests {
.await
.unwrap();

let fulfilled = admin_svc.get_request(req.id).await.unwrap();
let fulfilled = admin_svc.get_request(req.id, admin_id).await.unwrap();
assert_eq!(fulfilled.status, RequestStatus::Fulfilled);
}

// ── Get tests ─────────────────────────────────────────────────────────────

#[tokio::test]
async fn owner_gets_own_request() {
let (svc, _pool) = make_service(UserRole::Member).await;
let user_id = UserId::new();
let req = svc.submit_request(user_id, music_input()).await.unwrap();

let fetched = svc.get_request(req.id, user_id).await.unwrap();
assert_eq!(fetched.id, req.id);
assert_eq!(fetched.user_id, user_id);
}

#[tokio::test]
async fn member_cannot_get_other_user_request() {
let (svc, _pool) = make_service(UserRole::Member).await;
let alice = UserId::new();
let bob = UserId::new();
let req = svc.submit_request(alice, music_input()).await.unwrap();

let err = svc.get_request(req.id, bob).await.unwrap_err();
assert!(matches!(err, AitesisError::InsufficientPermission { .. }));
}

#[tokio::test]
async fn admin_gets_any_user_request() {
let (member_svc, pool) = make_service(UserRole::Member).await;
let alice = UserId::new();
let req = member_svc
.submit_request(alice, music_input())
.await
.unwrap();

let admin_svc = AitesisServiceImpl::new(
pool.clone(),
pool.clone(),
default_config(),
MockRoles {
role: UserRole::Admin,
},
AlwaysValidIdentity,
AlwaysCreateMonitor,
);
let admin_id = UserId::new();
let fetched = admin_svc.get_request(req.id, admin_id).await.unwrap();
assert_eq!(fetched.id, req.id);
assert_eq!(fetched.user_id, alice);
}
}
8 changes: 7 additions & 1 deletion crates/archon/src/serve.rs
Original file line number Diff line number Diff line change
Expand Up @@ -368,9 +368,15 @@ impl DynRequestService for RequestAdapter {
fn get_request(
&self,
request_id: themelion::RequestId,
caller_id: themelion::UserId,
) -> RequestServiceFut<'_, aitesis::MediaRequest> {
let service = Arc::clone(&self.0);
Box::pin(async move { service.get_request(request_id).await.map_err(Into::into) })
Box::pin(async move {
service
.get_request(request_id, caller_id)
.await
.map_err(Into::into)
})
}

fn list_requests(
Expand Down
8 changes: 7 additions & 1 deletion crates/archon/tests/acquisition_integration.rs
Original file line number Diff line number Diff line change
Expand Up @@ -231,9 +231,15 @@ impl DynRequestService for MockRequestAdapter {
fn get_request(
&self,
request_id: themelion::RequestId,
caller_id: themelion::UserId,
) -> RequestServiceFut<'_, aitesis::MediaRequest> {
let service = Arc::clone(&self.0);
Box::pin(async move { service.get_request(request_id).await.map_err(Into::into) })
Box::pin(async move {
service
.get_request(request_id, caller_id)
.await
.map_err(Into::into)
})
}

fn list_requests(
Expand Down
1 change: 1 addition & 0 deletions crates/paroche/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ pub mod error;
pub mod middleware;
pub mod net_validate;
pub mod opds;
pub mod redact;
pub mod response;
pub mod routes;
pub mod state;
Expand Down
178 changes: 178 additions & 0 deletions crates/paroche/src/redact.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,178 @@
//! Credential redaction for indexer URLs leaving the API boundary.

/// Replacement value for redacted credential query parameters.
const REDACTED: &str = "REDACTED";

/// Query keys that are credentials when matched exactly (case-insensitive).
///
/// `r` is the conventional Torznab passkey parameter; `api` is used bare by
/// several Newznab indexers.
const EXACT_CREDENTIAL_KEYS: &[&str] = &["r", "api"];

/// Substrings that mark a query key as credential-bearing (case-insensitive).
///
/// Covers the Torznab/Newznab conventions: `apikey`, `api_key`, `passkey`,
/// `torrent_pass`, `authkey`, `auth_key`, `token`, `secret`, and variants.
/// Over-matching is deliberate — a redacted-but-harmless parameter costs
/// nothing, a leaked passkey compromises a private-tracker account.
const CREDENTIAL_KEY_SUBSTRINGS: &[&str] = &["key", "pass", "token", "secret", "auth"];

fn is_credential_key(key: &str) -> bool {
let key = key.to_ascii_lowercase();
EXACT_CREDENTIAL_KEYS.contains(&key.as_str())
|| CREDENTIAL_KEY_SUBSTRINGS
.iter()
.any(|marker| key.contains(marker))
}

/// Redacts credential-bearing query parameter values from a download URL.
///
/// Torznab/Newznab download URLs embed the indexer `apikey`/`passkey` in the
/// query string; returning them raw hands the operator's private-tracker
/// credentials to any authenticated member. Only parameter VALUES are
/// replaced — keys, ordering, separators, and the fragment survive, so the
/// URL stays recognizable in the UI.
///
/// The transformation is purely lexical (split on `#`, `?`, `&`, `=`), so a
/// URL the `url` crate would reject (for example a magnet URI) is still
/// redacted rather than passed through raw.
#[must_use]
pub fn redact_download_url(url: &str) -> String {
let (head, fragment) = match url.split_once('#') {
Some((head, fragment)) => (head, Some(fragment)),
None => (url, None),
};
let Some((base, query)) = head.split_once('?') else {
return url.to_string();
};

let redacted_query = query
.split('&')
.map(|pair| match pair.split_once('=') {
Some((key, _)) if is_credential_key(key) => format!("{key}={REDACTED}"),
_ => pair.to_string(),
})
.collect::<Vec<_>>()
.join("&");

match fragment {
Some(fragment) => format!("{base}?{redacted_query}#{fragment}"),
None => format!("{base}?{redacted_query}"),
}
}

/// Recursively redacts every `download_url` string field in a JSON value.
///
/// Search results flow through paroche as opaque `serde_json::Value` trees
/// (indexer -> zetesis -> route), so the redaction walks the tree instead of
/// a typed response struct.
pub fn redact_download_urls_in_json(value: &mut serde_json::Value) {
match value {
serde_json::Value::Object(map) => {
for (key, entry) in map.iter_mut() {
if key == "download_url" {
if let serde_json::Value::String(url) = entry {
*url = redact_download_url(url);
}
} else {
redact_download_urls_in_json(entry);
}
}
}
serde_json::Value::Array(items) => {
for item in items.iter_mut() {
redact_download_urls_in_json(item);
}
}
_ => {}
}
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn redacts_apikey_value_keeping_structure() {
assert_eq!(
redact_download_url("https://indexer.example/api?t=get&id=42&apikey=SECRET"),
"https://indexer.example/api?t=get&id=42&apikey=REDACTED"
);
}

#[test]
fn redacts_all_conventional_credential_keys() {
for key in [
"apikey",
"api_key",
"passkey",
"authkey",
"auth_key",
"token",
"secret",
"r",
"torrent_pass",
"password",
] {
let url = format!("https://indexer.example/dl?{key}=SECRET&file=x.torrent");
let redacted = redact_download_url(&url);
assert_eq!(
redacted,
format!("https://indexer.example/dl?{key}=REDACTED&file=x.torrent"),
"{key} must be redacted"
);
}
}

#[test]
fn matches_keys_case_insensitively() {
assert_eq!(
redact_download_url("https://indexer.example/dl?ApiKey=SECRET"),
"https://indexer.example/dl?ApiKey=REDACTED"
);
}

#[test]
fn keeps_non_credential_params_and_fragment() {
assert_eq!(
redact_download_url("https://indexer.example/dl?t=get&id=42#frag"),
"https://indexer.example/dl?t=get&id=42#frag"
);
}

#[test]
fn leaves_query_free_urls_untouched() {
assert_eq!(
redact_download_url("https://indexer.example/dl/42.torrent"),
"https://indexer.example/dl/42.torrent"
);
}

#[test]
fn redacts_magnet_uri_credentials_without_touching_xt() {
assert_eq!(
redact_download_url("magnet:?xt=urn:btih:abc123&passkey=SECRET"),
"magnet:?xt=urn:btih:abc123&passkey=REDACTED"
);
}

#[test]
fn json_walk_redacts_nested_download_urls_only() {
let mut value = serde_json::json!({
"results": [{
"title": "Album",
"download_url": "https://indexer.example/dl?apikey=SECRET",
"info_url": "https://indexer.example/details/42"
}]
});
redact_download_urls_in_json(&mut value);
assert_eq!(
value["results"][0]["download_url"],
"https://indexer.example/dl?apikey=REDACTED"
);
assert_eq!(
value["results"][0]["info_url"],
"https://indexer.example/details/42"
);
}
}
Loading