Compare commits

..
Author SHA1 Message Date
nelind 8a168f1daa fix(api): dont do rotation key validation in signPlcOperation as it blocks migrations 2026-08-29 02:43:11 +02:00
TrezyandTangled 1866d2bf0e test: i dunno what youre talking about i would never leave prints in production code 🙃
Signed-off-by: Trezy <tre@trezy.com>
2026-08-28 20:28:54 +00:00
TrezyandTangled dc2f924130 fix: add transition: scopes to owner-level delegates
Signed-off-by: Trezy <tre@trezy.com>
2026-08-28 20:28:54 +00:00
TrezyandTangled 97224551bf test: verify taxonomy entries parse to the correct type
Signed-off-by: Trezy <tre@trezy.com>
2026-08-28 20:28:54 +00:00
TrezyandTangled 68ae485a52 fix: restore rpc: scopes for delegation
Signed-off-by: Trezy <tre@trezy.com>
2026-08-28 20:28:54 +00:00
Johanna LarssonandTangled 228c1bbbf5 Normalize the allow private IPs flag application 2026-08-28 12:36:47 +00:00
Johanna LarssonandTangled 093484388f Allow private IPs in dev
Running tranquil with `just run-dev` using `pds.test` seems to be broken due to a recent change that blocks requests to private ips. Inside the compose network `pds.test` resolves to the traefik container's private IP. So it can't make requests to stuff like `https://pds.test/oauth-client-metadata.json` or the local plc.

Introduces a new flag, default off, that allows connecting to private IPs, set to true for the dev compose.
2026-08-28 12:36:47 +00:00
Johanna LarssonandTangled 0f0c50f7d4 Loosen CORS header requirements
Bluesky started sending `x-bsky-is-beta-user` for some users on XRPC requests, but tranquil has a strict allowlist of CORS headers. The spec doesn't (?) specify any requirements around CORS headers, so we can avoid trouble when Bluesky make changes by just allowing all headers.

This PR replaces the allowlist with the request mirror behavior, where any headers sent on a CORS request are echoed back. This also matches the reference PDS.
2026-08-28 07:16:50 +00:00
Jack PlattenandTangled 26aa399cda Fix name of test 2026-08-27 20:07:59 +00:00
Jack PlattenandTangled f296bb68df fix: allow path-empty URIs and drop authority/path charset checks
RFC 3986 lets hier-part be path-empty, so "urn:" alone is a valid
URI; treat it as one.
2026-08-27 20:07:59 +00:00
Jack PlattenandTangled d979cb969a add underscore to scheme support.
adds tests for mbid directly, as well as underscore scheme.

Followup:
`scheme:` is not currently passing this function, but can wait for now
2026-08-27 20:07:59 +00:00
739db41130 fix: accept RFC 3986 scheme:opaque-part URIs without //
is_valid_uri required a literal "://", but the atproto uri string
format follows RFC 3986's generic URI grammar, which also allows
"scheme:opaque-part" forms with no authority (e.g. urn:isbn:...).
Records using such values were rejected once production lexicons
enable strict validation.

Reported as #130.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-27 20:07:59 +00:00
Johanna LarssonandTangled c0caa93228 Dev compose improvements
1. Set the max connections to 20, I frequently see

db-1            | 2026-08-23 08:57:02.784 UTC [12641] FATAL:  sorry, too many clients already

2. Add a wildcard route in Traefik to serve DID documents locally.

3. Expose the PLC port locally so I can point an app at `http://localhost:2582` and be able to do full OAuth flows.
2026-08-23 09:50:08 +00:00
blooym.devandTangled 3ade3d10c1 docs: clarify the postgres seq fix command
There was a syntax error in this command, so I updated the doc to clarify it and also show it needs an integer, not a string like it implied before.
2026-08-21 17:35:46 +00:00
Jack PlattenandTangled 0189aa9f96 Update config commit to create new round 2026-08-21 16:16:45 +00:00
Jack PlattenandTangled d495d7d729 Use crate::types::queuedcomms export
also generate example.toml
2026-08-21 16:16:45 +00:00
73cb89c9b7 resolve review feedback.
- eliminates panic opportunity on receiving email
- strict enum
- added unit test for ensuring that atmos headers don't leak onto
  directmx

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-21 16:16:45 +00:00
Jack PlattenandTangled ecb7934a20 fix: fix missing test failure 2026-08-21 16:16:45 +00:00
Jack PlattenandTangled 9edc7dcdd8 comms: add comail.at category support
Adds a defaulted to off option to add the `X-Atmos-Category` headers
to emails sent via smarthost, for proper categorization by comail.

Category breakdown is as follows:

verification: EmailVerification, ChannelVerification, ChannelVerified,
  MigrationVerification, LegacyLoginAlert, EmailUpdate, PlcOperation,
  AccountDeletion
