Something went wrong. Try again.
Our Personal Data Server from scratch!
Something went wrong. Try again.
34 kB · 969 lines
Rust
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970use serde::{Deserialize, Serialize};use std::collections::{HashMap, HashSet};
#[derive(Debug, Clone, PartialEq, Eq)]pub enum ParsedScope { Atproto, TransitionGeneric, TransitionChat, TransitionEmail, Repo(RepoScope), Blob(BlobScope), Rpc(RpcScope), Account(AccountScope), Identity(IdentityScope), Include(IncludeScope), Unknown(String),}
#[derive(Debug, Clone, PartialEq, Eq)]pub struct IncludeScope { pub nsid: String, pub aud: Option<String>,}
#[derive(Debug, Clone, PartialEq, Eq)]pub struct RepoScope { pub collection: Option<String>, pub actions: HashSet<RepoAction>,}
impl RepoScope { pub fn to_scope_string(&self) -> String { let mut actions: Vec<RepoAction> = self.actions.iter().copied().collect(); actions.sort();
let rendered: Vec<&str> = actions.iter().map(RepoAction::as_str).collect();
format!( "repo:{}?action={}", self.collection.as_deref().unwrap_or("*"), rendered.join("&action=") ) }}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]#[serde(rename_all = "lowercase")]pub enum RepoAction { Create, Update, Delete,}
impl RepoAction { pub const ALL: [RepoAction; 3] = [Self::Create, Self::Update, Self::Delete];
pub fn parse_str(s: &str) -> Option<Self> { match s { "create" => Some(Self::Create), "update" => Some(Self::Update), "delete" => Some(Self::Delete), _ => None, } }
pub fn as_str(&self) -> &'static str { match self { Self::Create => "create", Self::Update => "update", Self::Delete => "delete", } }}
#[derive(Debug, Clone, PartialEq, Eq)]pub struct BlobScope { pub accept: HashSet<String>,}
impl BlobScope { pub fn matches_mime(&self, mime: &str) -> bool { if self.accept.is_empty() || self.accept.contains("*/*") { return true; } self.accept.iter().any(|pattern| { pattern == mime || pattern.strip_suffix("/*").is_some_and(|prefix| { mime.starts_with(prefix) && mime.chars().nth(prefix.len()) == Some('/') }) }) }}
#[derive(Debug, Clone, PartialEq, Eq)]pub struct RpcScope { pub lxm: String, pub aud: Option<String>,}
#[derive(Debug, Clone, PartialEq, Eq)]pub struct AccountScope { pub attr: AccountAttr, pub action: AccountAction,}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]pub enum AccountAttr { Email, Handle, Repo, Status, Wildcard,}
#[derive(Debug, Clone, PartialEq, Eq)]pub struct IdentityScope { pub attr: IdentityAttr,}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]pub enum IdentityAttr { Handle, Wildcard,}
impl AccountAttr { pub fn parse_str(s: &str) -> Option<Self> { match s { "email" => Some(Self::Email), "handle" => Some(Self::Handle), "repo" => Some(Self::Repo), "status" => Some(Self::Status), "*" => Some(Self::Wildcard), _ => None, } }}
impl IdentityAttr { pub fn parse_str(s: &str) -> Option<Self> { match s { "handle" => Some(Self::Handle), "*" => Some(Self::Wildcard), _ => None, } }}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]pub enum AccountAction { Read, Manage,}
impl AccountAction { pub fn parse_str(s: &str) -> Option<Self> { match s { "read" => Some(Self::Read), "manage" => Some(Self::Manage), _ => None, } }}
fn parse_query_params(query: &str) -> HashMap<String, Vec<String>> { query .split('&') .filter_map(|part| part.split_once('=')) .fold(HashMap::new(), |mut acc, (key, value)| { let decoded = urlencoding::decode(value) .map(|s| s.into_owned()) .unwrap_or_else(|_| value.to_string()); acc.entry(key.to_string()).or_default().push(decoded); acc })}
/// The query parameters one scope prefix understands, and which of them may/// appear more than once.////// Every scope prefix that takes parameters must check its query against one of/// these before trusting `params`, because `parse_query_params` is lossy in/// three fail-OPEN directions — each hands back a grant BROADER than the client/// asked for, which is the one direction an authorization parser must never err/// in:////// - An **unknown** key is simply never looked up. For `repo:` an absent/// `action` means ALL THREE actions, so `?actionn=create` granted/// create+update+delete. For `blob:` an absent `accept` is an empty set,/// which `BlobScope::matches_mime` treats as every mime type, so/// `?acceptt=image/*` granted every upload./// - A **valueless** key never survives `split_once('=')`, so `?action` is/// indistinguishable from sending nothing. It cannot be seen in `params` at/// all, which is why the check runs against the RAW query string./// - A **repeated** single-valued key resolves through `.first()`, so/// `rpc:x?aud=*&aud=did:web:y` granted ANY audience — and the order deciding/// that is the client's to choose.////// Upstream nulls the whole permission in all three cases: `Parser.parse`/// returns null on a key outside its schema, and `getSingle` returns null when/// a `multiple: false` param repeats. So this does too, rather than keeping the/// part it understood.struct QuerySchema { /// Every parameter this prefix reads. A key outside this list nulls the /// scope, including a parameter upstream defines that this parser does not /// implement — accepting and ignoring one would put a permission on the /// consent screen that nothing enforces. known: &'static [&'static str], /// The subset of `known` that may repeat. A parameter belongs here only if /// this parser actually reads every value: upstream's `multiple: true` is /// not enough on its own, since collapsing a repeat to `.first()` is how /// the escalation above happens. repeatable: &'static [&'static str],}
/// `repo:`'s `collection` is positional here and never read from the query, so/// it is deliberately absent from `known`.const REPO_QUERY: QuerySchema = QuerySchema { known: &["action"], repeatable: &["action"],};
/// `blob:`'s `accept` is collected from both the positional slot and every/// query value, so it repeats.const BLOB_QUERY: QuerySchema = QuerySchema { known: &["accept"], repeatable: &["accept"],};
/// `aud` is `multiple: false` upstream. `lxm` is `multiple: true` there, but/// this parser reads only the first, so a repeat is refused rather than/// silently collapsed.const RPC_QUERY: QuerySchema = QuerySchema { known: &["lxm", "aud"], repeatable: &[],};
/// `action` is `multiple: true` upstream, but as with `rpc:`'s `lxm` only the/// first is read here.const ACCOUNT_QUERY: QuerySchema = QuerySchema { known: &["action"], repeatable: &[],};
/// `identity:` takes no query parameters at all.const IDENTITY_QUERY: QuerySchema = QuerySchema { known: &[], repeatable: &[],};
const INCLUDE_QUERY: QuerySchema = QuerySchema { known: &["aud"], repeatable: &[],};
impl QuerySchema { /// Whether `query` is one this prefix can be trusted to have parsed whole. fn accepts(&self, query: &str, params: &HashMap<String, Vec<String>>) -> bool { if query.is_empty() { return true; }
// Checked against the raw query rather than `params`, because a key // with no `=` never reaches `params` to be noticed. let every_param_known = query.split('&').all(|part| { part.split_once('=') .is_some_and(|(key, _)| self.known.contains(&key)) }); if !every_param_known { return false; }
let repeats_a_single_valued_param = self.known.iter().any(|key| { !self.repeatable.contains(key) && params.get(*key).is_some_and(|values| values.len() > 1) }); if repeats_a_single_valued_param { return false; }
// A wildcard must be written as `*`. // // `parse_query_params` decodes values, so `aud=%2A` yields a real // wildcard — while `consent.rs` describes the string the CLIENT sent. // So a user reads `rpc:app.bsky.feed.getTimeline?aud=%2A`, takes `%2A` // for a literal audience, and approves every audience. // // Deliberately NOT a ban on percent-encoding: a DID is full of colons, // and a client sending `aud=did%3Aweb%3Aabc` is being careful, not // hostile — nulling that would break conformant clients silently. Only // a value that GAINS a `*` by being decoded is refused, which catches // `%2a` as well as `%2A` and the `blob:` spellings `%2A%2F%2A` and // `image%2F%2A`. Upstream's formatter emits a wildcard as a literal // `*`, so no conformant client is nulled by this. let hides_a_wildcard = query .split('&') .filter_map(|part| part.split_once('=')) .any(|(_, raw)| { urlencoding::decode(raw) .is_ok_and(|decoded| decoded.matches('*').count() > raw.matches('*').count()) });
!hides_a_wildcard }}
/// Whether `base` names exactly `prefix`, as upstream's `isScopeStringFor`/// requires: the prefix must be the whole thing or be followed by a `:`.////// A plain `starts_with` let `blobbity` parse as a blob scope with an empty/// accept set, which `BlobScope::matches_mime` treats as EVERY mime type — so/// an unrecognised scope string granted all blob uploads.fn names_prefix(base: &str, prefix: &str) -> bool { base == prefix || base .strip_prefix(prefix) .is_some_and(|r| r.starts_with(':'))}
fn parse_repo_actions(params: &HashMap<String, Vec<String>>) -> Option<HashSet<RepoAction>> { match params.get("action") { None => Some(RepoAction::ALL.into_iter().collect()), Some(values) if values.is_empty() => None, Some(values) => values.iter().map(|s| RepoAction::parse_str(s)).collect(), }}
pub fn parse_scope(scope: &str) -> ParsedScope { match scope { "atproto" => return ParsedScope::Atproto, "transition:generic" => return ParsedScope::TransitionGeneric, "transition:chat.bsky" => return ParsedScope::TransitionChat, "transition:email" => return ParsedScope::TransitionEmail, _ => {} }
let (base, query) = scope.split_once('?').unwrap_or((scope, "")); let params = parse_query_params(query);
if let Some(rest) = base.strip_prefix("repo:") { if !REPO_QUERY.accepts(query, ¶ms) { return ParsedScope::Unknown(scope.to_string()); }
let collection = if rest == "*" || rest.is_empty() { None } else { Some(rest.to_string()) };
let Some(actions) = parse_repo_actions(¶ms) else { return ParsedScope::Unknown(scope.to_string()); };
return ParsedScope::Repo(RepoScope { collection, actions, }); }
if base == "repo" { if !REPO_QUERY.accepts(query, ¶ms) { return ParsedScope::Unknown(scope.to_string()); }
let Some(actions) = parse_repo_actions(¶ms) else { return ParsedScope::Unknown(scope.to_string()); };
return ParsedScope::Repo(RepoScope { collection: None, actions, }); }
if names_prefix(base, "blob") { if !BLOB_QUERY.accepts(query, ¶ms) { return ParsedScope::Unknown(scope.to_string()); }
let positional = base.strip_prefix("blob:").unwrap_or(""); let accept: HashSet<String> = std::iter::once(positional) .filter(|s| !s.is_empty()) .map(String::from) .chain( params .get("accept") .into_iter() .flatten() .map(String::clone), ) .collect();
return ParsedScope::Blob(BlobScope { accept }); }
if names_prefix(base, "rpc") { if !RPC_QUERY.accepts(query, ¶ms) { return ParsedScope::Unknown(scope.to_string()); }
let lxm_positional = base.strip_prefix("rpc:").map(|s| s.to_string()); let lxm = lxm_positional.or_else(|| params.get("lxm").and_then(|v| v.first().cloned())); let aud = params.get("aud").and_then(|v| v.first().cloned());
let Some(lxm) = lxm else { return ParsedScope::Unknown(scope.to_string()); }; if lxm == "*" && aud.as_deref() == Some("*") { return ParsedScope::Unknown(scope.to_string()); }
return ParsedScope::Rpc(RpcScope { lxm, aud }); }
if let Some(attr_str) = base.strip_prefix("account:") && let Some(attr) = AccountAttr::parse_str(attr_str) { if !ACCOUNT_QUERY.accepts(query, ¶ms) { return ParsedScope::Unknown(scope.to_string()); }
let action = params .get("action") .and_then(|v| v.first()) .and_then(|s| AccountAction::parse_str(s)) .unwrap_or(AccountAction::Read);
return ParsedScope::Account(AccountScope { attr, action }); }
if let Some(attr_str) = base.strip_prefix("identity:") && let Some(attr) = IdentityAttr::parse_str(attr_str) { if !IDENTITY_QUERY.accepts(query, ¶ms) { return ParsedScope::Unknown(scope.to_string()); }
return ParsedScope::Identity(IdentityScope { attr }); }
if let Some(nsid) = base.strip_prefix("include:") { if !INCLUDE_QUERY.accepts(query, ¶ms) { return ParsedScope::Unknown(scope.to_string()); }
let aud = params.get("aud").and_then(|v| v.first().cloned()); return ParsedScope::Include(IncludeScope { nsid: nsid.to_string(), aud, }); }
ParsedScope::Unknown(scope.to_string())}
pub fn parse_scope_string(scope_str: &str) -> Vec<ParsedScope> { scope_str.split_whitespace().map(parse_scope).collect()}
#[cfg(test)]mod tests { use super::*;
#[test] fn test_parse_atproto() { assert_eq!(parse_scope("atproto"), ParsedScope::Atproto); }
#[test] fn test_parse_transition_scopes() { assert_eq!( parse_scope("transition:generic"), ParsedScope::TransitionGeneric ); assert_eq!( parse_scope("transition:chat.bsky"), ParsedScope::TransitionChat ); assert_eq!( parse_scope("transition:email"), ParsedScope::TransitionEmail ); }
#[test] fn test_parse_repo_wildcard() { let scope = parse_scope("repo:*?action=create"); match scope { ParsedScope::Repo(r) => { assert!(r.collection.is_none()); assert!(r.actions.contains(&RepoAction::Create)); assert!(!r.actions.contains(&RepoAction::Update)); } _ => panic!("Expected Repo scope"), } }
#[test] fn test_parse_repo_collection() { let scope = parse_scope("repo:app.bsky.feed.post?action=create&action=delete"); match scope { ParsedScope::Repo(r) => { assert_eq!(r.collection, Some("app.bsky.feed.post".to_string())); assert!(r.actions.contains(&RepoAction::Create)); assert!(r.actions.contains(&RepoAction::Delete)); assert!(!r.actions.contains(&RepoAction::Update)); } _ => panic!("Expected Repo scope"), } }
#[test] fn test_parse_repo_no_actions_means_all() { let scope = parse_scope("repo:app.bsky.feed.post"); match scope { ParsedScope::Repo(r) => { assert!(r.actions.contains(&RepoAction::Create)); assert!(r.actions.contains(&RepoAction::Update)); assert!(r.actions.contains(&RepoAction::Delete)); } _ => panic!("Expected Repo scope"), } }
#[test] fn test_parse_repo_unrecognized_action_is_not_a_repo_scope() { assert!(matches!( parse_scope("repo:app.bsky.feed.post?action=read"), ParsedScope::Unknown(_) )); assert!(matches!( parse_scope("repo:app.bsky.feed.post?action="), ParsedScope::Unknown(_) )); assert!(matches!( parse_scope("repo?action=read"), ParsedScope::Unknown(_) )); }
#[test] fn test_parse_blob_wildcard() { let scope = parse_scope("blob:*/*"); match scope { ParsedScope::Blob(b) => { assert!(b.accept.contains("*/*")); assert!(b.matches_mime("image/png")); assert!(b.matches_mime("video/mp4")); } _ => panic!("Expected Blob scope"), } }
#[test] fn test_parse_blob_specific() { let scope = parse_scope("blob?accept=image/*&accept=video/*"); match scope { ParsedScope::Blob(b) => { assert!(b.matches_mime("image/png")); assert!(b.matches_mime("image/jpeg")); assert!(b.matches_mime("video/mp4")); assert!(!b.matches_mime("text/plain")); } _ => panic!("Expected Blob scope"), } }
#[test] fn test_parse_rpc() { let scope = parse_scope("rpc:app.bsky.feed.getTimeline?aud=did:web:api.bsky.app"); match scope { ParsedScope::Rpc(r) => { assert_eq!(r.lxm, "app.bsky.feed.getTimeline"); assert_eq!(r.aud, Some("did:web:api.bsky.app".to_string())); } _ => panic!("Expected Rpc scope"), } }
#[test] fn test_parse_account() { let scope = parse_scope("account:email?action=read"); match scope { ParsedScope::Account(a) => { assert_eq!(a.attr, AccountAttr::Email); assert_eq!(a.action, AccountAction::Read); } _ => panic!("Expected Account scope"), }
let scope2 = parse_scope("account:repo?action=manage"); match scope2 { ParsedScope::Account(a) => { assert_eq!(a.attr, AccountAttr::Repo); assert_eq!(a.action, AccountAction::Manage); } _ => panic!("Expected Account scope"), } }
#[test] fn test_parse_scope_string() { let scopes = parse_scope_string("atproto repo:*?action=create blob:*/*"); assert_eq!(scopes.len(), 3); assert_eq!(scopes[0], ParsedScope::Atproto); match &scopes[1] { ParsedScope::Repo(_) => {} _ => panic!("Expected Repo"), } match &scopes[2] { ParsedScope::Blob(_) => {} _ => panic!("Expected Blob"), } }
#[test] fn test_parse_include() { let scope = parse_scope("include:app.bsky.authFullApp?aud=did:web:api.bsky.app"); match scope { ParsedScope::Include(i) => { assert_eq!(i.nsid, "app.bsky.authFullApp"); assert_eq!(i.aud, Some("did:web:api.bsky.app".to_string())); } _ => panic!("Expected Include scope"), }
let scope2 = parse_scope("include:com.example.authBasicFeatures"); match scope2 { ParsedScope::Include(i) => { assert_eq!(i.nsid, "com.example.authBasicFeatures"); assert_eq!(i.aud, None); } _ => panic!("Expected Include scope"), } }
#[test] fn test_parse_identity() { let scope = parse_scope("identity:handle"); match scope { ParsedScope::Identity(i) => { assert_eq!(i.attr, IdentityAttr::Handle); } _ => panic!("Expected Identity scope"), }
let scope2 = parse_scope("identity:*"); match scope2 { ParsedScope::Identity(i) => { assert_eq!(i.attr, IdentityAttr::Wildcard); } _ => panic!("Expected Identity scope"), } }
#[test] fn test_parse_account_status() { let scope = parse_scope("account:status?action=read"); match scope { ParsedScope::Account(a) => { assert_eq!(a.attr, AccountAttr::Status); assert_eq!(a.action, AccountAction::Read); } _ => panic!("Expected Account scope"), } }
#[test] fn test_rpc_wildcard_aud_forbidden() { let scope = parse_scope("rpc:*?aud=*"); assert!(matches!(scope, ParsedScope::Unknown(_)));
let scope2 = parse_scope("rpc?aud=*"); assert!(matches!(scope2, ParsedScope::Unknown(_)));
let scope3 = parse_scope("rpc:app.bsky.feed.getTimeline?aud=*"); assert!(matches!(scope3, ParsedScope::Rpc(_)));
let scope4 = parse_scope("rpc:*?aud=did:web:api.bsky.app"); assert!(matches!(scope4, ParsedScope::Rpc(_))); }
#[test] fn test_rpc_lxm_required() { let bare = parse_scope("rpc"); assert!(matches!(bare, ParsedScope::Unknown(_))); }
#[test] fn test_url_encoded_aud_with_fragment() { let scope = parse_scope("include:app.bsky.authFullApp?aud=did:web:api.bsky.app%23bsky_appview"); match scope { ParsedScope::Include(i) => { assert_eq!(i.nsid, "app.bsky.authFullApp"); assert_eq!(i.aud, Some("did:web:api.bsky.app#bsky_appview".to_string())); } _ => panic!("Expected Include scope"), }
let scope2 = parse_scope( "rpc:com.atproto.moderation.createReport?aud=did:web:api.bsky.app%23bsky_appview", ); match scope2 { ParsedScope::Rpc(r) => { assert_eq!(r.lxm, "com.atproto.moderation.createReport"); assert_eq!(r.aud, Some("did:web:api.bsky.app#bsky_appview".to_string())); } _ => panic!("Expected Rpc scope"), } }
/// An unknown, valueless or repeated `repo:` param nulls the whole grant. /// /// This is the worst of the fail-OPEN cases, because an ABSENT `action` /// means ALL THREE actions (`parse_repo_actions`). So a key that fails to /// arrive is not merely ignored — it WIDENS the grant: /// `?actionn=create` was never looked up and `?action` never survived /// `split_once('=')`, and both yielded create+update+delete where the /// client asked for create alone and the reference grants nothing at all. /// /// Asserted as `Unknown` rather than "not all three actions", because a /// fix that narrowed to some other action set would still be wrong. #[test] fn an_unknown_or_valueless_repo_param_nulls_the_grant() { for bad in [ // The near-miss key that motivated this: silently dropped, so // `action` read as absent, so every action was granted. "repo:app.bsky.feed.post?actionn=create", "repo?actionn=create", // A key with no `=` never reaches `params` at all, so it can only // be caught against the raw query string. "repo:app.bsky.feed.post?action", "repo?action", // A recognised param sitting beside a bad one must NOT survive. "repo:app.bsky.feed.post?action=create&orphan", "repo:app.bsky.feed.post?action=create&unexpected=1", "repo:*?action=create&actionn=delete", // `collection` is a REFERENCE param we do not implement — it is // positional only here. Accepting and ignoring it would put // `collection=com.example.other` on the consent screen while // granting the positional collection instead. "repo:app.bsky.feed.post?collection=com.example.other", "repo?collection=com.example.other", ] { assert!( matches!(parse_scope(bad), ParsedScope::Unknown(_)), "{bad} must null the grant, got {:?}", parse_scope(bad) ); }
// `action` is `multiple: true` in the reference, so repeating it is how // a client asks for several and must stay legal. Over-tightening here // would break every real multi-action grant. let ParsedScope::Repo(r) = parse_scope("repo:app.bsky.feed.post?action=create&action=delete") else { panic!("repeating `action` must stay legal"); }; assert_eq!(r.actions.len(), 2); assert!(r.actions.contains(&RepoAction::Create)); assert!(r.actions.contains(&RepoAction::Delete)); }
/// `rpc:`'s single-valued params may not repeat, and unknown or valueless /// ones null the grant. /// /// `aud` is `multiple: false` in the reference, so `getSingle` nulls on a /// repeat. Here it resolved through `.first()`, which means /// `?aud=*&aud=did:web:y` granted ANY audience — and the client chose the /// order that decided it. `lxm` is `multiple: true` upstream, but this /// parser only ever reads the first, so repeating it is rejected rather /// than silently collapsed to whichever came first. #[test] fn a_repeated_unknown_or_valueless_rpc_param_nulls_the_grant() { for bad in [ // Both orders, because the bug was that the first silently won. "rpc:app.bsky.feed.getTimeline?aud=*&aud=did:web:api.bsky.app", "rpc:app.bsky.feed.getTimeline?aud=did:web:api.bsky.app&aud=*", "rpc?lxm=*&lxm=app.bsky.feed.getTimeline&aud=did:web:api.bsky.app", "rpc?lxm=app.bsky.feed.getTimeline&lxm=*&aud=did:web:api.bsky.app", // Unknown and valueless keys. "rpc:app.bsky.feed.getTimeline?audd=did:web:api.bsky.app", "rpc:app.bsky.feed.getTimeline?aud", "rpc:app.bsky.feed.getTimeline?aud=did:web:api.bsky.app&orphan", "rpc:app.bsky.feed.getTimeline?aud=did:web:api.bsky.app&unexpected=1", ] { assert!( matches!(parse_scope(bad), ParsedScope::Unknown(_)), "{bad} must null the grant, got {:?}", parse_scope(bad) ); } }
/// A wildcard must be written as `*`, not as its percent-encoded spelling. /// /// `parse_query_params` decodes values, while `consent.rs` describes the /// string the CLIENT sent (`described` is the raw scope). So a user reads /// `rpc:app.bsky.feed.getTimeline?aud=%2A`, takes `%2A` for a literal /// audience, and approves EVERY audience. /// /// Deliberately not a ban on percent-encoding: a DID is full of colons and /// a client sending `aud=did%3Aweb%3Aapi.bsky.app` is being careful, not /// hostile. Only a value that gains a `*` by being decoded is refused, so /// `%2a` is caught as well as `%2A`. Upstream's own formatter emits a /// wildcard as a literal `*` (`URLSearchParams` does not encode it), so no /// conformant client is nulled by this. #[test] fn an_encoded_wildcard_nulls_the_grant() { for bad in [ "rpc:app.bsky.feed.getTimeline?aud=%2A", "rpc:app.bsky.feed.getTimeline?aud=%2a", "rpc?lxm=%2A&aud=did:web:api.bsky.app", // The blob wildcard is `*/*`, so the encoded spelling hides a // grant over every mime type. "blob?accept=%2A%2F%2A", "blob?accept=image%2F%2A", ] { assert!( matches!(parse_scope(bad), ParsedScope::Unknown(_)), "{bad} must null the grant, got {:?}", parse_scope(bad) ); }
// An encoded DID is a careful client, not a hostile one. let ParsedScope::Rpc(r) = parse_scope("rpc:app.bsky.feed.getTimeline?aud=did%3Aweb%3Aapi.bsky.app") else { panic!("an encoded DID must still parse"); }; assert_eq!(r.aud, Some("did:web:api.bsky.app".to_string()));
// And a literal wildcard is still the supported spelling. assert!(matches!( parse_scope("rpc:app.bsky.feed.getTimeline?aud=*"), ParsedScope::Rpc(_) )); }
/// An unknown or valueless `blob:` param nulls the grant rather than /// granting every mime type. /// /// `blob` with no `accept` at all is an EMPTY accept set, and /// `BlobScope::matches_mime` returns true for everything when the set is /// empty (see `test_blob_empty_accept`, which pins that on purpose). So /// `blob?acceptt=image/*` was not merely ignored — it granted EVERY mime /// type, making `blob:` fail open just as hard as `repo:`. #[test] fn an_unknown_or_valueless_blob_param_nulls_the_grant() { for bad in [ "blob?acceptt=image/*", "blob?accept", "blob?accept=image/*&orphan", "blob?accept=image/*&unexpected=1", "blob:image/*?acceptt=video/*", ] { assert!( matches!(parse_scope(bad), ParsedScope::Unknown(_)), "{bad} must null the grant, got {:?}", parse_scope(bad) ); }
// `accept` is `multiple: true` upstream and this parser collects every // value, so repeating it must stay legal. let ParsedScope::Blob(b) = parse_scope("blob?accept=image/*&accept=video/*") else { panic!("repeating `accept` must stay legal"); }; assert_eq!(b.accept.len(), 2); }
/// `account:`, `identity:` and `include:` default NARROW, so a dropped key /// never widened them — but the key is still checked, so the same typo is /// not quietly accepted in one place and refused in another. /// /// `account:`'s `action` is the interesting one: it resolved through /// `.first()`, so `?action=read&action=manage` and `?action=manage&action=read` /// disagreed about whether `manage` was granted, with the client choosing. #[test] fn narrow_defaulting_prefixes_still_check_their_params() { for bad in [ // account: `action` is single-valued here even though it is // `multiple: true` upstream, because only the first is ever read. "account:email?action=read&action=manage", "account:email?action=manage&action=read", "account:email?actionn=manage", "account:email?action", "account:email?action=read&unexpected=1", // identity: takes no query params at all. "identity:handle?action=read", "identity:handle?orphan", // include: `aud` is `multiple: false` upstream. "include:app.bsky.authFullApp?aud=did:web:a&aud=did:web:b", "include:app.bsky.authFullApp?audd=did:web:a", "include:app.bsky.authFullApp?aud", ] { assert!( matches!(parse_scope(bad), ParsedScope::Unknown(_)), "{bad} must null the grant, got {:?}", parse_scope(bad) ); }
// The ordinary forms are untouched. assert!(matches!( parse_scope("account:email?action=manage"), ParsedScope::Account(_) )); assert!(matches!( parse_scope("identity:handle"), ParsedScope::Identity(_) )); assert!(matches!( parse_scope("include:app.bsky.authFullApp?aud=did:web:api.bsky.app"), ParsedScope::Include(_) ));
// And an absent `action` still means `read`, the narrow default. let ParsedScope::Account(a) = parse_scope("account:email") else { panic!("expected an account scope"); }; assert_eq!(a.action, AccountAction::Read); }
/// A prefix must end at a `:` or a `?`, as upstream's `isScopeStringFor` /// requires. /// /// `blob` and `rpc` were matched with `starts_with`, so `blobbity` parsed /// as a blob scope with an EMPTY accept set — which `matches_mime` treats /// as every mime type. An unrecognised scope string therefore granted all /// blob uploads. `rpcish?lxm=…` was the same shape for RPC. #[test] fn a_prefix_must_end_at_a_colon_or_question_mark() { for bad in [ "blobbity", "blobbity?accept=image/*", "blobs:image/*", "rpcish?lxm=app.bsky.feed.getTimeline", "rpcish:app.bsky.feed.getTimeline", "repository:app.bsky.feed.post", ] { assert!( matches!(parse_scope(bad), ParsedScope::Unknown(_)), "{bad} must not be recognised, got {:?}", parse_scope(bad) ); }
// The real prefixes, in all three permitted shapes. assert!(matches!(parse_scope("blob"), ParsedScope::Blob(_))); assert!(matches!(parse_scope("blob:image/*"), ParsedScope::Blob(_))); assert!(matches!( parse_scope("blob?accept=image/*"), ParsedScope::Blob(_) )); assert!(matches!( parse_scope("rpc:app.bsky.feed.getTimeline"), ParsedScope::Rpc(_) )); assert!(matches!( parse_scope("rpc?lxm=app.bsky.feed.getTimeline"), ParsedScope::Rpc(_) )); }}