diff --git a/crates/tranquil-pds/src/api/proxy.rs b/crates/tranquil-pds/src/api/proxy.rs index 5c53005..efdf723 100644 --- a/crates/tranquil-pds/src/api/proxy.rs +++ b/crates/tranquil-pds/src/api/proxy.rs @@ -109,6 +109,35 @@ fn is_protected_method(method: &str) -> bool { PROTECTED_METHODS.contains(method) } +/// Fetch the `feed` generator record from the AppView and return its `did`. +async fn resolve_feed_generator_did(appview_url: &str, query: Option<&str>) -> Option { + #[derive(serde::Deserialize)] + struct GetFeedQuery { + feed: String, + } + + let feed = serde_urlencoded::from_str::(query?) + .ok()? + .feed; + let at_uri = crate::types::AtUri::new(feed).ok()?; + let repo = at_uri.did()?; + let collection = at_uri.collection()?; + let rkey = at_uri.rkey()?; + + let resp = proxy_client() + .get(format!("{appview_url}/xrpc/com.atproto.repo.getRecord")) + .query(&[("repo", repo), ("collection", collection), ("rkey", rkey)]) + .send() + .await + .ok()?; + if !resp.status().is_success() { + warn!(status = %resp.status(), "getFeed proxy: getRecord for feed generator failed"); + return None; + } + let body: serde_json::Value = resp.json().await.ok()?; + body.get("value")?.get("did")?.as_str().map(str::to_string) +} + pub struct XrpcProxyLayer { state: AppState, } @@ -287,10 +316,27 @@ async fn proxy_handler( }, }; + // getFeed must be audienced to the feed generator, not the AppView. + let (token_aud, token_lxm) = if method == "app.bsky.feed.getFeed" { + match resolve_feed_generator_did(&resolved.url, query.as_deref()).await { + Some(feed_did) => (feed_did, "app.bsky.feed.getFeedSkeleton"), + None => { + warn!( + "getFeed proxy: could not resolve feed generator DID; refusing \ + to mint an AppView-audienced token" + ); + return ApiError::InvalidRequest("Could not resolve feed".into()) + .into_response(); + } + } + } else { + (resolved.did.clone(), method) + }; + match crate::auth::create_service_token( &auth_user.did, - &resolved.did, - Some(method), + &token_aud, + Some(token_lxm), &key_bytes, ) { Ok(new_token) => { diff --git a/crates/tranquil-pds/tests/feed_proxy_aud.rs b/crates/tranquil-pds/tests/feed_proxy_aud.rs new file mode 100644 index 0000000..c699dfc --- /dev/null +++ b/crates/tranquil-pds/tests/feed_proxy_aud.rs @@ -0,0 +1,185 @@ +mod common; + +use base64::Engine; +use base64::engine::general_purpose::URL_SAFE_NO_PAD; +use common::*; +use reqwest::StatusCode; +use serde_json::{Value, json}; +use std::sync::{Arc, Mutex}; +use wiremock::matchers::{method, path}; +use wiremock::{Mock, MockServer, Request, Respond, ResponseTemplate}; + +fn decode_jwt_claims(jwt: &str) -> Value { + let payload = jwt + .split('.') + .nth(1) + .expect("malformed jwt: no claims segment"); + let bytes = URL_SAFE_NO_PAD + .decode(payload) + .expect("malformed jwt: claims not base64url"); + serde_json::from_slice(&bytes).expect("malformed jwt: claims not json") +} + +struct CaptureAuth(Arc>>); + +impl Respond for CaptureAuth { + fn respond(&self, req: &Request) -> ResponseTemplate { + let auth = req + .headers + .get("authorization") + .and_then(|v| v.to_str().ok()) + .map(str::to_string); + *self.0.lock().unwrap() = auth; + ResponseTemplate::new(200).set_body_json(json!({ "feed": [] })) + } +} + +/// getFeed's service-auth token must be audienced to the feed generator, not the AppView. +#[tokio::test] +async fn get_feed_service_auth_is_audienced_to_feed_generator() { + let client = client(); + let (token, _did) = create_account_and_login(&client).await; + + // One mock server doubles as the AppView: did:web doc, getRecord, and getFeed. + let appview = MockServer::start().await; + let appview_uri = appview.uri(); + let host = appview_uri + .strip_prefix("http://") + .expect("mock uri should be http"); + // Literal-colon host so did:web resolves over http to the local mock. + let appview_did = format!("did:web:{host}"); + + Mock::given(method("GET")) + .and(path("/.well-known/did.json")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "id": appview_did, + "service": [{ + "id": "#bsky_appview", + "type": "BskyAppView", + "serviceEndpoint": appview_uri, + }] + }))) + .mount(&appview) + .await; + + let feed_did = "did:web:feedgen.example.com"; + let feed_uri = "at://did:plc:feedcreator00000000000000/app.bsky.feed.generator/myfeed"; + + // The feed generator record resolves to its service DID. + Mock::given(method("GET")) + .and(path("/xrpc/com.atproto.repo.getRecord")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "uri": feed_uri, + "value": { "$type": "app.bsky.feed.generator", "did": feed_did }, + }))) + .mount(&appview) + .await; + + // Capture the Authorization header the AppView is handed for getFeed. + let captured: Arc>> = Arc::new(Mutex::new(None)); + Mock::given(method("GET")) + .and(path("/xrpc/app.bsky.feed.getFeed")) + .respond_with(CaptureAuth(captured.clone())) + .mount(&appview) + .await; + + // Send the params a real client sends, so feed extraction must ignore extras. + let res = client + .get(format!("{}/xrpc/app.bsky.feed.getFeed", base_url().await)) + .query(&[("feed", feed_uri), ("limit", "30"), ("cursor", "abc123")]) + .header("authorization", format!("Bearer {}", token)) + .header("atproto-proxy", format!("{}#bsky_appview", appview_did)) + .send() + .await + .expect("getFeed proxy request failed"); + assert_eq!( + res.status(), + StatusCode::OK, + "getFeed proxy should succeed: {:?}", + res.text().await + ); + + let auth = captured + .lock() + .unwrap() + .clone() + .expect("AppView received no Authorization header"); + let jwt = auth + .strip_prefix("Bearer ") + .expect("forwarded auth should be a bearer token"); + let claims = decode_jwt_claims(jwt); + + assert_eq!( + claims["aud"].as_str(), + Some(feed_did), + "service-auth token must be audienced to the feed generator, got {:?}", + claims["aud"] + ); + assert_eq!( + claims["lxm"].as_str(), + Some("app.bsky.feed.getFeedSkeleton"), + "service-auth token lxm must be getFeedSkeleton, got {:?}", + claims["lxm"] + ); +} + +/// An unresolvable feed generator must be refused, not forwarded with an AppView aud. +#[tokio::test] +async fn get_feed_refuses_when_feed_generator_unresolvable() { + let client = client(); + let (token, _did) = create_account_and_login(&client).await; + + let appview = MockServer::start().await; + let appview_uri = appview.uri(); + let host = appview_uri + .strip_prefix("http://") + .expect("mock uri should be http"); + let appview_did = format!("did:web:{host}"); + + Mock::given(method("GET")) + .and(path("/.well-known/did.json")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "id": appview_did, + "service": [{ + "id": "#bsky_appview", + "type": "BskyAppView", + "serviceEndpoint": appview_uri, + }] + }))) + .mount(&appview) + .await; + + // getRecord fails, so the feed generator DID can't be resolved. + Mock::given(method("GET")) + .and(path("/xrpc/com.atproto.repo.getRecord")) + .respond_with(ResponseTemplate::new(404).set_body_json(json!({ + "error": "RecordNotFound", + }))) + .mount(&appview) + .await; + + // getFeed must never be reached with an AppView-audienced token. + Mock::given(method("GET")) + .and(path("/xrpc/app.bsky.feed.getFeed")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ "feed": [] }))) + .expect(0) + .mount(&appview) + .await; + + let feed_uri = "at://did:plc:feedcreator00000000000000/app.bsky.feed.generator/myfeed"; + let res = client + .get(format!("{}/xrpc/app.bsky.feed.getFeed", base_url().await)) + .query(&[("feed", feed_uri)]) + .header("authorization", format!("Bearer {token}")) + .header("atproto-proxy", format!("{appview_did}#bsky_appview")) + .send() + .await + .expect("getFeed proxy request failed"); + + assert_eq!( + res.status(), + StatusCode::BAD_REQUEST, + "unresolvable feed should be rejected, got {}", + res.status() + ); +}