fix(proxy): limit audience of getFeed service-auth to the feed generator

This commit is contained in:
ave
2026-06-04 22:39:14 +03:00
committed by Tangled
parent ffce1d5d05
commit 91999819c6
2 changed files with 233 additions and 2 deletions
+48 -2
View File
@@ -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<String> {
#[derive(serde::Deserialize)]
struct GetFeedQuery {
feed: String,
}
let feed = serde_urlencoded::from_str::<GetFeedQuery>(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) => {
+185
View File
@@ -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<Mutex<Option<String>>>);
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<Mutex<Option<String>>> = 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()
);
}