use anyhow::{bail, Context as _, Result};
use reqwest::Client;
use serde_json::Value;
use crate::net;
pub const JSON: &[&str] = &["application/json"];
pub const DID_JSON: &[&str] = &[
"application/json",
"application/did+ld+json",
"application/did+json",
];
fn check_meta(status: u16, content_type: Option<&str>, allowed: &[&str]) -> Result<()> {
if status != 200 {
bail!("expected status 200, got {status}");
}
let Some(raw) = content_type else {
bail!("response has no Content-Type; refusing to parse it as JSON");
};
let media = raw
.split(';')
.next()
.unwrap_or_default()
.trim()
.to_ascii_lowercase();
if !allowed.iter().any(|a| *a == media) {
bail!("unexpected Content-Type {media:?}; expected one of {allowed:?}");
}
Ok(())
}
pub async fn get_json(client: &Client, url: &str, allowed: &[&str]) -> Result<Value> {
get_json_optional(client, url, allowed)
.await?
.with_context(|| format!("document not found at {url}"))
}
pub async fn get_json_optional(
client: &Client,
url: &str,
allowed: &[&str],
) -> Result<Option<Value>> {
let resp = net::guarded_get_no_redirect(client, url, &[])
.await
.with_context(|| format!("fetching {url}"))?;
let status = resp.status().as_u16();
if status == 404 {
return Ok(None);
}
let content_type = resp
.headers()
.get(reqwest::header::CONTENT_TYPE)
.and_then(|v| v.to_str().ok())
.map(str::to_string);
check_meta(status, content_type.as_deref(), allowed)
.with_context(|| format!("fetching {url}"))?;
let body = net::read_capped(resp)
.await
.with_context(|| format!("reading {url}"))?;
crate::atproto::refuse_a_structure_explosion(&body, &format!("the body of {url}"))?;
let value =
serde_json::from_slice(&body).with_context(|| format!("{url} is not valid JSON"))?;
Ok(Some(value))
}
#[cfg(test)]
mod tests {
#[tokio::test]
async fn a_did_document_that_is_a_structure_explosion_is_refused() {
let mut body = String::from(r#"{"id":"did:web:probe.test","service":["#);
for _ in 0..700_000 {
body.push_str("{},");
}
body.push_str("{}]}");
assert!(
crate::atproto::count_structural_chars(body.as_bytes())
> crate::atproto::MAX_LIST_STRUCTURAL_CHARS,
"the probe body is not over the cap, so this test proves nothing",
);
let base = crate::net::tests::serve_bodies_in_sequence(vec![body.into_bytes()]).await;
let port: u16 = base
.trim_end_matches('/')
.rsplit(':')
.next()
.unwrap()
.parse()
.unwrap();
crate::net::test_host_override(
"did-explosion.test",
std::net::SocketAddr::from(([127, 0, 0, 1], port)),
);
let err = get_json(
&Client::new(),
&format!("http://did-explosion.test:{port}/did.json"),
DID_JSON,
)
.await
.expect_err("a structure explosion was parsed rather than refused");
let rendered = format!("{err:#}");
assert!(
rendered.contains("structural characters"),
"failed for the wrong reason: {rendered}"
);
}
use super::*;
#[test]
fn a_200_with_json_is_accepted() {
assert!(check_meta(200, Some("application/json"), JSON).is_ok());
}
#[test]
fn the_media_type_is_compared_without_parameters_or_case() {
for ct in [
"application/json; charset=utf-8",
"application/json;charset=UTF-8",
"APPLICATION/JSON",
" application/json ",
] {
assert!(check_meta(200, Some(ct), JSON).is_ok(), "rejected {ct:?}");
}
}
#[test]
fn a_non_json_content_type_is_rejected() {
for ct in [
"text/html",
"text/plain",
"application/xml",
"application/jsonp",
"application/json-seq",
] {
assert!(check_meta(200, Some(ct), JSON).is_err(), "accepted {ct:?}");
}
}
#[test]
fn a_missing_content_type_is_rejected() {
assert!(check_meta(200, None, JSON).is_err());
}
#[test]
fn only_status_200_is_accepted() {
for status in [201u16, 204, 206, 301, 302, 400, 401, 403, 500] {
assert!(
check_meta(status, Some("application/json"), JSON).is_err(),
"accepted status {status}"
);
}
}
#[test]
fn the_allowed_set_is_per_document_kind() {
assert!(check_meta(200, Some("application/did+ld+json"), DID_JSON).is_ok());
assert!(check_meta(200, Some("application/json"), DID_JSON).is_ok());
assert!(check_meta(200, Some("application/did+ld+json"), JSON).is_err());
}
#[tokio::test]
async fn discovery_fetches_fail_closed_on_internal_targets() {
let client = Client::new();
for url in [
"http://127.0.0.1/.well-known/oauth-authorization-server",
"http://169.254.169.254/.well-known/oauth-protected-resource",
"http://10.1.2.3/did.json",
] {
for rendered in [
format!(
"{:#}",
get_json(&client, url, JSON).await.expect_err("allowed")
),
format!(
"{:#}",
get_json_optional(&client, url, JSON)
.await
.expect_err("allowed")
),
] {
assert!(
rendered.contains("forbidden (internal) address"),
"{url} failed for the wrong reason: {rendered}"
);
}
}
}
}