use anyhow::{Context as _, Result};
use reqwest::Client;
use sqlx::SqlitePool;
use super::discovery::origin_of;
use super::dpop::{self, Endpoint};
use super::keys::SigningKey;
use super::store;
use crate::net;
pub struct PostOutcome {
pub status: u16,
pub body: Vec<u8>,
}
impl std::fmt::Debug for PostOutcome {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("PostOutcome")
.field("status", &self.status)
.field(
"body",
&format_args!("<{} bytes redacted>", self.body.len()),
)
.finish()
}
}
const MAX_RESPONSE_NODES: usize = 64_000;
fn refuse_a_response_explosion(body: &[u8], status: u16) -> Result<()> {
let nodes = crate::atproto::count_structural_chars(body);
anyhow::ensure!(
nodes <= MAX_RESPONSE_NODES,
"the response body (status {status}) counts at least {nodes} structural \
characters, over the {MAX_RESPONSE_NODES} cap for this endpoint — refusing \
before parsing it"
);
Ok(())
}
impl PostOutcome {
pub fn json(&self) -> Result<serde_json::Value> {
refuse_a_response_explosion(&self.body, self.status)?;
serde_json::from_slice(&self.body).with_context(|| {
format!(
"response (status {}, {} bytes) is not valid JSON",
self.status,
self.body.len()
)
})
}
pub fn is_success(&self) -> bool {
(200..300).contains(&self.status)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Retry {
Allowed,
Forbidden,
}
fn next_nonce(
attempt: usize,
retry: Retry,
challenge: Option<String>,
already_sent: Option<&str>,
) -> Option<String> {
if attempt != 0 || retry == Retry::Forbidden {
return None;
}
let fresh = challenge?;
if Some(fresh.as_str()) == already_sent {
return None;
}
Some(fresh)
}
fn request_headers(
proof: &str,
access_token: Option<&str>,
) -> Result<Vec<(reqwest::header::HeaderName, reqwest::header::HeaderValue)>> {
let mut headers = vec![(
reqwest::header::HeaderName::from_static("dpop"),
reqwest::header::HeaderValue::from_str(proof)
.context("DPoP proof is not a valid header value")?,
)];
if let Some(token) = access_token {
headers.push((
reqwest::header::AUTHORIZATION,
reqwest::header::HeaderValue::from_str(&format!("DPoP {token}"))
.context("access token is not a valid header value")?,
));
}
Ok(headers)
}
pub enum DpopBody<'a> {
Query,
Form(&'a [(&'a str, &'a str)]),
Json(Vec<u8>),
}
impl DpopBody<'_> {
pub(crate) fn method(&self) -> &'static str {
match self {
DpopBody::Query => "GET",
DpopBody::Form(_) | DpopBody::Json(_) => "POST",
}
}
}
pub struct DpopRequest<'a> {
pub endpoint: Endpoint,
pub url: &'a str,
pub key: &'a SigningKey,
pub access_token: Option<&'a str>,
pub body: DpopBody<'a>,
pub retry: Retry,
}
pub async fn send_with_dpop(
client: &Client,
pool: &SqlitePool,
request: &DpopRequest<'_>,
) -> Result<PostOutcome> {
let DpopRequest {
endpoint,
url,
key,
access_token,
retry,
ref body,
} = *request;
let method = body.method();
let origin = origin_of(url)?;
let mut nonce = store::get_nonce(pool, &origin).await?;
for attempt in 0..2 {
let proof = dpop::proof(key, method, url, access_token, nonce.as_deref())?;
let headers = request_headers(&proof, access_token)?;
let response = match body {
DpopBody::Query => net::guarded_get_no_redirect(client, url, &headers).await,
DpopBody::Form(params) => net::guarded_post_form(client, url, &headers, params).await,
DpopBody::Json(bytes) => {
net::guarded_post_json(client, url, &headers, bytes.clone()).await
}
}
.with_context(|| format!("{method} {url}"))?;
let status = response.status().as_u16();
let www_authenticate = response
.headers()
.get(reqwest::header::WWW_AUTHENTICATE)
.and_then(|v| v.to_str().ok())
.map(str::to_string);
let offered = response
.headers()
.get("DPoP-Nonce")
.and_then(|v| v.to_str().ok())
.map(str::to_string);
let body = net::read_capped(response)
.await
.with_context(|| format!("reading the response from {url}"))?;
if let Some(offered) = &offered {
if Some(offered) != nonce.as_ref() {
store::put_nonce(pool, &origin, offered, chrono::Utc::now().timestamp()).await?;
}
}
let challenge = dpop::nonce_challenge(
endpoint,
status,
www_authenticate.as_deref(),
&body,
offered.as_deref(),
);
if let Some(fresh) = next_nonce(attempt, retry, challenge, nonce.as_deref()) {
tracing::debug!(%url, status, "DPoP nonce challenge; retrying once with a fresh nonce");
nonce = Some(fresh);
continue;
}
return Ok(PostOutcome { status, body });
}
unreachable!("the loop returns on its second pass")
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn a_dpop_post_fails_closed_on_an_internal_target() {
let pool = crate::store::init_url("sqlite::memory:").await.unwrap();
store::init_schema(&pool).await.unwrap();
let key = SigningKey::generate("k");
let err = send_with_dpop(
&Client::new(),
&pool,
&DpopRequest {
endpoint: Endpoint::AuthorizationServer,
url: "http://127.0.0.1/oauth/token",
key: &key,
access_token: None,
body: DpopBody::Form(&[("grant_type", "refresh_token")]),
retry: Retry::Allowed,
},
)
.await
.expect_err("must refuse a loopback token endpoint");
let rendered = format!("{err:#}");
assert!(
rendered.contains("forbidden (internal) address"),
"failed for the wrong reason: {rendered}"
);
}
#[test]
fn the_response_funnel_has_its_own_cap_sized_for_its_own_traffic() {
let results: Vec<serde_json::Value> = (0..500)
.map(|i| {
serde_json::json!({
"$type": "com.atproto.repo.applyWrites#createResult",
"uri": format!("at://did:plc:ohutz6x5acjmpuulp3x7wxxc/community.lexicon.rss.subscription/3lab{i:08}"),
"cid": "bafyreibaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
"validationStatus": "valid",
})
})
.collect();
let big_but_legitimate = serde_json::json!({
"commit": { "cid": "bafyreibaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", "rev": "3labcdefghijk" },
"results": results,
})
.to_string();
let counted = crate::atproto::count_structural_chars(big_but_legitimate.as_bytes());
assert!(
counted > 8_000,
"the probe body counts only {counted}, so it is not the large legitimate \
case it is meant to be",
);
let outcome = PostOutcome {
status: 200,
body: big_but_legitimate.into_bytes(),
};
outcome
.json()
.expect("a 500-op applyWrites result is legitimate traffic on this path");
let mut body = String::from(r#"{"results":["#);
for _ in 0..70_000 {
body.push_str("{},");
}
body.push_str("{}]}");
let counted = crate::atproto::count_structural_chars(body.as_bytes());
assert!(
counted > MAX_RESPONSE_NODES && counted < crate::atproto::MAX_LIST_STRUCTURAL_CHARS,
"this probe must sit BETWEEN the two caps to prove they differ: counted \
{counted}, endpoint cap {MAX_RESPONSE_NODES}, listing cap {}",
crate::atproto::MAX_LIST_STRUCTURAL_CHARS,
);
let err = PostOutcome {
status: 200,
body: body.into_bytes(),
}
.json()
.expect_err("a body the listing cap would admit was accepted here");
let rendered = format!("{err:#}");
assert!(
rendered.contains("structural characters"),
"failed for the wrong reason: {rendered}"
);
}
#[test]
fn a_non_json_body_is_never_echoed_into_the_error() {
let outcome = PostOutcome {
status: 200,
body: br#"{"access_token":"eyJhbGciOiJFUzI1NiJ9.SECRET-TOKEN-VALUE"#.to_vec(),
};
let rendered = format!("{:#}", outcome.json().unwrap_err());
assert!(rendered.contains("200"), "status is the useful diagnostic");
assert!(
!rendered.contains("SECRET-TOKEN-VALUE") && !rendered.contains("eyJhbGciOiJ"),
"the body leaked into the error: {rendered}"
);
}
#[test]
fn a_nonce_challenge_on_the_first_attempt_is_retried() {
assert_eq!(
next_nonce(0, Retry::Allowed, Some("fresh".into()), None).as_deref(),
Some("fresh")
);
}
#[test]
fn a_second_attempt_never_retries() {
assert!(next_nonce(1, Retry::Allowed, Some("fresh".into()), None).is_none());
}
#[test]
fn a_request_that_must_not_repeat_is_never_retried() {
assert!(next_nonce(0, Retry::Forbidden, Some("fresh".into()), None).is_none());
}
#[test]
fn an_unchanged_nonce_is_not_worth_retrying() {
assert!(next_nonce(0, Retry::Allowed, Some("same".into()), Some("same")).is_none());
assert_eq!(
next_nonce(0, Retry::Allowed, Some("new".into()), Some("old")).as_deref(),
Some("new")
);
}
#[test]
fn no_challenge_means_no_retry() {
assert!(next_nonce(0, Retry::Allowed, None, None).is_none());
}
#[test]
fn the_method_follows_the_body_kind() {
assert_eq!(DpopBody::Query.method(), "GET");
assert_eq!(DpopBody::Form(&[("a", "b")]).method(), "POST");
assert_eq!(DpopBody::Json(b"{}".to_vec()).method(), "POST");
}
#[test]
fn a_resource_request_carries_the_token_as_well_as_the_proof() {
let headers = request_headers("the-proof", Some("the-token")).unwrap();
let names: Vec<String> = headers.iter().map(|(n, _)| n.to_string()).collect();
assert!(names.contains(&"dpop".to_string()));
assert!(names.contains(&"authorization".to_string()));
let auth = headers
.iter()
.find(|(n, _)| n.as_str() == "authorization")
.map(|(_, v)| v.to_str().unwrap().to_string())
.unwrap();
assert_eq!(auth, "DPoP the-token");
}
#[test]
fn an_authorization_server_request_carries_only_the_proof() {
let headers = request_headers("the-proof", None).unwrap();
assert_eq!(headers.len(), 1);
assert_eq!(headers[0].0.as_str(), "dpop");
}
#[test]
fn a_malformed_token_is_rejected_rather_than_injected() {
assert!(request_headers("proof", Some("tok\r\nX-Evil: 1")).is_err());
assert!(request_headers("pro\nof", None).is_err());
}
}