fix: did:web also uses handle domains not hostname

This commit is contained in:
Lewis
2026-03-09 19:23:35 +00:00
committed by Tangled
parent f78b004df3
commit 2c8568b207
16 changed files with 226 additions and 92 deletions
Generated
+15 -15
View File
@@ -6094,7 +6094,7 @@ dependencies = [
[[package]]
name = "tranquil-auth"
version = "0.3.0"
version = "0.3.1"
dependencies = [
"anyhow",
"base32",
@@ -6117,7 +6117,7 @@ dependencies = [
[[package]]
name = "tranquil-cache"
version = "0.3.0"
version = "0.3.1"
dependencies = [
"async-trait",
"base64 0.22.1",
@@ -6131,7 +6131,7 @@ dependencies = [
[[package]]
name = "tranquil-comms"
version = "0.3.0"
version = "0.3.1"
dependencies = [
"async-trait",
"base64 0.22.1",
@@ -6146,7 +6146,7 @@ dependencies = [
[[package]]
name = "tranquil-config"
version = "0.3.0"
version = "0.3.1"
dependencies = [
"confique",
"serde",
@@ -6154,7 +6154,7 @@ dependencies = [
[[package]]
name = "tranquil-crypto"
version = "0.3.0"
version = "0.3.1"
dependencies = [
"aes-gcm",
"base64 0.22.1",
@@ -6170,7 +6170,7 @@ dependencies = [
[[package]]
name = "tranquil-db"
version = "0.3.0"
version = "0.3.1"
dependencies = [
"async-trait",
"chrono",
@@ -6187,7 +6187,7 @@ dependencies = [
[[package]]
name = "tranquil-db-traits"
version = "0.3.0"
version = "0.3.1"
dependencies = [
"async-trait",
"base64 0.22.1",
@@ -6203,7 +6203,7 @@ dependencies = [
[[package]]
name = "tranquil-infra"
version = "0.3.0"
version = "0.3.1"
dependencies = [
"async-trait",
"bytes",
@@ -6214,7 +6214,7 @@ dependencies = [
[[package]]
name = "tranquil-oauth"
version = "0.3.0"
version = "0.3.1"
dependencies = [
"anyhow",
"axum",
@@ -6237,7 +6237,7 @@ dependencies = [
[[package]]
name = "tranquil-pds"
version = "0.3.0"
version = "0.3.1"
dependencies = [
"aes-gcm",
"anyhow",
@@ -6324,7 +6324,7 @@ dependencies = [
[[package]]
name = "tranquil-repo"
version = "0.3.0"
version = "0.3.1"
dependencies = [
"bytes",
"cid",
@@ -6336,7 +6336,7 @@ dependencies = [
[[package]]
name = "tranquil-ripple"
version = "0.3.0"
version = "0.3.1"
dependencies = [
"async-trait",
"backon",
@@ -6361,7 +6361,7 @@ dependencies = [
[[package]]
name = "tranquil-scopes"
version = "0.3.0"
version = "0.3.1"
dependencies = [
"axum",
"futures",
@@ -6377,7 +6377,7 @@ dependencies = [
[[package]]
name = "tranquil-storage"
version = "0.3.0"
version = "0.3.1"
dependencies = [
"async-trait",
"aws-config",
@@ -6394,7 +6394,7 @@ dependencies = [
[[package]]
name = "tranquil-types"
version = "0.3.0"
version = "0.3.1"
dependencies = [
"chrono",
"cid",
+1 -1
View File
@@ -19,7 +19,7 @@ members = [
]
[workspace.package]
version = "0.3.0"
version = "0.3.1"
edition = "2024"
license = "AGPL-3.0-or-later"
@@ -140,7 +140,8 @@ pub async fn create_account(
}
}
let available_domains = tranquil_config::get().server.available_user_domain_list();
let cfg = tranquil_config::get();
let available_domains = cfg.server.available_user_domain_list();
let matched_domain = available_domains
.iter()
.filter(|d| input.handle.ends_with(&format!(".{}", d)))
@@ -163,23 +164,10 @@ pub async fn create_account(
}
}
} else {
if input.handle.contains(' ') || input.handle.contains('\t') {
return ApiError::InvalidRequest("Handle cannot contain spaces".into()).into_response();
match crate::api::validation::validate_full_domain_handle(&input.handle) {
Ok(h) => h,
Err(e) => return ApiError::from(e).into_response(),
}
if let Some(c) = input
.handle
.chars()
.find(|c| !c.is_ascii_alphanumeric() && *c != '.' && *c != '-')
{
return ApiError::InvalidRequest(format!("Handle contains invalid character: {}", c))
.into_response();
}
let handle_lower = input.handle.to_lowercase();
if crate::moderation::has_explicit_slur(&handle_lower) {
return ApiError::InvalidRequest("Inappropriate language in handle".into())
.into_response();
}
handle_lower
};
let email: Option<String> = input
.email
@@ -234,7 +222,7 @@ pub async fn create_account(
},
})
};
let hostname = &tranquil_config::get().server.hostname;
let hostname = &cfg.server.hostname;
let pds_endpoint = format!("https://{}", hostname);
let handle = match matched_domain {
Some(domain) => format!("{}.{}", validated_short_handle, domain),
@@ -274,10 +262,8 @@ pub async fn create_account(
if !crate::api::server::meta::is_self_hosted_did_web_enabled() {
return ApiError::SelfHostedDidWebDisabled.into_response();
}
let pds_hostname = tranquil_config::get().server.hostname_without_port();
let subdomain_host = format!("{}.{}", input.handle, pds_hostname);
let encoded_subdomain = subdomain_host.replace(':', "%3A");
let self_hosted_did = format!("did:web:{}", encoded_subdomain);
let encoded_handle = handle.replace(':', "%3A");
let self_hosted_did = format!("did:web:{}", encoded_handle);
info!(did = %self_hosted_did, "Creating self-hosted did:web account (subdomain)");
self_hosted_did
}
+16 -14
View File
@@ -122,17 +122,21 @@ pub fn get_public_key_multibase(key_bytes: &[u8]) -> Result<String, KeyError> {
}
pub async fn well_known_did(State(state): State<AppState>, headers: HeaderMap) -> Response {
let hostname = &tranquil_config::get().server.hostname;
let hostname_without_port = tranquil_config::get().server.hostname_without_port();
let cfg = tranquil_config::get();
let hostname = &cfg.server.hostname;
let hostname_without_port = cfg.server.hostname_without_port();
let host_header = get_header_str(&headers, http::header::HOST).unwrap_or(hostname);
let host_without_port = host_header.split(':').next().unwrap_or(host_header);
if host_without_port != hostname_without_port
&& host_without_port.ends_with(&format!(".{}", hostname_without_port))
{
let handle = host_without_port
.strip_suffix(&format!(".{}", hostname_without_port))
.unwrap_or(host_without_port);
return serve_subdomain_did_doc(&state, handle, hostname).await;
if host_without_port != hostname_without_port {
let is_subdomain = cfg
.server
.available_user_domain_list()
.into_iter()
.chain(std::iter::once(hostname_without_port.to_string()))
.any(|d| host_without_port.ends_with(&format!(".{}", d)));
if is_subdomain {
return serve_handle_did_doc(&state, host_without_port, hostname).await;
}
}
let did = if hostname.contains(':') {
format!("did:web:{}", hostname.replace(':', "%3A"))
@@ -151,11 +155,9 @@ pub async fn well_known_did(State(state): State<AppState>, headers: HeaderMap) -
.into_response()
}
async fn serve_subdomain_did_doc(state: &AppState, subdomain: &str, hostname: &str) -> Response {
let hostname_for_handles = hostname.split(':').next().unwrap_or(hostname);
let subdomain_host = format!("{}.{}", subdomain, hostname_for_handles);
let encoded_subdomain = subdomain_host.replace(':', "%3A");
let expected_did = format!("did:web:{}", encoded_subdomain);
async fn serve_handle_did_doc(state: &AppState, handle: &str, hostname: &str) -> Response {
let encoded_handle = handle.replace(':', "%3A");
let expected_did = format!("did:web:{}", encoded_handle);
let expected_did_typed: crate::types::Did = match expected_did.parse() {
Ok(d) => d,
Err(_) => return ApiError::InvalidRequest("Invalid DID format".into()).into_response(),
+18 -6
View File
@@ -215,15 +215,12 @@ mod tests {
use super::*;
#[test]
fn test_ssrf_safe_https() {
assert!(is_ssrf_safe("https://api.bsky.app/xrpc/test").is_ok());
assert!(is_ssrf_safe("https://1.1.1.1/xrpc/test").is_ok());
}
#[test]
fn test_ssrf_blocks_http_by_default() {
let result = is_ssrf_safe("http://external.example.com/xrpc/test");
assert!(matches!(
result,
Err(SsrfError::InsecureProtocol(_)) | Err(SsrfError::DnsResolutionFailed(_))
));
let result = is_ssrf_safe("http://93.184.216.34/xrpc/test");
assert!(matches!(result, Err(SsrfError::InsecureProtocol(_))));
}
#[test]
fn test_ssrf_allows_localhost_http() {
@@ -231,6 +228,21 @@ mod tests {
assert!(is_ssrf_safe("http://localhost:8080/test").is_ok());
}
#[test]
fn test_ssrf_blocks_non_unicast_ip() {
assert!(matches!(
is_ssrf_safe("https://0.0.0.0/test"),
Err(SsrfError::NonUnicastIp(_))
));
assert!(matches!(
is_ssrf_safe("https://224.0.0.1/test"),
Err(SsrfError::NonUnicastIp(_))
));
assert!(matches!(
is_ssrf_safe("https://255.255.255.255/test"),
Err(SsrfError::NonUnicastIp(_))
));
}
#[test]
fn test_validate_at_uri() {
let result = validate_at_uri("at://did:plc:test/app.bsky.feed.post/abc123");
assert!(result.is_ok());
@@ -112,8 +112,9 @@ pub async fn create_passkey_account(
.map(|d| d.starts_with("did:web:"))
.unwrap_or(false);
let hostname = &tranquil_config::get().server.hostname;
let available_domains = tranquil_config::get().server.available_user_domain_list();
let cfg = tranquil_config::get();
let hostname = &cfg.server.hostname;
let available_domains = cfg.server.available_user_domain_list();
let matched_domain = available_domains
.iter()
.filter(|d| input.handle.ends_with(&format!(".{}", d)))
@@ -134,7 +135,10 @@ pub async fn create_passkey_account(
}
}
} else {
input.handle.to_lowercase()
match crate::api::validation::validate_full_domain_handle(&input.handle) {
Ok(h) => h,
Err(_) => return ApiError::InvalidHandle(None).into_response(),
}
};
let email = input
@@ -246,10 +250,11 @@ pub async fn create_passkey_account(
let did = match did_type {
"web" => {
let pds_hostname = tranquil_config::get().server.hostname_without_port();
let subdomain_host = format!("{}.{}", input.handle, pds_hostname);
let encoded_subdomain = subdomain_host.replace(':', "%3A");
let self_hosted_did = format!("did:web:{}", encoded_subdomain);
if !crate::api::server::meta::is_self_hosted_did_web_enabled() {
return ApiError::SelfHostedDidWebDisabled.into_response();
}
let encoded_handle = handle.replace(':', "%3A");
let self_hosted_did = format!("did:web:{}", encoded_handle);
info!(did = %self_hosted_did, "Creating self-hosted did:web passkey account");
self_hosted_did
}
+43
View File
@@ -258,6 +258,49 @@ pub enum ReservedHandlePolicy {
Reject,
}
pub fn validate_full_domain_handle(handle: &str) -> Result<String, HandleValidationError> {
let handle = handle.trim();
if handle.is_empty() {
return Err(HandleValidationError::Empty);
}
if handle.contains(' ') || handle.contains('\t') || handle.contains('\n') {
return Err(HandleValidationError::ContainsSpaces);
}
if handle.len() > MAX_HANDLE_LENGTH {
return Err(HandleValidationError::TooLong);
}
if handle
.chars()
.any(|c| !c.is_ascii_alphanumeric() && c != '.' && c != '-')
{
return Err(HandleValidationError::InvalidCharacters);
}
if !handle.contains('.') {
return Err(HandleValidationError::InvalidCharacters);
}
let labels: Vec<&str> = handle.split('.').collect();
let has_invalid_label = labels
.iter()
.any(|label| label.is_empty() || label.len() > MAX_DOMAIN_LABEL_LENGTH || label.starts_with('-') || label.ends_with('-'));
if has_invalid_label {
return Err(HandleValidationError::InvalidCharacters);
}
let handle_lower = handle.to_lowercase();
if crate::moderation::has_explicit_slur(&handle_lower) {
return Err(HandleValidationError::BannedWord);
}
Ok(handle_lower)
}
pub fn validate_short_handle(handle: &str) -> Result<String, HandleValidationError> {
validate_service_handle(handle, ReservedHandlePolicy::Reject)
}
+43 -10
View File
@@ -743,6 +743,7 @@ pub async fn get_pending_registration(
#[derive(Debug, Deserialize)]
pub struct CheckHandleQuery {
pub handle: String,
pub domain: Option<String>,
}
#[derive(Debug, Serialize)]
@@ -773,7 +774,18 @@ pub async fn check_handle_available(
};
let available_domains = tranquil_config::get().server.available_user_domain_list();
let full_handle = format!("{}.{}", validated, &available_domains[0]);
if let Some(ref d) = query.domain {
if !available_domains.iter().any(|ad| ad == d) {
return Err(ApiError::InvalidRequest(
"Unknown user domain".into(),
));
}
}
let domain = query
.domain
.as_deref()
.unwrap_or(&available_domains[0]);
let full_handle = format!("{}.{}", validated, domain);
let handle_typed: crate::types::Handle = match full_handle.parse() {
Ok(h) => h,
Err(_) => return Err(ApiError::InvalidHandle(None)),
@@ -855,12 +867,32 @@ pub async fn complete_registration(
.await?
.ok_or(ApiError::SsoSessionExpired)?;
let hostname = &tranquil_config::get().server.hostname;
let available_domains = tranquil_config::get().server.available_user_domain_list();
let cfg = tranquil_config::get();
let hostname = &cfg.server.hostname;
let available_domains = cfg.server.available_user_domain_list();
let handle = match crate::api::validation::validate_short_handle(&input.handle) {
Ok(h) => format!("{}.{}", h, &available_domains[0]),
Err(_) => return Err(ApiError::InvalidHandle(None)),
let matched_domain = available_domains
.iter()
.filter(|d| input.handle.ends_with(&format!(".{}", d)))
.max_by_key(|d| d.len());
let handle = if !input.handle.contains('.') || matched_domain.is_some() {
let handle_to_validate = match matched_domain {
Some(domain) => input
.handle
.strip_suffix(&format!(".{}", domain))
.unwrap_or(&input.handle),
None => &input.handle,
};
match crate::api::validation::validate_short_handle(handle_to_validate) {
Ok(h) => format!("{}.{}", h, matched_domain.unwrap_or(&available_domains[0])),
Err(_) => return Err(ApiError::InvalidHandle(None)),
}
} else {
match crate::api::validation::validate_full_domain_handle(&input.handle) {
Ok(h) => h,
Err(_) => return Err(ApiError::InvalidHandle(None)),
}
};
let verification_channel = input
@@ -981,10 +1013,11 @@ pub async fn complete_registration(
let did = match did_type {
"web" => {
let pds_hostname = tranquil_config::get().server.hostname_without_port();
let subdomain_host = format!("{}.{}", input.handle, pds_hostname);
let encoded_subdomain = subdomain_host.replace(':', "%3A");
let self_hosted_did = format!("did:web:{}", encoded_subdomain);
if !crate::api::server::meta::is_self_hosted_did_web_enabled() {
return Err(ApiError::SelfHostedDidWebDisabled);
}
let encoded_handle = handle.replace(':', "%3A");
let self_hosted_did = format!("did:web:{}", encoded_handle);
tracing::info!(did = %self_hosted_did, "Creating self-hosted did:web SSO account");
self_hosted_did
}
@@ -133,10 +133,6 @@ pub struct FirehoseConsumer {
}
impl FirehoseConsumer {
pub async fn connect(port: u16) -> Self {
Self::connect_inner(port, None).await
}
pub async fn connect_with_cursor(port: u16, cursor: i64) -> Self {
Self::connect_inner(port, Some(cursor)).await
}
@@ -276,3 +276,37 @@ async fn update_handle_bare_uses_configured_domain() {
"updateHandle with bare handle should use configured domain, not PDS hostname"
);
}
#[tokio::test]
async fn did_web_uses_handle_domain_not_hostname() {
unsafe {
std::env::set_var("ENABLE_PDS_HOSTED_DID_WEB", "true");
}
let client = client();
let base = base_url_with_domain().await;
let short_handle = format!("hd{}", &uuid::Uuid::new_v4().simple().to_string()[..12]);
let payload = json!({
"handle": short_handle,
"email": format!("{}@example.com", short_handle),
"password": "Testpass123!",
"didType": "web"
});
let res = client
.post(format!(
"{}/xrpc/com.atproto.server.createAccount",
base
))
.json(&payload)
.send()
.await
.expect("createAccount request failed");
assert_eq!(res.status(), StatusCode::OK);
let body: Value = res.json().await.expect("Invalid JSON");
let did = body["did"].as_str().expect("No DID in response");
let expected_did = format!("did:web:{}.{}", short_handle, HANDLE_DOMAIN);
assert_eq!(
did, expected_did,
"did:web should use handle domain '{}', not PDS hostname",
HANDLE_DOMAIN
);
}
+14 -6
View File
@@ -34,6 +34,7 @@ export interface RegistrationFlowState {
error: string | null;
submitting: boolean;
pdsHostname: string;
selectedDomain: string;
handleAvailable: boolean | null;
checkingHandle: boolean;
discordInUse: boolean;
@@ -68,6 +69,7 @@ export function createRegistrationFlow(
error: null,
submitting: false,
pdsHostname,
selectedDomain: "",
handleAvailable: null,
checkingHandle: false,
discordInUse: false,
@@ -84,7 +86,10 @@ export function createRegistrationFlow(
}
function getFullHandle(): string {
return `${state.info.handle.trim()}.${state.pdsHostname}`;
const handle = state.info.handle.trim();
if (handle.includes('.')) return handle;
const domain = state.selectedDomain || state.pdsHostname;
return `${handle}.${domain}`;
}
function extractDomain(did: string): string {
@@ -132,10 +137,10 @@ export function createRegistrationFlow(
}
state.checkingHandle = true;
try {
const params = new URLSearchParams({ handle });
if (state.selectedDomain) params.set("domain", state.selectedDomain);
const response = await fetch(
`${getPdsEndpoint()}/oauth/sso/check-handle-available?handle=${
encodeURIComponent(handle)
}`,
`${getPdsEndpoint()}/oauth/sso/check-handle-available?${params}`,
);
const data = await response.json();
state.handleAvailable = data.available === true;
@@ -239,7 +244,7 @@ export function createRegistrationFlow(
}
const result = await api.createAccount({
handle: state.info.handle.trim(),
handle: getFullHandle(),
email: state.info.email.trim(),
password: state.info.password!,
inviteCode: state.info.inviteCode?.trim() || undefined,
@@ -291,7 +296,7 @@ export function createRegistrationFlow(
}
const result = await api.createPasskeyAccount({
handle: unsafeAsHandle(state.info.handle.trim()),
handle: unsafeAsHandle(getFullHandle()),
email: state.info.email?.trim()
? unsafeAsEmail(state.info.email.trim())
: undefined,
@@ -532,6 +537,9 @@ export function createRegistrationFlow(
getPdsDid,
getFullHandle,
extractDomain,
setSelectedDomain(domain: string) {
state.selectedDomain = domain;
},
proceedFromInfo,
selectKeyMode,
+2 -1
View File
@@ -102,6 +102,7 @@
flow = createRegistrationFlow('passkey', hostname)
}
selectedDomain = serverInfo?.availableUserDomains?.[0] || window.location.hostname
if (flow) flow.setSelectedDomain(selectedDomain)
} catch (e) {
console.error('Failed to load server info:', e)
} finally {
@@ -353,7 +354,7 @@
placeholder={$_('register.handlePlaceholder')}
disabled={flow.state.submitting}
onInput={(v) => { flow!.info.handle = v }}
onDomainChange={(d) => { selectedDomain = d }}
onDomainChange={(d) => { selectedDomain = d; flow!.setSelectedDomain(d) }}
/>
{#if fullHandle()}
<p class="hint">{$_('register.handleHint', { values: { handle: fullHandle() } })}</p>
+8 -2
View File
@@ -150,6 +150,7 @@
let checkHandleTimeout: ReturnType<typeof setTimeout> | null = null
$effect(() => {
void selectedDomain
if (checkHandleTimeout) {
clearTimeout(checkHandleTimeout)
}
@@ -167,7 +168,9 @@
handleError = null
try {
const response = await fetch(`/oauth/sso/check-handle-available?handle=${encodeURIComponent(handle)}`)
const params = new URLSearchParams({ handle })
if (selectedDomain) params.set('domain', selectedDomain)
const response = await fetch(`/oauth/sso/check-handle-available?${params}`)
const data = await response.json()
handleAvailable = data.available
if (!data.available && data.reason) {
@@ -222,6 +225,9 @@
return
}
const fullHandle = !handle.includes('.') && selectedDomain
? `${handle.trim()}.${selectedDomain}`
: handle.trim()
submitting = true
try {
@@ -233,7 +239,7 @@
},
body: JSON.stringify({
token,
handle,
handle: fullHandle,
email: email || null,
invite_code: inviteCode || null,
verification_channel: verificationChannel,
+2 -1
View File
@@ -115,6 +115,7 @@
flow = createRegistrationFlow('passkey', hostname)
}
selectedDomain = serverInfo?.availableUserDomains?.[0] || window.location.hostname
if (flow) flow.setSelectedDomain(selectedDomain)
} catch (e) {
console.error('Failed to load server info:', e)
} finally {
@@ -368,7 +369,7 @@
placeholder={$_('register.handlePlaceholder')}
disabled={flow.state.submitting}
onInput={(v) => { flow!.info.handle = v }}
onDomainChange={(d) => { selectedDomain = d }}
onDomainChange={(d) => { selectedDomain = d; flow!.setSelectedDomain(d) }}
/>
{#if flow.info.handle.includes('.')}
<p class="hint warning">{$_('register.handleDotWarning')}</p>
+2 -1
View File
@@ -109,6 +109,7 @@
flow = createRegistrationFlow('password', hostname)
}
selectedDomain = serverInfo?.availableUserDomains?.[0] || window.location.hostname
if (flow) flow.setSelectedDomain(selectedDomain)
} catch (e) {
console.error('Failed to load server info:', e)
} finally {
@@ -313,7 +314,7 @@
placeholder={$_('register.handlePlaceholder')}
disabled={flow.state.submitting}
onInput={(v) => { flow!.info.handle = v }}
onDomainChange={(d) => { selectedDomain = d }}
onDomainChange={(d) => { selectedDomain = d; flow!.setSelectedDomain(d) }}
/>
{#if flow.info.handle.includes('.')}
<p class="hint warning">{$_('register.handleDotWarning')}</p>
@@ -165,6 +165,7 @@
let checkHandleTimeout: ReturnType<typeof setTimeout> | null = null
$effect(() => {
void selectedDomain
if (checkHandleTimeout) {
clearTimeout(checkHandleTimeout)
}
@@ -182,7 +183,9 @@
handleError = null
try {
const response = await fetch(`/oauth/sso/check-handle-available?handle=${encodeURIComponent(handle)}`)
const params = new URLSearchParams({ handle })
if (selectedDomain) params.set('domain', selectedDomain)
const response = await fetch(`/oauth/sso/check-handle-available?${params}`)
const data = await response.json()
handleAvailable = data.available
if (!data.available && data.reason) {
@@ -269,6 +272,9 @@
return
}
const fullHandle = !handle.includes('.') && selectedDomain
? `${handle.trim()}.${selectedDomain}`
: handle.trim()
submitting = true
try {
@@ -280,7 +286,7 @@
},
body: JSON.stringify({
token,
handle,
handle: fullHandle,
email: email || null,
invite_code: inviteCode || null,
verification_channel: verificationChannel,