password-reset: PasswordReset, PasskeyRecovery
mfa-otp: TwoFactorCode
bulk: Welcome
untagged: AdminEmail
2026-08-21 16:16:45 +00:00
Matan KushnerandTangled 479fa3ed22 fix: require DPoP for loopback clients 2026-08-21 11:41:29 +00:00
Louis EscherandTangled aa815931e0 Update lib.rs 2026-08-20 08:21:53 +00:00
Louis EscherandTangled 0ce725174d fix: DID length test, service test, cloning, dead code (should be it!) 2026-08-20 08:21:53 +00:00
Louis EscherandTangled dae3cc7e08 fix: aud fragment matching 2026-08-20 08:21:53 +00:00
Louis EscherandTangled b9e7955606 fix: pass exp to token creation 2026-08-20 08:21:53 +00:00
Louis EscherandTangled 32c58b1d0b fix: make thingy allow list 2026-08-20 08:21:53 +00:00
Louis EscherandTangled 1b5a2b319c fix: getServiceAuth aud parsing 2026-08-20 08:21:53 +00:00
LewisandTangled ed3d129594 just: clippy over all targets, lint the bsky-off build
Lewis: May this revision serve well! <did:plc:3fwecdnvtcscjnrx2p4n7alz>
2026-08-16 17:15:23 +00:00
LewisandTangled 8d0b6f8322 cache: DID, SSO, & OAuth client metadata caches onto shared cache
Lewis: May this revision serve well! <did:plc:3fwecdnvtcscjnrx2p4n7alz>
2026-08-16 17:15:23 +00:00
LewisandTangled 0fc577316e lexicon: schema docs & negative results via cluster cache
Lewis: May this revision serve well! <did:plc:3fwecdnvtcscjnrx2p4n7alz>
2026-08-16 17:15:23 +00:00
LewisandTangled 52d5236e89 plc: dedup fetch paths, cache TTL from config
Lewis: May this revision serve well! <did:plc:3fwecdnvtcscjnrx2p4n7alz>
2026-08-16 17:15:23 +00:00
LewisandTangled 0274f19d75 auth: EmailTokenPurpose from tranquil-types, shared cache key fns, MemoryCache in tests
Lewis: May this revision serve well! <did:plc:3fwecdnvtcscjnrx2p4n7alz>
2026-08-16 17:15:23 +00:00
LewisandTangled 135912194d types: HttpUrl newtypes, shared cache key/JSON helpers
Lewis: May this revision serve well! <did:plc:3fwecdnvtcscjnrx2p4n7alz>
2026-08-16 17:15:23 +00:00
LewisandTangled 0b8787d1de pds: compile bsky-specific proxy, CORS, & validation out under bsky features
Lewis: May this revision serve well! <did:plc:3fwecdnvtcscjnrx2p4n7alz>
2026-08-16 17:15:23 +00:00
32 changed files with 938 additions and 153 deletions
+2
View File
@@ -1,6 +1,7 @@
[server]
hostname = "pds.test"
allow_http_proxy = true
allow_private_fetch = true
invite_code_required = false
disable_rate_limiting = true
@@ -10,6 +11,7 @@ dir = "/app/frontend/public"
[database]
url = "postgres://postgres:postgres@db:5432/pds"
max_connections = 20
[storage]
path = "/var/lib/tranquil-pds/blobs"
@@ -118,18 +118,6 @@ pub async fn sign_plc_operation(
}
})?;
let signing_did_key = signing_key_to_did_key(&signing_key);
if let Some(rotation_keys) = unsigned_op.get("rotationKeys").and_then(Value::as_array) {
let rotation_key_strs: Vec<&str> = rotation_keys.iter().filter_map(Value::as_str).collect();
if let Some(missing) = missing_required_rotation_key(
&rotation_key_strs,
&signing_did_key,
tranquil_config::get().secrets.plc_rotation_key.as_deref(),
) {
return Err(ApiError::InvalidRequest(missing.message().into()));
}
}
let signed_op = sign_operation(&unsigned_op, &signing_key).map_err(|e| {
error!("Failed to sign PLC operation: {:?}", e);
ApiError::InternalError(None)
+3 -2
View File
@@ -12,7 +12,7 @@ use tranquil_pds::api::ApiError;
use tranquil_pds::api::proxy_client::{is_ssrf_safe, proxy_client};
use tranquil_pds::auth::{AnyUser, Auth};
use tranquil_pds::state::AppState;
use tranquil_pds::types::{Did, Nsid};
use tranquil_pds::types::{Did, DidRef, Nsid};
static CREATE_REPORT_NSID: LazyLock<Nsid> =
LazyLock::new(|| "com.atproto.moderation.createReport".parse().unwrap());
@@ -151,8 +151,9 @@ async fn proxy_to_report_service(
let service_token = match tranquil_pds::auth::create_service_token(
&auth_user.did,
service_did,
&DidRef::from(service_did),
Some(&CREATE_REPORT_NSID),
None,
&key_bytes,
) {
Ok(t) => t,
+15 -10
View File
@@ -11,7 +11,7 @@ use tracing::{error, info, warn};
use tranquil_pds::api::error::ApiError;
use tranquil_pds::auth::extractor::{Auth, Permissive};
use tranquil_pds::state::AppState;
use tranquil_pds::types::Did;
use tranquil_pds::types::DidRef;
use tranquil_types::Nsid;
static CREATE_ACCOUNT_NSID: LazyLock<Nsid> =
@@ -45,7 +45,7 @@ static PROTECTED_METHODS: LazyLock<HashSet<&'static str>> = LazyLock::new(|| {
#[derive(Deserialize)]
pub struct GetServiceAuthParams {
pub aud: Did,
pub aud: DidRef,
pub lxm: Option<Nsid>,
pub exp: Option<i64>,
}
@@ -169,14 +169,19 @@ pub async fn get_service_auth(
}
}
let service_token =
match tranquil_pds::auth::create_service_token(&auth.did, &params.aud, lxm, &key_bytes) {
Ok(t) => t,
Err(e) => {
error!("Failed to create service token: {:?}", e);
return ApiError::InternalError(None).into_response();
}
};
let service_token = match tranquil_pds::auth::create_service_token(
&auth.did,
&params.aud,
lxm,
params.exp,
&key_bytes,
) {
Ok(t) => t,
Err(e) => {
error!("Failed to create service token: {:?}", e);
return ApiError::InternalError(None).into_response();
}
};
(
StatusCode::OK,
Json(GetServiceAuthOutput {
+10 -6
View File
@@ -10,7 +10,7 @@ use chrono::{DateTime, Duration, Utc};
use hmac::{Hmac, Mac};
use k256::ecdsa::{Signature, SigningKey, signature::Signer};
use sha2::Sha256;
use tranquil_types::{Did, Jti, Nsid};
use tranquil_types::{Did, DidRef, Jti, Nsid};
type HmacSha256 = Hmac<Sha256>;
@@ -127,16 +127,20 @@ pub fn create_refresh_token_with_jti(
pub fn create_service_token(
did: &Did,
aud: &Did,
aud: &DidRef,
lxm: Option<&Nsid>,
exp: Option<i64>,
key_bytes: &[u8],
) -> Result<String> {
let signing_key = SigningKey::from_slice(key_bytes)?;
let expiration = Utc::now()
.checked_add_signed(Duration::seconds(60))
.expect("valid timestamp")
.timestamp();
let expiration = match exp {
Some(exp) => exp,
None => Utc::now()
.checked_add_signed(Duration::seconds(60))
.expect("valid timestamp")
.timestamp(),
};
let claims = Claims {
iss: did.clone(),
+123 -8
View File
@@ -1,25 +1,41 @@
use lettre::Message;
use lettre::message::Mailbox;
use lettre::message::header::ContentType;
use lettre::message::header::{Header, HeaderName, HeaderValue};
use uuid::Uuid;
use super::types::EmailDomain;
use crate::sender::SendError;
use crate::types::QueuedComms;
use crate::types::{CommsType, QueuedComms};
pub(super) fn build(from: &Mailbox, qc: &QueuedComms) -> Result<Message, SendError> {
pub(super) fn build(
from: &Mailbox,
qc: &QueuedComms,
apply_atmos_categories: bool,
) -> Result<Message, SendError> {
let to: Mailbox = qc
.recipient
.parse()
.map_err(|e: lettre::address::AddressError| SendError::InvalidRecipient(e.to_string()))?;
let subject = qc.subject.as_deref().unwrap_or("Notification");
let message_id = format!("<{}@{}>", Uuid::new_v4(), from.email.domain());
Message::builder()
let builder = Message::builder()
.from(from.clone())
.to(to)
.subject(subject)
.message_id(Some(message_id))
.header(ContentType::TEXT_PLAIN)
.header(ContentType::TEXT_PLAIN);
let category = apply_atmos_categories
.then(|| atmos_category(qc.comms_type))
.flatten();
let builder = match category {
Some(category) => builder.header(category),
None => builder,
};
builder
.body(qc.body.clone())
.map_err(|e| SendError::MessageBuild(e.to_string()))
}
@@ -34,10 +50,57 @@ pub(super) fn recipient_domain(message: &Message) -> Result<EmailDomain, SendErr
.map_err(|e| SendError::InvalidRecipient(format!("invalid recipient domain: {e}")))
}
// for use with comail.at
#[derive(Debug, Clone, PartialEq, Eq, Copy)]
enum AtmosCategory {
PasswordReset,
MfaOtp,
Verification,
}
impl AtmosCategory {
fn as_str(self) -> &'static str {
match self {
Self::PasswordReset => "password-reset",
Self::MfaOtp => "mfa-otp",
Self::Verification => "verification",
}
}
}
impl Header for AtmosCategory {
fn name() -> HeaderName {
HeaderName::new_from_ascii_str("X-Atmos-Category")
}
fn parse(_s: &str) -> Result<Self, Box<dyn std::error::Error + Send + Sync>> {
//since we're never receiving email, we don't care about parsing
Err("X-Atmos-Category is write-only".into())
}
fn display(&self) -> HeaderValue {
HeaderValue::new(Self::name(), self.as_str().to_string())
}
}
fn atmos_category(comms_type: CommsType) -> Option<AtmosCategory> {
use CommsType::*;
match comms_type {
EmailVerification
| ChannelVerification
| ChannelVerified
| MigrationVerification
| LegacyLoginAlert
| EmailUpdate
| PlcOperation
| AccountDeletion
| Welcome => Some(AtmosCategory::Verification),
PasswordReset | PasskeyRecovery => Some(AtmosCategory::PasswordReset),
TwoFactorCode => Some(AtmosCategory::MfaOtp),
AdminEmail => None,
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::types::{CommsChannel, CommsStatus, CommsType};
use crate::types::{CommsChannel, CommsStatus};
use chrono::Utc;
use uuid::Uuid;
@@ -71,6 +134,7 @@ mod tests {
let msg = build(
&from_mailbox(),
&fixture("user@nel.pet", Some("Welcome"), "Hello world."),
false,
)
.unwrap();
let raw = String::from_utf8(msg.formatted()).unwrap();
@@ -87,6 +151,7 @@ mod tests {
let msg = build(
&from_mailbox(),
&fixture("user@nel.pet", Some("héllo wörld"), "Body"),
false,
)
.unwrap();
let raw = String::from_utf8(msg.formatted()).unwrap();
@@ -99,6 +164,7 @@ mod tests {
let result = build(
&from_mailbox(),
&fixture("x@nel.pet\r\nBcc: evil@x", Some("s"), "b"),
false,
);
assert!(matches!(result, Err(SendError::InvalidRecipient(_))));
}
@@ -108,6 +174,7 @@ mod tests {
let msg = build(
&from_mailbox(),
&fixture("user@nel.pet", Some("hi\r\nBcc: evil@nel.pet"), "body"),
false,
)
.expect("subject CRLF should be encoded, not rejected");
let raw = String::from_utf8(msg.formatted()).unwrap();
@@ -123,7 +190,12 @@ mod tests {
#[test]
fn message_id_uses_from_domain() {
let msg = build(&from_mailbox(), &fixture("user@nel.pet", Some("s"), "b")).unwrap();
let msg = build(
&from_mailbox(),
&fixture("user@nel.pet", Some("s"), "b"),
false,
)
.unwrap();
let raw = String::from_utf8(msg.formatted()).unwrap();
let line = raw
.lines()
@@ -137,15 +209,58 @@ mod tests {
#[test]
fn missing_subject_uses_default() {
let msg = build(&from_mailbox(), &fixture("user@nel.pet", None, "Body")).unwrap();
let msg = build(
&from_mailbox(),
&fixture("user@nel.pet", None, "Body"),
false,
)
.unwrap();
let raw = String::from_utf8(msg.formatted()).unwrap();
assert!(raw.contains("Subject: Notification"));
}
#[test]
fn recipient_domain_extracted() {
let msg = build(&from_mailbox(), &fixture("user@Nel.PET", Some("s"), "b")).unwrap();
let msg = build(
&from_mailbox(),
&fixture("user@Nel.PET", Some("s"), "b"),
false,
)
.unwrap();
let d = recipient_domain(&msg).unwrap();
assert_eq!(d.as_str(), "nel.pet");
}
#[test]
fn atmos_category_header_present_when_enabled_and_mapped() {
let qc = QueuedComms {
comms_type: CommsType::PasswordReset,
..fixture("user@nel.pet", Some("s"), "b")
};
let msg = build(&from_mailbox(), &qc, true).unwrap();
let raw = String::from_utf8(msg.formatted()).unwrap();
assert!(raw.contains("X-Atmos-Category: password-reset"));
}
#[test]
fn atmos_category_header_absent_when_disabled() {
let qc = QueuedComms {
comms_type: CommsType::PasswordReset,
..fixture("user@nel.pet", Some("s"), "b")
};
let msg = build(&from_mailbox(), &qc, false).unwrap();
let raw = String::from_utf8(msg.formatted()).unwrap();
assert!(!raw.contains("X-Atmos-Category"));
}
#[test]
fn atmos_category_header_absent_when_unmapped() {
let qc = QueuedComms {
comms_type: CommsType::AdminEmail,
..fixture("user@nel.pet", Some("s"), "b")
};
let msg = build(&from_mailbox(), &qc, true).unwrap();
let raw = String::from_utf8(msg.formatted()).unwrap();
assert!(!raw.contains("X-Atmos-Category"));
}
}
+55 -1
View File
@@ -124,6 +124,7 @@ fn build_smarthost(
Ok(SendMode::Smarthost {
transport: Box::new(builder.build()),
total_timeout,
apply_atmos_categories: cfg.email.smarthost.apply_atmos_categories,
})
}
@@ -176,6 +177,16 @@ fn build_dkim(cfg: &tranquil_config::DkimConfig) -> Result<Option<DkimSigner>, S
DkimSigner::load(selector, domain, path).map(Some)
}
fn wants_atmos_categories(mode: &SendMode) -> bool {
match mode {
SendMode::Smarthost {
apply_atmos_categories,
..
} => *apply_atmos_categories,
SendMode::DirectMx { .. } => false,
}
}
#[async_trait]
impl CommsSender for EmailSender {
fn channel(&self) -> CommsChannel {
@@ -183,7 +194,8 @@ impl CommsSender for EmailSender {
}
async fn send(&self, notification: &QueuedComms) -> Result<(), SendError> {
let mut message = message::build(&self.from, notification)?;
let mut message =
message::build(&self.from, notification, wants_atmos_categories(&self.mode))?;
if let Some(signer) = &self.dkim {
signer.sign(&mut message);
}
@@ -196,3 +208,45 @@ impl CommsSender for EmailSender {
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use lettre::Tokio1Executor;
use std::time::Duration;
fn dummy_smarthost(apply_atmos_categories: bool) -> SendMode {
let transport =
AsyncSmtpTransport::<Tokio1Executor>::builder_dangerous("localhost").build();
SendMode::Smarthost {
transport: Box::new(transport),
total_timeout: Duration::from_secs(10),
apply_atmos_categories,
}
}
fn dummy_direct_mx() -> SendMode {
SendMode::DirectMx {
resolver: Arc::new(TokioAsyncResolver::tokio(
ResolverConfig::default(),
ResolverOpts::default(),
)),
helo: HeloName::parse("mta.nel.pet").unwrap(),
command_timeout: Duration::from_secs(5),
total_timeout: Duration::from_secs(10),
require_tls: false,
inflight: Arc::new(Semaphore::new(1)),
}
}
#[tokio::test]
async fn smarthost_reflects_its_own_flag() {
assert!(wants_atmos_categories(&dummy_smarthost(true)));
assert!(!wants_atmos_categories(&dummy_smarthost(false)));
}
#[test]
fn direct_mx_never_wants_atmos_categories() {
assert!(!wants_atmos_categories(&dummy_direct_mx()));
}
}
+11 -2
View File
@@ -19,6 +19,7 @@ pub enum SendMode {
Smarthost {
transport: Box<AsyncSmtpTransport<Tokio1Executor>>,
total_timeout: Duration,
apply_atmos_categories: bool,
},
DirectMx {
resolver: Arc<TokioAsyncResolver>,
@@ -33,8 +34,15 @@ pub enum SendMode {
impl std::fmt::Debug for SendMode {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Smarthost { total_timeout, .. } => {
write!(f, "SendMode::Smarthost(total_timeout={total_timeout:?})")
Self::Smarthost {
total_timeout,
apply_atmos_categories,
..
} => {
write!(
f,
"SendMode::Smarthost(total_timeout={total_timeout:?}, apply_atmos_categories={apply_atmos_categories:?})"
)
}
Self::DirectMx {
helo, require_tls, ..
@@ -52,6 +60,7 @@ pub async fn dispatch(mode: &SendMode, message: Message) -> Result<(), SendError
SendMode::Smarthost {
transport,
total_timeout,
..
} => with_total_timeout(*total_timeout, run_send(transport, message)).await,
SendMode::DirectMx {
resolver,
@@ -53,6 +53,7 @@ fn build_smarthost_sender_with_total_timeout(
SendMode::Smarthost {
transport: Box::new(transport),
total_timeout,
apply_atmos_categories: false,
},
None,
)
+9
View File
@@ -465,6 +465,10 @@ pub struct ServerConfig {
#[config(env = "DISABLE_RATE_LIMITING", default = false)]
pub disable_rate_limiting: bool,
/// Allow outbound fetches to private network addresses. Useful for local development using docker compose.
#[config(env = "ALLOW_PRIVATE_FETCH", default = false)]
pub allow_private_fetch: bool,
/// Skip the verified-comms-channel gate for login and record writes.
/// Please keep this off unless you're an invite-only PDS!
#[config(env = "DISABLE_ACCOUNT_VERIFICATION_GATE", default = false)]
@@ -1124,6 +1128,10 @@ pub struct SmarthostConfig {
/// stuck relay cannot stall the comms queue.
#[config(env = "MAIL_SMARTHOST_TOTAL_TIMEOUT_SECS", default = 60)]
pub total_timeout_secs: u64,
/// Apply Atmos/Comail.at categories for headers to be categorized appropriately.
#[config(env = "MAIL_APPLY_ATMOS_CATEGORIES", default = false)]
pub apply_atmos_categories: bool,
}
#[derive(Debug, Config)]
@@ -1985,6 +1993,7 @@ port = 587
pool_size: 4,
command_timeout_secs: 30,
total_timeout_secs: 60,
apply_atmos_categories: false,
},
direct_mx: DirectMxConfig {
command_timeout_secs: 30,
+47 -8
View File
@@ -34,15 +34,28 @@ pub fn is_valid_datetime(s: &str) -> bool {
chrono::DateTime::parse_from_rfc3339(s).is_ok()
}
/// Checks the scheme only, not the character set or structure of what
/// follows. The aim is to accept at least all valid URIs; we can always
/// tighten this later. It does not parse the authority, because at-uris
/// put colons in the authority (at://did:plc:abc123/collection/rkey) and
/// any 3986 authority parser reads that as a non-numeric port and rejects
/// it.
pub fn is_valid_uri(s: &str) -> bool {
s.split_once("://").is_some_and(|(scheme, rest)| {
!scheme.is_empty()
&& scheme
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '+' || c == '.' || c == '-')
&& scheme.starts_with(|c: char| c.is_ascii_alphabetic())
&& !rest.is_empty()
})
let Some((scheme, rest)) = s.split_once(':') else {
return false;
};
let valid_scheme = !scheme.is_empty()
&& scheme.starts_with(|c: char| c.is_ascii_alphabetic())
&& scheme
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '+' || c == '.' || c == '-' || c == '_');
if !valid_scheme {
return false;
}
match rest.strip_prefix("//") {
Some(authority_and_path) => !authority_and_path.is_empty(),
None => true,
}
}
pub fn is_valid_cid(s: &str) -> bool {
@@ -151,6 +164,32 @@ mod tests {
assert!(!is_valid_uri("https://"));
}
#[test]
fn test_valid_uris_without_authority() {
// RFC 3986 hier-part doesn't require "//": scheme ":" opaque-part is also a URI.
assert!(is_valid_uri("urn:isbn:9780141439518"));
assert!(is_valid_uri("mailto:user@example.com"));
assert!(is_valid_uri("mbid:70766a5a-3f95-4b19-96c8-a2c9c4a5e6e5")); //authority-less / path-rootless
assert!(is_valid_uri(
"has_an_underscore:70766a5a-3f95-4b19-96c8-a2c9c4a5e6e5"
));
assert!(is_valid_uri("urn:"));
}
#[test]
fn test_invalid_uri_without_scheme() {
assert!(!is_valid_uri(":no-scheme"));
}
#[test]
fn test_valid_uris_dont_reject_at_uri_authority_colons() {
// at-uri authorities contain colons (did:plc:...); is_valid_uri must not
// reject them the way a strict RFC 3986 authority parser would.
assert!(is_valid_uri(
"at://did:plc:cwdkf4xxjpznceembuuspt3d/sh.tangled.repo.pull/3mtjn7zouwn22"
));
}
#[test]
fn test_valid_cids() {
assert!(is_valid_cid("bafyreiabcdef123456"));
+7 -5
View File
@@ -72,10 +72,11 @@ pub struct ClientMetadataCache {
cache: Arc<dyn Cache>,
http_client: Client,
cache_ttl: Duration,
fetch_policy: ReachPolicy,
}
impl ClientMetadataCache {
pub fn new(cache: Arc<dyn Cache>, cache_ttl: Duration) -> Self {
pub fn new(cache: Arc<dyn Cache>, cache_ttl: Duration, fetch_policy: ReachPolicy) -> Self {
Self {
cache,
http_client: {
@@ -84,8 +85,8 @@ impl ClientMetadataCache {
.connect_timeout(std::time::Duration::from_secs(10))
.pool_max_idle_per_host(10)
.pool_idle_timeout(std::time::Duration::from_secs(90))
.redirect(redirect_policy(ReachPolicy::DEBUG_LOOPBACK))
.dns_resolver(dns_guard(ReachPolicy::DEBUG_LOOPBACK))
.redirect(redirect_policy(fetch_policy))
.dns_resolver(dns_guard(fetch_policy))
.user_agent(concat!(
"Tranquil-PDS/",
env!("CARGO_PKG_VERSION"),
@@ -98,6 +99,7 @@ impl ClientMetadataCache {
.expect("failed to build client metadata HTTP client")
},
cache_ttl,
fetch_policy,
}
}
@@ -142,7 +144,7 @@ impl ClientMetadataCache {
response_types: vec!["code".into()],
scope,
token_endpoint_auth_method: Some("none".into()),
dpop_bound_access_tokens: Some(false),
dpop_bound_access_tokens: Some(true),
jwks: None,
jwks_uri: None,
application_type: Some("native".into()),
@@ -249,7 +251,7 @@ impl ClientMetadataCache {
async fn fetch_metadata(&self, client_id: &ClientId) -> Result<ClientMetadata, OAuthError> {
let url = reqwest::Url::parse(client_id)
.map_err(|_| OAuthError::InvalidClient("client_id must be a URL".to_string()))?;
if !url_reach_permits(&url, ReachPolicy::DEBUG_LOOPBACK) {
if !url_reach_permits(&url, self.fetch_policy) {
return Err(OAuthError::InvalidClient(
"client_id must be an https URL inside the allowed host reach".to_string(),
));
+3 -2
View File
@@ -5,7 +5,7 @@ use std::sync::LazyLock;
use crate::api::error::ApiError;
use crate::api::proxy_client::proxy_client;
use crate::state::AppState;
use crate::types::{Did, Nsid};
use crate::types::{Did, DidRef, Nsid};
use crate::util::get_header_str;
use axum::{
body::Bytes,
@@ -361,8 +361,9 @@ async fn proxy_handler(
match crate::auth::create_service_token(
&auth_user.did,
&token_aud,
&DidRef::from(token_aud),
Some(&token_lxm),
None,
&key_bytes,
) {
Ok(new_token) => {
+3 -2
View File
@@ -5,8 +5,9 @@ pub use roles::{
CanAddControllers, CanControlAccounts, verify_can_add_controllers, verify_can_control_accounts,
};
pub use scopes::{
EDITOR_FULL_SCOPES, GrantCoverage, InvalidDelegationScopeError, OWNER_FULL_SCOPES,
SCOPE_PRESETS, ScopePreset, ValidatedDelegationScope, grant_coverage, intersect_scopes,
ADMIN_FULL_SCOPES, EDITOR_FULL_SCOPES, GrantCoverage, InvalidDelegationScopeError,
OWNER_FULL_SCOPES, SCOPE_PRESETS, ScopePreset, ValidatedDelegationScope, grant_coverage,
intersect_scopes,
};
pub use tranquil_db_traits::DelegationActionType;
+130 -3
View File
@@ -14,10 +14,15 @@ pub struct ScopePreset {
pub scopes: &'static str,
}
pub const OWNER_FULL_SCOPES: &str = "atproto repo:* blob:*/* identity:* account:*?action=manage";
pub const OWNER_FULL_SCOPES: &str = concat!(
"atproto repo:* blob:*/* rpc:* identity:* account:*?action=manage ",
"transition:generic transition:chat.bsky transition:email"
);
pub const ADMIN_FULL_SCOPES: &str = "atproto repo:* blob:*/* rpc:* account:*?action=manage";
pub const EDITOR_FULL_SCOPES: &str =
"atproto repo:*?action=create repo:*?action=update repo:*?action=delete blob:*/*";
"atproto repo:*?action=create repo:*?action=update repo:*?action=delete blob:*/* rpc:*";
pub const SCOPE_PRESETS: &[ScopePreset] = &[
ScopePreset {
@@ -30,7 +35,7 @@ pub const SCOPE_PRESETS: &[ScopePreset] = &[
name: "admin",
label: "Admin",
description: "Manage account settings, post content, upload media",
scopes: "atproto repo:* blob:*/* account:*?action=manage",
scopes: ADMIN_FULL_SCOPES,
},
ScopePreset {
name: "editor",
@@ -330,4 +335,126 @@ mod tests {
GrantCoverage::Narrowed("repo:io.atcr.manifest?action=create".to_string())
);
}
// Tracks all known scope prefixes
const GRANULAR_SCOPE_TAXONOMY: &[(&str, &str)] = &[
("repo", "repo:app.bsky.feed.post?action=create"),
("blob", "blob:image/png"),
("rpc", "rpc:app.bsky.actor.getProfile?aud=*"),
("account", "account:email?action=manage"),
("identity", "identity:handle"),
("transition:generic", "transition:generic"),
("transition:chat.bsky", "transition:chat.bsky"),
("transition:email", "transition:email"),
];
/// The taxonomy label a scope type must be represented by, or `None` for scope types
/// delegation never gates.
fn taxonomy_label(scope: &ParsedScope) -> Option<&'static str> {
match scope {
ParsedScope::Repo(_) => Some("repo"),
ParsedScope::Blob(_) => Some("blob"),
ParsedScope::Rpc(_) => Some("rpc"),
ParsedScope::Account(_) => Some("account"),
ParsedScope::Identity(_) => Some("identity"),
ParsedScope::TransitionGeneric => Some("transition:generic"),
ParsedScope::TransitionChat => Some("transition:chat.bsky"),
ParsedScope::TransitionEmail => Some("transition:email"),
ParsedScope::Atproto => None,
ParsedScope::Include(_) => None,
ParsedScope::Unknown(_) => None,
}
}
#[test]
fn test_taxonomy_entries_parse_to_the_scope_type_they_claim() {
GRANULAR_SCOPE_TAXONOMY.iter().for_each(|(label, scope)| {
assert_eq!(
taxonomy_label(&parse_scope(scope)),
Some(*label),
"taxonomy entry `{}` does not parse to a `{}` scope, so the reachability \
test is not actually exercising that scope type",
scope,
label
);
});
}
fn coverage_matrix() -> String {
GRANULAR_SCOPE_TAXONOMY
.iter()
.map(|(label, scope)| {
let granting: Vec<&str> = SCOPE_PRESETS
.iter()
.filter(|p| grant_coverage(p.scopes, scope) != GrantCoverage::Withheld)
.map(|p| p.name)
.collect();
match granting.is_empty() {
true => format!(" {:<9} ({}) -> NONE", label, scope),
false => format!(" {:<9} ({}) -> {}", label, scope, granting.join(", ")),
}
})
.collect::<Vec<String>>()
.join("\n")
}
#[test]
fn test_every_granular_scope_type_is_reachable_through_some_preset() {
let unreachable: Vec<&str> = GRANULAR_SCOPE_TAXONOMY
.iter()
.filter(|(_, scope)| {
SCOPE_PRESETS
.iter()
.all(|p| grant_coverage(p.scopes, scope) == GrantCoverage::Withheld)
})
.map(|(label, _)| *label)
.collect();
assert!(
unreachable.is_empty(),
"no delegation preset confers any `{}` scope, so delegated accounts cannot use \
that capability at all.\ncoverage by preset:\n{}",
unreachable.join("`, `"),
coverage_matrix()
);
}
#[test]
fn test_forbidden_rpc_wildcard_is_not_a_usable_grant() {
// `rpc:*?aud=*` wildcards both lxm and aud, which the spec forbids, so it parses to
// Unknown and confers nothing. A preset reaching for it to mean "all rpc" would look
// right and silently grant nothing -- `rpc:*` is the form that works.
assert_eq!(
grant_coverage("atproto rpc:*?aud=*", "rpc:app.bsky.actor.getProfile?aud=*"),
GrantCoverage::Withheld
);
assert_eq!(
grant_coverage("atproto rpc:*", "rpc:app.bsky.actor.getProfile?aud=*"),
GrantCoverage::Full
);
}
#[test]
fn test_forbidden_rpc_wildcard_request_stays_denied() {
assert_eq!(
grant_coverage("atproto rpc:*", "rpc:*?aud=*"),
GrantCoverage::Withheld
);
}
#[test]
fn test_grant_may_mix_transition_and_granular_scopes() {
assert!(ValidatedDelegationScope::new(OWNER_FULL_SCOPES).is_ok());
assert_eq!(
intersect_scopes("atproto transition:generic", OWNER_FULL_SCOPES),
"atproto transition:generic"
);
assert_eq!(
intersect_scopes(
"atproto repo:app.bsky.feed.post?action=create",
OWNER_FULL_SCOPES
),
"atproto repo:app.bsky.feed.post?action=create"
);
}
}
+12 -9
View File
@@ -61,22 +61,21 @@ pub struct DidResolver {
client: Client,
cache_ttl: Duration,
plc_directory_url: String,
fetch_policy: tranquil_types::ReachPolicy,
}
impl DidResolver {
pub fn new(cache: Arc<dyn Cache>) -> Self {
let cfg = tranquil_config::get();
let fetch_policy =
tranquil_types::ReachPolicy::from_private_fetch(cfg.server.allow_private_fetch);
let client = Client::builder()
.timeout(Duration::from_secs(10))
.connect_timeout(Duration::from_secs(5))
.pool_max_idle_per_host(10)
.redirect(tranquil_types::redirect_policy(
tranquil_types::ReachPolicy::DEBUG_LOOPBACK,
))
.dns_resolver(tranquil_types::dns_guard(
tranquil_types::ReachPolicy::DEBUG_LOOPBACK,
))
.redirect(tranquil_types::redirect_policy(fetch_policy))
.dns_resolver(tranquil_types::dns_guard(fetch_policy))
.build()
.expect("failed to build DID resolver HTTP client");
@@ -87,6 +86,7 @@ impl DidResolver {
client,
cache_ttl: Duration::from_secs(cfg.plc.did_cache_ttl_secs),
plc_directory_url: cfg.plc.directory_url.clone(),
fetch_policy,
}
}
@@ -155,7 +155,7 @@ impl DidResolver {
&self,
did: &Did,
) -> Result<serde_json::Value, DidResolutionError> {
let url = build_did_web_url(did)?;
let url = build_did_web_url(did, self.fetch_policy)?;
debug!("Resolving did:web {} via {}", did, url);
@@ -214,7 +214,10 @@ impl DidResolver {
}
}
fn build_did_web_url(did: &Did) -> Result<String, DidResolutionError> {
fn build_did_web_url(
did: &Did,
policy: tranquil_types::ReachPolicy,
) -> Result<String, DidResolutionError> {
let host = did
.strip_prefix("did:web:")
.ok_or(DidResolutionError::InvalidDidWeb)?;
@@ -254,7 +257,7 @@ fn build_did_web_url(did: &Did) -> Result<String, DidResolutionError> {
if tranquil_types::url_reach(&url) == Some(tranquil_types::HostReach::Loopback) {
let _ = url.set_scheme("http");
}
match tranquil_types::url_reach_permits(&url, tranquil_types::ReachPolicy::DEBUG_LOOPBACK) {
match tranquil_types::url_reach_permits(&url, policy) {
true => Ok(url.to_string()),
false => Err(DidResolutionError::DidWebHostRejected(host)),
}
+1 -14
View File
@@ -106,20 +106,7 @@ pub fn app_with_routes(state: AppState, external: ExternalRoutes) -> Router {
CorsLayer::new()
.allow_origin(Any)
.allow_methods([Method::GET, Method::POST, Method::OPTIONS])
.allow_headers(AllowHeaders::list(
[
http::header::AUTHORIZATION,
http::header::CONTENT_TYPE,
http::header::CONTENT_ENCODING,
http::header::ACCEPT_ENCODING,
http::header::USER_AGENT,
util::HEADER_DPOP,
util::HEADER_ATPROTO_PROXY,
util::HEADER_ATPROTO_ACCEPT_LABELERS,
]
.into_iter()
.chain(util::CORS_BSKY_ALLOW_HEADERS),
))
.allow_headers(AllowHeaders::mirror_request())
.expose_headers([
http::header::WWW_AUTHENTICATE,
util::HEADER_DPOP_NONCE,
+6 -8
View File
@@ -10,7 +10,9 @@ use tranquil_oauth::{
AuthorizationServerMetadata, ClientMetadata, compute_es256_jkt, compute_pkce_challenge,
create_dpop_proof,
};
use tranquil_types::{AuthorizationCode, ClientId, CrossPdsState, Did, Issuer, PdsUrl};
use tranquil_types::{
AuthorizationCode, ClientId, CrossPdsState, Did, Issuer, PdsUrl, ReachPolicy,
};
use crate::cache::Cache;
@@ -68,16 +70,12 @@ pub struct CrossPdsOAuthClient {
}
impl CrossPdsOAuthClient {
pub fn new(cache: Arc<dyn Cache>) -> Self {
pub fn new(cache: Arc<dyn Cache>, fetch_policy: ReachPolicy) -> Self {
let http = Client::builder()
.timeout(Duration::from_secs(15))
.connect_timeout(Duration::from_secs(5))
.redirect(tranquil_types::redirect_policy(
tranquil_types::ReachPolicy::GlobalOnly,
))
.dns_resolver(tranquil_types::dns_guard(
tranquil_types::ReachPolicy::GlobalOnly,
))
.redirect(tranquil_types::redirect_policy(fetch_policy))
.dns_resolver(tranquil_types::dns_guard(fetch_policy))
.build()
.expect("failed to build cross-PDS OAuth HTTP client");
Self { http, cache }
+5 -6
View File
@@ -187,17 +187,16 @@ impl PlcClient {
});
let timeout_secs = cfg.map_or(10, |c| c.plc.timeout_secs);
let connect_timeout_secs = cfg.map_or(5, |c| c.plc.connect_timeout_secs);
let fetch_policy = tranquil_types::ReachPolicy::from_private_fetch(
cfg.map_or(false, |c| c.server.allow_private_fetch),
);
let client = Client::builder()
.timeout(Duration::from_secs(timeout_secs))
.connect_timeout(Duration::from_secs(connect_timeout_secs))
.pool_max_idle_per_host(5)
.pool_idle_timeout(Duration::from_secs(90))
.redirect(tranquil_types::redirect_policy(
tranquil_types::ReachPolicy::DEBUG_LOOPBACK,
))
.dns_resolver(tranquil_types::dns_guard(
tranquil_types::ReachPolicy::DEBUG_LOOPBACK,
))
.redirect(tranquil_types::redirect_policy(fetch_policy))
.dns_resolver(tranquil_types::dns_guard(fetch_policy))
.build()
.expect("failed to build PLC directory HTTP client");
Self {
+76 -33
View File
@@ -225,10 +225,17 @@ struct CacheBound {
impl CacheBound {
fn new(cache: &Arc<dyn Cache>, sso_config: &'static SsoConfig) -> Self {
tranquil_lexicon::LexiconRegistry::global().set_shared_cache(cache.clone());
let fetch_policy = tranquil_types::ReachPolicy::from_private_fetch(
tranquil_config::get().server.allow_private_fetch,
);
Self {
did_resolver: Arc::new(DidResolver::new(cache.clone())),
cross_pds_oauth: Arc::new(CrossPdsOAuthClient::new(cache.clone())),
client_metadata_cache: ClientMetadataCache::new(cache.clone(), CLIENT_METADATA_TTL),
cross_pds_oauth: Arc::new(CrossPdsOAuthClient::new(cache.clone(), fetch_policy)),
client_metadata_cache: ClientMetadataCache::new(
cache.clone(),
CLIENT_METADATA_TTL,
fetch_policy,
),
sso_manager: SsoManager::from_config(sso_config, cache.clone()),
}
}
@@ -514,42 +521,78 @@ struct TranquilStoreWiring {
}
fn migrate_delegation_preset_scopes(metastore: &tranquil_store::metastore::Metastore) {
const MARKER_KEY: &str = "migration:delegation_preset_scopes_v1";
const LEGACY_EDITOR_SCOPES: &str =
const V1_MARKER: &str = "migration:delegation_preset_scopes_v1";
const V1_LEGACY_OWNER: &str = "atproto";
const V1_LEGACY_EDITOR: &str =
"repo:*?action=create repo:*?action=update repo:*?action=delete blob:*/*";
// v2 adds `rpc:*` to the writing presets
// Without it delegated sessions can't hold rpc scopes
const V2_MARKER: &str = "migration:delegation_preset_scopes_v2";
const V2_LEGACY_OWNER: &str = "atproto repo:* blob:*/* identity:* account:*?action=manage";
const V2_LEGACY_ADMIN: &str = "atproto repo:* blob:*/* account:*?action=manage";
const V2_LEGACY_EDITOR: &str =
"atproto repo:*?action=create repo:*?action=update repo:*?action=delete blob:*/*";
// v3 adds the `transition:` scopes to the owner preset
// Without them an owner-level delegation withholds all transition scopes
const V3_MARKER: &str = "migration:delegation_preset_scopes_v3";
const V3_LEGACY_OWNER: &str =
"atproto repo:* blob:*/* rpc:* identity:* account:*?action=manage";
let passes: [(&str, &[(&str, &str)]); 3] = [
(
V1_MARKER,
&[
(V1_LEGACY_OWNER, crate::delegation::OWNER_FULL_SCOPES),
(V1_LEGACY_EDITOR, crate::delegation::EDITOR_FULL_SCOPES),
],
),
(
V2_MARKER,
&[
(V2_LEGACY_OWNER, crate::delegation::OWNER_FULL_SCOPES),
(V2_LEGACY_ADMIN, crate::delegation::ADMIN_FULL_SCOPES),
(V2_LEGACY_EDITOR, crate::delegation::EDITOR_FULL_SCOPES),
],
),
(
V3_MARKER,
&[(V3_LEGACY_OWNER, crate::delegation::OWNER_FULL_SCOPES)],
),
];
let infra = metastore.infra_ops();
if infra.get_server_config(MARKER_KEY).ok().flatten().is_some() {
return;
}
let ops = metastore.delegation_ops();
let owners = match ops.remap_grant_scopes("atproto", crate::delegation::OWNER_FULL_SCOPES) {
Ok(n) => n,
Err(e) => {
tracing::error!(error = ?e, "delegation owner-scope migration failed, will retry on next start");
return;
}
};
let editors = match ops
.remap_grant_scopes(LEGACY_EDITOR_SCOPES, crate::delegation::EDITOR_FULL_SCOPES)
{
Ok(n) => n,
Err(e) => {
tracing::error!(error = ?e, "delegation editor-scope migration failed, will retry on next start");
return;
}
};
if owners + editors > 0 {
tracing::info!(
owners,
editors,
"upgraded legacy delegation grants to preset scopes"
);
}
if let Err(e) = infra.upsert_server_config(MARKER_KEY, "done") {
tracing::error!(error = ?e, "failed to record delegation scope migration marker, will retry");
for (marker, remaps) in passes {
if infra.get_server_config(marker).ok().flatten().is_some() {
continue;
}
let mut migrated = 0usize;
for (from, to) in remaps {
match ops.remap_grant_scopes(from, to) {
Ok(n) => migrated += n,
Err(e) => {
tracing::error!(error = ?e, marker, from, "delegation scope migration failed, will retry on next start");
return;
}
}
}
if migrated > 0 {
tracing::info!(
marker,
migrated,
"upgraded legacy delegation grants to preset scopes"
);
}
if let Err(e) = infra.upsert_server_config(marker, "done") {
tracing::error!(error = ?e, marker, "failed to record delegation scope migration marker, will retry");
return;
}
}
}
-4
View File
@@ -89,10 +89,6 @@ pub const HEADER_ATPROTO_CONTENT_LABELERS: HeaderName =
HeaderName::from_static("atproto-content-labelers");
#[cfg(feature = "bsky-support")]
pub const HEADER_X_BSKY_TOPICS: HeaderName = HeaderName::from_static("x-bsky-topics");
#[cfg(feature = "bsky-support")]
pub const CORS_BSKY_ALLOW_HEADERS: [HeaderName; 1] = [HEADER_X_BSKY_TOPICS];
#[cfg(not(feature = "bsky-support"))]
pub const CORS_BSKY_ALLOW_HEADERS: [HeaderName; 0] = [];
pub fn get_header_str(
headers: &HeaderMap,
+3 -2
View File
@@ -14,7 +14,7 @@ use tranquil_pds::auth::{
get_did_from_token, get_jti_from_token, verify_access_token, verify_refresh_token,
verify_token,
};
use tranquil_types::{Did, Nsid};
use tranquil_types::{Did, DidRef, Nsid};
fn generate_user_key() -> Vec<u8> {
let secret_key = SecretKey::random(&mut OsRng);
@@ -169,8 +169,9 @@ fn test_token_type_confusion() {
let service_token = create_service_token(
&did,
&Did::new("did:web:nel.pet").expect("valid DID"),
&DidRef::new("did:web:nel.pet").expect("valid DID reference"),
Some(&Nsid::new("cafe.oyster.method").expect("valid NSID")),
None,
&key_bytes,
)
.unwrap();
+57
View File
@@ -1270,6 +1270,63 @@ async fn test_granular_scope_rpc_specific_method() {
);
}
#[tokio::test]
async fn test_granular_scope_rpc_aud_with_service_id() {
let url = base_url().await;
let http_client = client();
let (token, _, _) = get_oauth_token_with_scope(
"rpc:app.bsky.feed.getTimeline?aud=did:web:api.bsky.app#bsky_appview",
)
.await;
let allowed_res = http_client
.get(format!("{}/xrpc/com.atproto.server.getServiceAuth", url))
.bearer_auth(&token)
.query(&[
("aud", "did:web:api.bsky.app#bsky_appview"),
("lxm", "app.bsky.feed.getTimeline"),
])
.send()
.await
.unwrap();
assert_eq!(
allowed_res.status(),
StatusCode::OK,
"the granted service id must cover a request naming it"
);
let body: Value = allowed_res.json().await.unwrap();
let service_token = body["token"].as_str().unwrap();
let payload = service_token.split('.').nth(1).unwrap();
let claims: Value = serde_json::from_slice(&URL_SAFE_NO_PAD.decode(payload).unwrap()).unwrap();
assert_eq!(
claims["aud"], "did:web:api.bsky.app#bsky_appview",
"the service id must reach the signed claim even on the granular scope path"
);
for (aud, reason) in [
(
"did:web:api.bsky.app#atproto_labeler",
"a scope for the appview must not mint tokens for the labeler on the same DID",
),
(
"did:web:api.bsky.app",
"a scope for one service must not widen to the whole DID",
),
(
"did:web:other.example#bsky_appview",
"a service id must not smuggle in a different audience",
),
] {
let blocked_res = http_client
.get(format!("{}/xrpc/com.atproto.server.getServiceAuth", url))
.bearer_auth(&token)
.query(&[("aud", aud), ("lxm", "app.bsky.feed.getTimeline")])
.send()
.await
.unwrap();
assert_eq!(blocked_res.status(), StatusCode::FORBIDDEN, "{reason}");
}
}
#[tokio::test]
async fn test_oauth_metadata_includes_prompt_values_supported() {
let url = base_url().await;
@@ -280,10 +280,11 @@ async fn test_delegated_consent_marks_restricted_scopes() {
seed_permission_set(PERMISSION_SET_NSID, PERMISSION_SET_GRANULAR_SCOPE).await;
let scope = format!("atproto include:{}", PERMISSION_SET_NSID);
let (_session, consent_body, _mock) = create_delegated_session_with_scope(
let (_session, consent_body, _mock) = create_delegated_session_with_grant(
"psr",
"https://example.com/permset-restricted-callback",
&scope,
"atproto repo:*",
)
.await;
@@ -327,7 +328,53 @@ async fn test_delegated_consent_marks_restricted_scopes() {
assert_eq!(
rpc["restricted"].as_bool(),
Some(true),
"rpc scope is not conferred by the OWNER grant and must be restricted"
"rpc scope is not conferred by a repo-only grant and must be restricted"
);
}
#[tokio::test]
async fn test_delegated_owner_grant_confers_rpc_scopes() {
seed_permission_set(PERMISSION_SET_NSID, PERMISSION_SET_GRANULAR_SCOPE).await;
let scope = format!("atproto include:{}", PERMISSION_SET_NSID);
let (_session, consent_body, _mock) = create_delegated_session_with_scope(
"pso",
"https://example.com/permset-owner-rpc-callback",
&scope,
)
.await;
let set_entry = consent_body["permission_sets"]
.as_array()
.and_then(|sets| {
sets.iter()
.find(|s| s["nsid"].as_str() == Some(PERMISSION_SET_NSID))
})
.unwrap_or_else(|| {
panic!(
"expected a permission_sets entry for '{}'. Got: {:?}",
PERMISSION_SET_NSID, consent_body
)
});
let expanded = set_entry["expanded"]
.as_array()
.expect("permission_sets entry should have an expanded array");
let rpc = expanded
.iter()
.find(|s| s["scope"].as_str() == Some("rpc:io.atcr.getManifest?aud=*"))
.expect("expanded[] should list the rpc scope");
assert_eq!(
rpc["restricted"].as_bool(),
Some(false),
"the OWNER grant includes rpc:* and must confer rpc scopes"
);
assert_eq!(
set_entry["restricted"].as_bool(),
Some(false),
"a fully-covered set must not be flagged restricted"
);
}
@@ -181,6 +181,52 @@ fn test_permissions_rpc_lxm_wildcard_prefix() {
assert!(!perms.allows_rpc("did:web:api.bsky.app", &c("app.bsky.actor.getProfile")));
}
#[test]
fn test_permissions_rpc_aud_service_id_must_match_verbatim() {
let perms =
ScopePermissions::from_scope_string(Some("rpc:app.bsky.feed.*?aud=did:web:api.bsky.app"));
assert!(
perms.allows_rpc("did:web:api.bsky.app", &c("app.bsky.feed.getTimeline")),
"the granted audience must cover itself"
);
assert!(
!perms.allows_rpc(
"did:web:api.bsky.app#bsky_appview",
&c("app.bsky.feed.getTimeline")
),
"a bare DID grants nothing to the services listed under it"
);
assert!(
!perms.allows_rpc(
"did:web:other.example#bsky_appview",
&c("app.bsky.feed.getTimeline")
),
"a service id must not smuggle in a different audience"
);
let fragment_scope = ScopePermissions::from_scope_string(Some(
"rpc:app.bsky.feed.*?aud=did:web:api.bsky.app%23bsky_appview",
));
assert!(
fragment_scope.allows_rpc(
"did:web:api.bsky.app#bsky_appview",
&c("app.bsky.feed.getTimeline")
),
"the granted service id must cover itself"
);
assert!(
!fragment_scope.allows_rpc("did:web:api.bsky.app", &c("app.bsky.feed.getTimeline")),
"a scope granted for one service must not widen to the whole DID"
);
assert!(
!fragment_scope.allows_rpc(
"did:web:api.bsky.app#atproto_labeler",
&c("app.bsky.feed.getTimeline")
),
"the appview and the labeler are different audiences even on one DID"
);
}
#[test]
fn test_delegation_intersect_mismatched_params_empty() {
let result = intersect_scopes("repo:*?action=create", "repo:*?action=delete");
+40
View File
@@ -132,6 +132,46 @@ async fn test_service_auth() {
let lxm_payload = URL_SAFE_NO_PAD.decode(lxm_parts[1]).unwrap();
let lxm_claims: Value = serde_json::from_slice(&lxm_payload).unwrap();
assert_eq!(lxm_claims["lxm"], "com.atproto.repo.getRecord");
let fragment_res = client
.get(format!("{}/xrpc/com.atproto.server.getServiceAuth", base))
.bearer_auth(&access_jwt)
.query(&[
("aud", "did:web:example.com#colibri_appview"),
("lxm", "com.atproto.repo.getRecord"),
])
.send()
.await
.unwrap();
assert_eq!(fragment_res.status(), StatusCode::OK);
let fragment_body: Value = fragment_res.json().await.unwrap();
let fragment_token = fragment_body["token"].as_str().unwrap();
let fragment_parts: Vec<&str> = fragment_token.split('.').collect();
let fragment_payload = URL_SAFE_NO_PAD.decode(fragment_parts[1]).unwrap();
let fragment_claims: Value = serde_json::from_slice(&fragment_payload).unwrap();
assert_eq!(
fragment_claims["aud"], "did:web:example.com#colibri_appview",
"the service id must survive into the signed claim so the receiver can match it \
against its own DID document"
);
let empty_fragment = client
.get(format!("{}/xrpc/com.atproto.server.getServiceAuth", base))
.bearer_auth(&access_jwt)
.query(&[("aud", "did:web:example.com#")])
.send()
.await
.unwrap();
assert_eq!(empty_fragment.status(), StatusCode::BAD_REQUEST);
let double_fragment = client
.get(format!("{}/xrpc/com.atproto.server.getServiceAuth", base))
.bearer_auth(&access_jwt)
.query(&[("aud", "did:web:example.com#a#b")])
.send()
.await
.unwrap();
assert_eq!(double_fragment.status(), StatusCode::BAD_REQUEST);
let unauth = client
.get(format!("{}/xrpc/com.atproto.server.getServiceAuth", base))
.query(&[("aud", "did:web:example.com")])
+28 -13
View File
@@ -177,8 +177,6 @@ impl ScopePermissions {
return Ok(());
}
let aud_base = aud.split('#').next().unwrap_or(aud);
let has_permission = self.find_rpc_scopes().any(|rpc_scope| {
let lxm_matches = match &rpc_scope.lxm {
None => true,
@@ -193,10 +191,7 @@ impl ScopePermissions {
let aud_matches = match &rpc_scope.aud {
None => true,
Some(scope_aud) if scope_aud == "*" => true,
Some(scope_aud) => {
let scope_aud_base = scope_aud.split('#').next().unwrap_or(scope_aud);
scope_aud_base == aud_base
}
Some(scope_aud) => scope_aud == aud,
};
lxm_matches && aud_matches
@@ -558,27 +553,47 @@ mod tests {
let perms = ScopePermissions::from_scope_string(Some(
"rpc:app.bsky.feed.getAuthorFeed?aud=did:web:api.bsky.app#bsky_appview",
));
assert!(perms.allows_rpc("did:web:api.bsky.app", &c("app.bsky.feed.getAuthorFeed")));
assert!(perms.allows_rpc(
"did:web:api.bsky.app#bsky_appview",
&c("app.bsky.feed.getAuthorFeed")
));
assert!(perms.allows_rpc(
"did:web:api.bsky.app#other_service",
&c("app.bsky.feed.getAuthorFeed")
));
assert!(
!perms.allows_rpc("did:web:api.bsky.app", &c("app.bsky.feed.getAuthorFeed")),
"a scope naming one service must not cover the whole DID"
);
assert!(
!perms.allows_rpc(
"did:web:api.bsky.app#atproto_labeler",
&c("app.bsky.feed.getAuthorFeed")
),
"a scope naming one service must not cover a sibling service on the same DID"
);
assert!(!perms.allows_rpc("did:web:other.app", &c("app.bsky.feed.getAuthorFeed")));
assert!(!perms.allows_rpc("did:web:api.bsky.app", &c("app.bsky.feed.getTimeline")));
}
#[test]
fn test_rpc_scope_without_fragment_matches_with_fragment() {
fn test_rpc_scope_without_fragment_does_not_cover_service_ids() {
let perms = ScopePermissions::from_scope_string(Some(
"rpc:app.bsky.feed.getAuthorFeed?aud=did:web:api.bsky.app",
));
assert!(perms.allows_rpc("did:web:api.bsky.app", &c("app.bsky.feed.getAuthorFeed")));
assert!(
!perms.allows_rpc(
"did:web:api.bsky.app#bsky_appview",
&c("app.bsky.feed.getAuthorFeed")
),
"the audience is compared verbatim, so a bare DID grants nothing to its services"
);
}
#[test]
fn test_rpc_scope_aud_wildcard_covers_service_ids() {
let perms =
ScopePermissions::from_scope_string(Some("rpc:app.bsky.feed.getAuthorFeed?aud=*"));
assert!(perms.allows_rpc("did:web:api.bsky.app", &c("app.bsky.feed.getAuthorFeed")));
assert!(perms.allows_rpc(
"did:web:api.bsky.app#bsky_appview",
"did:web:api.bsky.app#atproto_labeler",
&c("app.bsky.feed.getAuthorFeed")
));
}
+173
View File
@@ -225,6 +225,52 @@ impl Did {
}
}
const DID_REF_MAX_LEN: usize = 2048;
const SERVICE_ID_MAX_LEN: usize = 128;
const fn is_pchar(b: u8) -> bool {
b.is_ascii_alphanumeric() || matches!(b, b'-' | b'.' | b'_' | b'~')
}
fn is_service_id(s: &str) -> bool {
!s.is_empty() && s.len() <= SERVICE_ID_MAX_LEN && s.bytes().all(is_pchar)
}
validated_string_newtype! {
pub struct DidRef;
error = DidRefError;
label = "DID reference";
validator = |s| {
if s.len() > DID_REF_MAX_LEN {
return Err(());
}
match s.split_once('#') {
None => jacquard_common::types::string::Did::new(s)
.map(|v| v.as_str().to_owned())
.map_err(|_| ()),
Some((did, service_id)) => {
if !is_service_id(service_id) {
return Err(());
}
let base = jacquard_common::types::string::Did::new(did).map_err(|_| ())?;
Ok(format!("{}#{}", base.as_str(), service_id))
}
}
};
}
impl From<Did> for DidRef {
fn from(did: Did) -> Self {
Self(did.0)
}
}
impl From<&Did> for DidRef {
fn from(did: &Did) -> Self {
Self(did.0.clone())
}
}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, sqlx::Type)]
#[serde(transparent)]
#[sqlx(transparent)]
@@ -1069,6 +1115,13 @@ impl ReachPolicy {
pub const DEBUG_LOOPBACK: ReachPolicy = ReachPolicy::AllowLoopback;
#[cfg(not(debug_assertions))]
pub const DEBUG_LOOPBACK: ReachPolicy = ReachPolicy::GlobalOnly;
pub fn from_private_fetch(allow_private: bool) -> ReachPolicy {
match allow_private {
true => ReachPolicy::AllowPrivate,
false => ReachPolicy::DEBUG_LOOPBACK,
}
}
}
pub trait UrlKind {
@@ -1588,6 +1641,126 @@ mod validated_newtype_tests {
);
}
#[test]
fn a_bare_did_ref_names_no_service() {
let aud = DidRef::new("did:plc:abc").unwrap();
assert_eq!(
aud.as_str(),
"did:plc:abc",
"an absent fragment is not the same as an empty one, so nothing may be appended"
);
}
#[test]
fn a_did_ref_keeps_the_service_id_it_was_given() {
let aud = DidRef::new("did:web:api.colibri.social#colibri_appview").unwrap();
assert_eq!(
aud.as_str(),
"did:web:api.colibri.social#colibri_appview",
"the fragment is what tells the receiver which of its services was audienced, \
so it must survive entirely"
);
}
#[test]
fn a_did_ref_normalizes_its_did_half_the_way_a_did_does() {
assert_eq!(
DidRef::new("at://did:plc:abc#colibri_appview")
.unwrap()
.as_str(),
"did:plc:abc#colibri_appview"
);
assert_eq!(
DidRef::new("did:plc:def").unwrap().as_str(),
Did::new("did:plc:def").unwrap().as_str(),
"a fragmentless DidRef must be byte-identical to the Did it replaces"
);
}
#[test]
fn a_did_ref_rejects_anything_that_cannot_name_one_service() {
for bad in [
"did:web:oyster.cafe#",
"did:web:oyster.cafe#a#b",
"did:web:oyster.cafe# whelk",
"did:web:oyster.cafe#a/b",
"did:web:oyster.cafe#a?b",
"did:web:oyster.cafe#<script>",
"did:web:oyster.cafe#a%20b",
"did:web:oyster.cafe#a%5Fb",
"did:web:oyster.cafe#a\"b",
"did:web:oyster.cafe#a[b]",
"did:web:oyster.cafe#atproto_p\u{200b}ds",
"did:web:oyster.cafe#\u{feff}atproto_pds",
"not-a-did#colibri_appview",
"#colibri_appview",
] {
assert!(
DidRef::new(bad).is_err(),
"{bad} should not parse as a DID reference"
);
}
}
#[test]
fn a_did_ref_does_not_second_guess_the_service_ids_it_has_not_seen() {
for good in [
"did:web:oyster.cafe#atproto_pds",
"did:web:oyster.cafe#atproto_labeler",
"did:plc:abc#bsky_chat",
"did:web:oyster.cafe#whelk.v2",
"did:web:oyster.cafe#atproto~pds",
] {
assert!(
DidRef::new(good).is_ok(),
"{good} names a service the receiver resolves in its own DID document, \
so rejecting it here would recreate the bug this type exists to fix"
);
}
}
#[test]
fn an_over_long_service_id_is_rejected_before_the_did_ref_bound_bites() {
let did = "did:web:oyster.cafe";
let longest_accepted = format!("{did}#{}", "a".repeat(SERVICE_ID_MAX_LEN));
assert!(DidRef::new(&longest_accepted).is_ok());
let one_too_long = format!("{did}#{}", "a".repeat(SERVICE_ID_MAX_LEN + 1));
assert!(
DidRef::new(&one_too_long).is_err(),
"no service names itself in more than {SERVICE_ID_MAX_LEN} bytes, and the 2048-byte \
aud bound is far too loose to catch a fragment used as a payload"
);
}
#[test]
fn an_over_long_did_ref_is_rejected() {
let did = format!("did:plc:{}", "a".repeat(DID_REF_MAX_LEN - "did:plc:".len()));
assert_eq!(did.len(), DID_REF_MAX_LEN);
assert!(
Did::new(&did).is_ok(),
"the DID half has to stand on its own, or the bound below proves nothing"
);
assert!(
DidRef::new(format!("{did}#x")).is_err(),
"the lexicon bounds aud at {DID_REF_MAX_LEN} bytes, which a whole DID plus the shortest service id already exceeds"
);
}
#[test]
fn a_did_ref_built_from_a_did_names_no_service() {
let did = Did::new("did:plc:def").unwrap();
assert_eq!(
DidRef::from(&did).as_str(),
did.as_str(),
"a DID that named no service must not gain one on the way in"
);
assert_eq!(
DidRef::from(did.clone()).as_str(),
did.as_str(),
"the owned conversion must land on the same bytes as the borrowed one"
);
}
#[test]
fn the_earliest_tid_sorts_below_every_generated_tid() {
let earliest = Tid::earliest();
+2
View File
@@ -96,6 +96,8 @@ services:
depends_on:
db:
condition: service_healthy
ports:
- "2582:2582"
mailpit:
profiles: [dev]
+1 -1
View File
@@ -19,4 +19,4 @@ Fixing this isn't too hard. You want to figure out what `seq` the relays think y
>
> #protip double check that the `seq` is actually misaligned with your PDS by getting the latest `seq` from your PDS using PDSls's firehose feature and a cursor value of `0` when connecting. If the relays don't have a `seq` that's bigger than your PDSs `seq` then this isn't your issue!
Now you need to update the PDS to use a `seq` that's *at least* one above the highest `seq` any of the relays have. Currently doing this on Tranquil depends on your used storage backend. For the default Postgres repo store you want to shutdown Tranquil itself, open the DB in `psql` and run `SELECT setval('firehose_seq, <new updated seq value>');` and the start Tranquil back up. Take a few actions to make sure new events get sent out. Everything should work once all the downstream consumers have had a chance to resync. You can double check with debug.hose.cam that all the relays show your PDS as `active`. Some might need a lil push with a request crawl, luckily debug.hose.cam also makes that easy :p (ignore that it says it failed to issue a request crawl, CORS is fickle).
Now you need to update the PDS to use a `seq` that's *at least* one above the highest `seq` any of the relays have. Currently doing this on Tranquil depends on your used storage backend. For the default Postgres repo store you want to shutdown Tranquil itself, open the DB in `psql` and run `SELECT setval('firehose_seq', <new updated seq integer value>);` and the start Tranquil back up. Take a few actions to make sure new events get sent out. Everything should work once all the downstream consumers have had a chance to resync. You can double check with debug.hose.cam that all the relays show your PDS as `active`. Some might need a lil push with a request crawl, luckily debug.hose.cam also makes that easy :p (ignore that it says it failed to issue a request crawl, CORS is fickle).
+14
View File
@@ -69,6 +69,13 @@
# Default value: false
#disable_rate_limiting = false
# Allow outbound fetches to private network addresses. Useful for local development using docker compose.
#
# Can also be specified via environment variable `ALLOW_PRIVATE_FETCH`.
#
# Default value: false
#allow_private_fetch = false
# Skip the verified-comms-channel gate for login and record writes.
# Please keep this off unless you're an invite-only PDS!
#
@@ -501,6 +508,13 @@
# Default value: 60
#total_timeout_secs = 60
# Apply Atmos/Comail.at categories for headers to be categorized appropriately.
#
# Can also be specified via environment variable `MAIL_APPLY_ATMOS_CATEGORIES`.
#
# Default value: false
#apply_atmos_categories = false
[email.direct_mx]
# Per-command SMTP timeout in seconds.
#
+6
View File
@@ -18,6 +18,12 @@ http:
service: frontend
priority: 1
tls: {}
handles:
rule: 'HostRegexp(`^.+\.pds\.test$`)'
entryPoints:
- websecure
service: backend
tls: {}
services:
backend: