0
2
Hosts identity data names are asked only on https, at public addresses
Only what this pull adds. It is built on grain runs on a Rust server modelled on knotless, without hatk.
crates/grain/src/auth.rs+4−2
| @@ -158,8 +158,10 @@ mod tests { | ||
async fn tokens_are_verified_against_the_issuers_key() { | ||
let (port, served) = documents(); | ||
let did = format!("did:web:127.0.0.1%3A{port}"); | ||
let ctx = Ctx::new(Env::from_vars(|k| { | ||
(k == "GRAIN_DID_WEB_SCHEME").then(|| "http".into()) | ||
let ctx = Ctx::new(Env::from_vars(|k| match k { | ||
"GRAIN_DID_WEB_SCHEME" => Some("http".into()), | ||
"GRAIN_ALLOW_PRIVATE" => Some("1".into()), | ||
_ => None, | ||
})); | ||
let key = P256Key::generate(); | ||
*served.lock().unwrap() = document(&did, &key.public()); | ||
crates/grain/src/env.rs+46−18
| @@ -35,6 +35,11 @@ pub struct Env { | ||
pub browser: Option<String>, | ||
pub identity_ttl: Duration, | ||
pub timeout: Duration, | ||
/// Whether hosts identity data names may be asked at loopback, private | ||
/// and other addresses that are not the public internet's, and not on | ||
/// https (`GRAIN_ALLOW_PRIVATE=1`): for tests and dev only, as a local | ||
/// PDS is. See [`crate::guard`]. | ||
pub allow_private: bool, | ||
} | ||
| ||
impl Env { | ||
| @@ -92,37 +97,54 @@ impl Env { | ||
browser: get("GRAIN_BROWSER").filter(|v| !v.is_empty()), | ||
identity_ttl: Duration::from_secs(num("GRAIN_IDENTITY_TTL_SECS", 3600)), | ||
timeout: Duration::from_secs(num("GRAIN_TIMEOUT_SECS", 60)), | ||
allow_private: get("GRAIN_ALLOW_PRIVATE").as_deref() == Some("1"), | ||
} | ||
} | ||
} | ||
| ||
/// Configuration plus the one HTTP client a process shares, so every request | ||
/// Configuration plus the HTTP clients a process shares, so every request | ||
/// to a PDS reuses its connection. | ||
#[derive(Clone)] | ||
pub struct Ctx { | ||
pub env: Env, | ||
/// For the hosts this process is set to ask, and its own account's. | ||
pub http: reqwest::Client, | ||
/// For the hosts identity data names, held to [`crate::guard`]: a | ||
/// `did:web` host, a handle's, the PDS a DID document names. | ||
pub guarded: reqwest::Client, | ||
} | ||
| ||
impl Ctx { | ||
pub fn new(env: Env) -> Ctx { | ||
let mut http = reqwest::Client::builder(); | ||
for cert in env.ca_certs.iter().flat_map(|path| extra_roots(path)) { | ||
http = http.add_root_certificate(cert); | ||
let base = || { | ||
let mut http = reqwest::Client::builder(); | ||
for cert in env.ca_certs.iter().flat_map(|path| extra_roots(path)) { | ||
http = http.add_root_certificate(cert); | ||
} | ||
http.user_agent(concat!("grain/", env!("CARGO_PKG_VERSION"))) | ||
.connect_timeout(Duration::from_secs(10)) | ||
.timeout(env.timeout) | ||
// Under the 5s Node servers keep an idle connection by | ||
// default, as a PLC directory does: past it, one can close | ||
// under the request reusing it. A PDS keeps one 90s, which | ||
// ties with any longer wait here. | ||
.pool_idle_timeout(Duration::from_secs(4)) | ||
.tcp_nodelay(true) | ||
}; | ||
let built = "the HTTP client configuration is static"; | ||
let http = base().build().expect(built); | ||
let guarded = crate::guard::client(base(), env.allow_private); | ||
let guarded = guarded.build().expect(built); | ||
Ctx { env, http, guarded } | ||
} | ||
| ||
/// Whether `url`, as identity data names it, may be asked | ||
/// ([`crate::guard::may_ask`]): any may when private hosts are allowed. | ||
pub fn may_ask(&self, url: &str) -> Result<(), crate::guard::Refused> { | ||
if self.env.allow_private { | ||
return Ok(()); | ||
} | ||
let http = http | ||
.user_agent(concat!("grain/", env!("CARGO_PKG_VERSION"))) | ||
.connect_timeout(Duration::from_secs(10)) | ||
.timeout(env.timeout) | ||
// Under the 5s Node servers keep an idle connection by default, | ||
// as a PLC directory does: past it, one can close under the | ||
// request reusing it. A PDS keeps one 90s, which ties with any | ||
// longer wait here. | ||
.pool_idle_timeout(Duration::from_secs(4)) | ||
.tcp_nodelay(true) | ||
.build() | ||
.expect("the HTTP client configuration is static"); | ||
Ctx { env, http } | ||
crate::guard::may_ask(url) | ||
} | ||
} | ||
| ||
| @@ -174,6 +196,9 @@ mod tests { | ||
assert!(e.service.is_none()); | ||
assert!(e.ca_certs.is_none()); | ||
assert!(e.identifier.is_none() && e.password.is_none() && e.browser.is_none()); | ||
assert!(!e.allow_private); | ||
let ctx = Ctx::new(e); | ||
assert!(ctx.may_ask("http://127.0.0.1:2583").is_err()); | ||
let bare = env(&[]); | ||
assert_eq!(bare.cache_dir, PathBuf::from("./.cache/grain")); | ||
assert_eq!(bare.config_dir, PathBuf::from("./.config/grain")); | ||
| @@ -193,6 +218,7 @@ mod tests { | ||
("GRAIN_SERVICE", "http://pds"), | ||
("GRAIN_IDENTITY_TTL_SECS", "5"), | ||
("GRAIN_TIMEOUT_SECS", "soon"), | ||
("GRAIN_ALLOW_PRIVATE", "1"), | ||
]); | ||
assert_eq!(e.cache_dir, PathBuf::from("/x/grain")); | ||
assert_eq!(e.config_dir, PathBuf::from("/y/grain")); | ||
| @@ -204,6 +230,8 @@ mod tests { | ||
assert_eq!(e.service.as_deref(), Some("http://pds")); | ||
assert_eq!(e.identity_ttl, Duration::from_secs(5)); | ||
assert_eq!(e.timeout, Duration::from_secs(60)); | ||
assert!(e.allow_private); | ||
assert!(!env(&[("GRAIN_ALLOW_PRIVATE", "yes")]).allow_private); | ||
assert_eq!( | ||
e.handle_resolvers, | ||
vec![ | ||
| @@ -224,7 +252,7 @@ mod tests { | ||
env(&[("GRAIN_CONFIG_DIR", "/k")]).config_dir, | ||
PathBuf::from("/k") | ||
); | ||
let _ = Ctx::new(e); | ||
assert_eq!(Ctx::new(e).may_ask("http://127.0.0.1:2583"), Ok(())); | ||
} | ||
| ||
/// A throwaway self-signed CA, only for reading back. | ||
crates/grain/src/guard.rsnew+180−0
| @@ -0,0 +1,180 @@ | ||
//! What a host that identity data names may be asked. A DID document names | ||
//! whatever PDS it likes, a `did:web` whatever host, a handle whatever | ||
//! name, and anyone can make grain-serve look one up, so such a host is | ||
//! asked only on https, at a public address, its hostname resolved to | ||
//! public addresses only ([`PublicOnly`]), and no redirect is followed | ||
//! ([`client`]). `GRAIN_ALLOW_PRIVATE=1` lifts this for tests and dev | ||
//! ([`crate::env::Env::allow_private`]). | ||
//! | ||
//! The PLC directory, the handle resolvers and the services a process is | ||
//! configured with are its settings, not identity data, and are asked as | ||
//! they are set. | ||
| ||
use std::net::{IpAddr, SocketAddr}; | ||
use std::sync::Arc; | ||
| ||
/// Why a host identity data names is not asked. | ||
#[derive(Debug, Clone, PartialEq, Eq)] | ||
pub struct Refused(pub String); | ||
| ||
impl std::fmt::Display for Refused { | ||
fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { | ||
f.write_str(&self.0) | ||
} | ||
} | ||
| ||
impl std::error::Error for Refused {} | ||
| ||
/// Whether `ip` is the public internet's: not loopback, private, | ||
/// link-local, shared, reserved, multicast, or for documentation. | ||
pub fn public(ip: IpAddr) -> bool { | ||
match ip { | ||
IpAddr::V4(v4) => { | ||
let [a, b, c, _] = v4.octets(); | ||
let special = v4.is_private() || v4.is_loopback() || v4.is_link_local(); | ||
let special = special || v4.is_documentation() || v4.is_multicast(); | ||
let special = special || a == 0 || a >= 240 || (a == 100 && (b & 0xc0) == 64); | ||
!(special || (a == 198 && (b & 0xfe) == 18) || (a == 192 && b == 0 && c == 0)) | ||
} | ||
IpAddr::V6(v6) => match v6.to_ipv4_mapped() { | ||
Some(v4) => public(v4.into()), | ||
// Global unicast, less the documentation prefix. | ||
None => { | ||
let [s, t, ..] = v6.segments(); | ||
(s & 0xe000) == 0x2000 && !(s == 0x2001 && t == 0xdb8) | ||
} | ||
}, | ||
} | ||
} | ||
| ||
/// Whether `url`, as identity data names it, may be asked: on https, and | ||
/// not at an address that is not public. A hostname's addresses are held | ||
/// to the same when it is resolved ([`PublicOnly`]). | ||
pub fn may_ask(url: &str) -> Result<(), Refused> { | ||
let parsed = reqwest::Url::parse(url).map_err(|e| Refused(format!("{url}: {e}")))?; | ||
if parsed.scheme() != "https" { | ||
return Err(Refused(format!("{url} is not on https"))); | ||
} | ||
let host = parsed.host_str().unwrap_or_default(); | ||
let ip = host.trim_matches(['[', ']']).parse::<IpAddr>().ok(); | ||
if ip.is_some_and(|ip| !public(ip)) { | ||
return Err(Refused(format!("{url} is not at a public address"))); | ||
} | ||
Ok(()) | ||
} | ||
| ||
/// Resolves a hostname to its public addresses only, so that a name | ||
/// pointing inward is not asked. | ||
pub struct PublicOnly; | ||
| ||
impl reqwest::dns::Resolve for PublicOnly { | ||
fn resolve(&self, name: reqwest::dns::Name) -> reqwest::dns::Resolving { | ||
Box::pin(async move { | ||
let found = tokio::net::lookup_host((name.as_str(), 0)).await?; | ||
let found: Vec<SocketAddr> = found.filter(|a| public(a.ip())).collect(); | ||
let why = format!("{} has no public address", name.as_str()); | ||
if found.is_empty() { | ||
return Err(why.into()); | ||
} | ||
Ok(Box::new(found.into_iter()) as reqwest::dns::Addrs) | ||
}) | ||
} | ||
} | ||
| ||
/// `http`, held to this policy: no redirects, since one could lead | ||
/// anywhere the name could not, and hostnames resolved to public addresses | ||
/// only, unless `allow_private`. | ||
pub fn client(http: reqwest::ClientBuilder, allow_private: bool) -> reqwest::ClientBuilder { | ||
let http = http.redirect(reqwest::redirect::Policy::none()); | ||
if allow_private { | ||
http | ||
} else { | ||
http.dns_resolver(Arc::new(PublicOnly)) | ||
} | ||
} | ||
| ||
#[cfg(test)] | ||
mod tests { | ||
use super::*; | ||
use reqwest::dns::Resolve; | ||
| ||
#[test] | ||
fn only_public_addresses() { | ||
for ip in ["8.8.8.8", "1.1.1.1", "2606:4700::1111", "::ffff:8.8.8.8"] { | ||
assert!(public(ip.parse().unwrap()), "{ip}"); | ||
} | ||
let inward = [ | ||
"127.0.0.1", | ||
"10.1.2.3", | ||
"172.16.0.1", | ||
"192.168.1.1", | ||
"169.254.169.254", | ||
"100.64.0.1", | ||
"0.0.0.0", | ||
"255.255.255.255", | ||
"240.0.0.1", | ||
"198.18.0.1", | ||
"192.0.0.8", | ||
"192.0.2.1", | ||
"224.0.0.1", | ||
"::1", | ||
"::", | ||
"fc00::1", | ||
"fd12::1", | ||
"fe80::1", | ||
"ff02::1", | ||
"2001:db8::1", | ||
"64:ff9b::a00:1", | ||
"::ffff:127.0.0.1", | ||
]; | ||
for ip in inward { | ||
assert!(!public(ip.parse().unwrap()), "{ip}"); | ||
} | ||
} | ||
| ||
#[test] | ||
fn urls_asked() { | ||
assert_eq!(may_ask("https://pds.example"), Ok(())); | ||
assert_eq!(may_ask("https://8.8.8.8/xrpc"), Ok(())); | ||
let refused = |url| may_ask(url).unwrap_err().to_string(); | ||
assert_eq!( | ||
refused("http://pds.example"), | ||
"http://pds.example is not on https" | ||
); | ||
for url in [ | ||
"https://127.0.0.1:2583", | ||
"https://[::1]", | ||
"https://10.0.0.2", | ||
"https://169.254.169.254/latest/meta-data", | ||
] { | ||
assert_eq!(refused(url), format!("{url} is not at a public address")); | ||
} | ||
assert!(refused("not a url").starts_with("not a url: ")); | ||
} | ||
| ||
#[tokio::test] | ||
async fn names_resolve_to_public_addresses_only() { | ||
let resolve = |name: &str| PublicOnly.resolve(name.parse().unwrap()); | ||
let found: Vec<SocketAddr> = resolve("8.8.8.8").await.unwrap().collect(); | ||
assert_eq!(found, ["8.8.8.8:0".parse().unwrap()]); | ||
for name in ["127.0.0.1", "localhost"] { | ||
let why = resolve(name).await.err().unwrap().to_string(); | ||
assert_eq!(why, format!("{name} has no public address")); | ||
} | ||
} | ||
| ||
#[tokio::test] | ||
async fn a_guarded_client_asks_no_name_that_points_inward() { | ||
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); | ||
let port = listener.local_addr().unwrap().port(); | ||
let http = client(reqwest::Client::builder(), false).build().unwrap(); | ||
let err = http | ||
.get(format!("http://localhost:{port}/")) | ||
.send() | ||
.await | ||
.unwrap_err(); | ||
let chain = format!("{:?}", anyhow::Error::from(err)); | ||
assert!(chain.contains("localhost has no public address"), "{chain}"); | ||
client(reqwest::Client::builder(), true).build().unwrap(); | ||
} | ||
} | ||
crates/grain/src/identity.rs+76−9
| @@ -1,5 +1,9 @@ | ||
//! Handle → DID → PDS, and the keys a DID document names: cached on disk | ||
//! so a warm lookup spends no round trip on it. | ||
//! | ||
//! The hosts identity data names, a handle's, a `did:web`'s and the PDS a | ||
//! DID document names, are held to [`crate::guard`]; the PLC directory and | ||
//! the handle resolvers set are asked as they are set. | ||
| ||
use crate::crypto::PublicKey; | ||
use crate::env::{Ctx, HandleResolver}; | ||
| @@ -278,17 +282,25 @@ pub async fn resolve_handle(ctx: &Ctx, handle: &str) -> Result<String> { | ||
pub const LOOKUP: Duration = Duration::from_secs(3); | ||
| ||
async fn well_known(ctx: &Ctx, scheme: &str, handle: &str) -> Result<String> { | ||
let req = ctx | ||
.http | ||
.get(format!("{scheme}://{handle}/.well-known/atproto-did")) | ||
.timeout(LOOKUP); | ||
let url = format!("{scheme}://{handle}/.well-known/atproto-did"); | ||
ctx.may_ask(&url)?; | ||
let req = ctx.guarded.get(url).timeout(LOOKUP); | ||
let res = crate::telemetry::send(req, 0).await?.error_for_status()?; | ||
Ok(res.text().await?.trim().to_string()) | ||
} | ||
| ||
pub async fn resolve_pds(ctx: &Ctx, did: &str) -> Result<String> { | ||
let doc = document(ctx, did).await?; | ||
service(&doc, "#atproto_pds").ok_or_else(|| anyhow!("{did} names no #atproto_pds service")) | ||
pds(ctx, did, &doc) | ||
} | ||
| ||
/// The PDS `doc`, `did`'s DID document, names, if it may be asked | ||
/// ([`Ctx::may_ask`]); a refusal is a [`crate::guard::Refused`]. | ||
pub fn pds(ctx: &Ctx, did: &str, doc: &Value) -> Result<String> { | ||
let pds = service(doc, "#atproto_pds"); | ||
let pds = pds.ok_or_else(|| anyhow!("{did} names no #atproto_pds service"))?; | ||
ctx.may_ask(&pds)?; | ||
Ok(pds) | ||
} | ||
| ||
/// Where `did`'s DID document is read: the PLC directory, or its | ||
| @@ -317,10 +329,12 @@ const KEPT_FOR: Duration = Duration::from_secs(300); | ||
/// `did`'s DID document, from the PLC directory or its `did:web` host. | ||
pub async fn document(ctx: &Ctx, did: &str) -> Result<Value> { | ||
let url = document_url(ctx, did)?; | ||
let mut req = ctx.http.get(&url); | ||
if did.starts_with("did:web:") { | ||
req = req.timeout(LOOKUP); | ||
} | ||
let req = if did.starts_with("did:web:") { | ||
ctx.may_ask(&url)?; | ||
ctx.guarded.get(&url).timeout(LOOKUP) | ||
} else { | ||
ctx.http.get(&url) | ||
}; | ||
let res = crate::telemetry::send(req, 0).await?; | ||
let res = res.error_for_status().context(format!("resolving {did}"))?; | ||
let doc: Value = res.json().await?; | ||
| @@ -570,6 +584,7 @@ mod tests { | ||
"GRAIN_CACHE_DIR" => Some(cache_dir.clone()), | ||
"GRAIN_DID_WEB_SCHEME" => Some("http".into()), | ||
"GRAIN_HANDLE_RESOLVER" => Some("well-known:http".into()), | ||
"GRAIN_ALLOW_PRIVATE" => Some("1".into()), | ||
_ => None, | ||
})); | ||
let id = resolve(&ctx, &name).await.unwrap(); | ||
| @@ -607,6 +622,58 @@ mod tests { | ||
assert_eq!(handle(&ctx, nowhere).await, None); | ||
} | ||
| ||
#[tokio::test] | ||
async fn hosts_identity_data_names_are_asked_only_if_public() { | ||
let dir = tempfile::tempdir().unwrap(); | ||
let cache_dir = dir.path().to_str().unwrap().to_string(); | ||
let ctx = |scheme: &'static str| { | ||
let cache_dir = cache_dir.clone(); | ||
Ctx::new(Env::from_vars(move |k| match k { | ||
"GRAIN_CACHE_DIR" => Some(cache_dir.clone()), | ||
"GRAIN_DID_WEB_SCHEME" => Some(scheme.into()), | ||
"GRAIN_HANDLE_RESOLVER" => Some(format!("well-known:{scheme}")), | ||
_ => None, | ||
})) | ||
}; | ||
let refused = |err: anyhow::Error| { | ||
let refused = err.downcast_ref::<crate::guard::Refused>(); | ||
refused | ||
.map(|r| r.0.clone()) | ||
.unwrap_or_else(|| panic!("{err:#}")) | ||
}; | ||
// A did:web host on loopback, over http or https. | ||
let did = "did:web:127.0.0.1%3A2583"; | ||
let why = refused(document(&ctx("http"), did).await.unwrap_err()); | ||
assert_eq!( | ||
why, | ||
"http://127.0.0.1:2583/.well-known/did.json is not on https" | ||
); | ||
let why = refused(document(&ctx("https"), did).await.unwrap_err()); | ||
assert!(why.ends_with("is not at a public address"), "{why}"); | ||
// A handle's host, over http. | ||
let why = resolve_handle(&ctx("http"), "alice.test") | ||
.await | ||
.unwrap_err(); | ||
assert!( | ||
why.to_string() | ||
.contains("alice.test/.well-known/atproto-did is not on https") | ||
); | ||
// A DID document's PDS. | ||
let ctx = ctx("https"); | ||
let doc = |pds: &str| json!({"service": [{"id": "#atproto_pds", "serviceEndpoint": pds}]}); | ||
let pds = |at: &str| pds(&ctx, "did:plc:a", &doc(at)); | ||
assert_eq!(pds("https://pds.example/").unwrap(), "https://pds.example"); | ||
for inward in [ | ||
"http://pds.example", | ||
"https://10.0.0.1", | ||
"https://[::1]:2583", | ||
] { | ||
assert!(refused(pds(inward).unwrap_err()).starts_with(inward)); | ||
} | ||
assert!(pds("").is_err()); | ||
assert!(super::pds(&ctx, "did:plc:a", &json!({})).is_err()); | ||
} | ||
| ||
#[test] | ||
fn service_lookup() { | ||
let doc = json!({"service": [ | ||
crates/grain/src/lib.rs+1−0
| @@ -9,6 +9,7 @@ pub mod base32; | ||
pub mod crypto; | ||
pub mod drisl; | ||
pub mod env; | ||
pub mod guard; | ||
pub mod identity; | ||
pub mod jetstream; | ||
pub mod keys; | ||
crates/grain/tests/common/mod.rs+4−1
| @@ -162,7 +162,8 @@ pub async fn account() -> Account { | ||
} | ||
| ||
/// A context whose config and cache are under `dir`, resolving handles at | ||
/// the PDS and DIDs at the harness's PLC directory, with `extra` beside. | ||
/// the PDS and DIDs at the harness's PLC directory, asking private hosts, | ||
/// with `extra` beside or in their place. | ||
pub fn ctx(dir: &Path, extra: &[(&str, &str)]) -> Ctx { | ||
let net = net(); | ||
let dir = dir.to_str().unwrap().to_string(); | ||
| @@ -179,6 +180,8 @@ pub fn ctx(dir: &Path, extra: &[(&str, &str)]) -> Ctx { | ||
"GRAIN_CACHE_DIR" => Some(format!("{dir}/cache")), | ||
"GRAIN_HANDLE_RESOLVER" => Some(net.pds.clone()), | ||
"GRAIN_PLC_URL" => Some(net.plc.clone()), | ||
// The reference PDS is on loopback, over http. | ||
"GRAIN_ALLOW_PRIVATE" => Some("1".into()), | ||
_ => None, | ||
} | ||
})) | ||
crates/grain-serve/src/backfill.rs+9−156
| @@ -26,13 +26,13 @@ | ||
//! each time after twice as long, until it is given up on. A host that | ||
//! asks to be left alone a while (429) is. | ||
//! | ||
//! A DID document names whatever PDS it likes, so one is asked only on | ||
//! https, at a public address, its hostname resolved to public addresses | ||
//! only, and no redirect is followed ([`Config::private`] lifts this for | ||
//! tests and dev). | ||
//! A DID document names whatever PDS it likes, so one is asked only as | ||
//! [`grain::guard`] allows: on https, at a public address, its hostname | ||
//! resolved to public addresses only, and no redirect followed | ||
//! (`GRAIN_ALLOW_PRIVATE=1` lifts this for tests and dev). | ||
| ||
use crate::index::{Applied, Index, PutError, records}; | ||
use anyhow::{Context, anyhow, ensure}; | ||
use anyhow::{Context, anyhow}; | ||
use bytes::Bytes; | ||
use grain::crypto::PublicKey; | ||
use grain::env::Ctx; | ||
| @@ -42,7 +42,6 @@ use serde::Deserialize; | ||
use serde_json::Value; | ||
use std::collections::{HashMap, HashSet}; | ||
use std::io::Read; | ||
use std::net::{IpAddr, SocketAddr}; | ||
use std::sync::{Arc, Mutex}; | ||
use std::time::Duration; | ||
use tokio::sync::{Notify, OwnedSemaphorePermit, Semaphore, mpsc}; | ||
| @@ -81,10 +80,6 @@ pub struct Config { | ||
pub rediscover: Duration, | ||
/// How often the queue is looked at when nothing says to. | ||
pub poll: Duration, | ||
/// Whether hosts at loopback, private and other addresses that are not | ||
/// the public internet's may be asked, and PDSes not on https: for | ||
/// tests and dev only, since a DID document names any URL it likes. | ||
pub private: bool, | ||
} | ||
| ||
impl Default for Config { | ||
| @@ -106,7 +101,6 @@ impl Default for Config { | ||
total: Duration::from_secs(600), | ||
rediscover: Duration::from_secs(6 * 3600), | ||
poll: Duration::from_secs(5), | ||
private: false, | ||
} | ||
} | ||
} | ||
| @@ -115,8 +109,7 @@ impl Config { | ||
/// From the environment: none unless `GRAIN_BACKFILL` is set, and not | ||
/// `0`. `GRAIN_BACKFILL_DIRECTORIES` and `GRAIN_BACKFILL_SKIP` are | ||
/// comma-separated, `GRAIN_BACKFILL_MIRROR` empty for none, and | ||
/// `GRAIN_BACKFILL_CONCURRENCY` a number. `GRAIN_BACKFILL_PRIVATE=1` | ||
/// lets it ask private addresses, as a dev PDS is. | ||
/// `GRAIN_BACKFILL_CONCURRENCY` a number. | ||
pub fn from_vars(get: impl Fn(&str) -> Option<String>) -> Option<Config> { | ||
if matches!(get("GRAIN_BACKFILL").as_deref(), None | Some("" | "0")) { | ||
return None; | ||
| @@ -132,7 +125,6 @@ impl Config { | ||
skip: get("GRAIN_BACKFILL_SKIP").map_or(d.skip, list), | ||
mirror: get("GRAIN_BACKFILL_MIRROR").map_or(d.mirror, |m| list(m).into_iter().next()), | ||
concurrency: concurrency.unwrap_or(d.concurrency).max(1), | ||
private: get("GRAIN_BACKFILL_PRIVATE").as_deref() == Some("1"), | ||
..d | ||
}) | ||
} | ||
| @@ -224,61 +216,6 @@ fn host_of(url: &str) -> String { | ||
.unwrap_or_default() | ||
} | ||
| ||
/// Whether `ip` is the public internet's: not loopback, private, | ||
/// link-local, shared, reserved, multicast, or for documentation. | ||
fn public(ip: IpAddr) -> bool { | ||
match ip { | ||
IpAddr::V4(v4) => { | ||
let [a, b, c, _] = v4.octets(); | ||
let special = v4.is_private() || v4.is_loopback() || v4.is_link_local(); | ||
let special = special || v4.is_documentation() || v4.is_multicast(); | ||
let special = special || a == 0 || a >= 240 || (a == 100 && (b & 0xc0) == 64); | ||
!(special || (a == 198 && (b & 0xfe) == 18) || (a == 192 && b == 0 && c == 0)) | ||
} | ||
IpAddr::V6(v6) => match v6.to_ipv4_mapped() { | ||
Some(v4) => public(v4.into()), | ||
// Global unicast, less the documentation prefix. | ||
None => { | ||
let [s, t, ..] = v6.segments(); | ||
(s & 0xe000) == 0x2000 && !(s == 0x2001 && t == 0xdb8) | ||
} | ||
}, | ||
} | ||
} | ||
| ||
/// Whether `pds`, as a DID document names it, may be asked: on https, and | ||
/// not at an address that is not public. A hostname's addresses are held | ||
/// to the same when it is resolved ([`PublicOnly`]). | ||
fn may_ask(pds: &str) -> anyhow::Result<()> { | ||
let url = reqwest::Url::parse(pds)?; | ||
ensure!(url.scheme() == "https", "its PDS, {pds}, is not on https"); | ||
let host = url.host_str().unwrap_or_default().trim_matches(['[', ']']); | ||
let ip = host.parse::<IpAddr>().ok(); | ||
ensure!( | ||
ip.is_none_or(public), | ||
"its PDS, {pds}, is not a public address" | ||
); | ||
Ok(()) | ||
} | ||
| ||
/// Resolves a hostname to its public addresses only, so that a name | ||
/// pointing inward is not asked. | ||
struct PublicOnly; | ||
| ||
impl reqwest::dns::Resolve for PublicOnly { | ||
fn resolve(&self, name: reqwest::dns::Name) -> reqwest::dns::Resolving { | ||
Box::pin(async move { | ||
let found = tokio::net::lookup_host((name.as_str(), 0)).await?; | ||
let found: Vec<SocketAddr> = found.filter(|a| public(a.ip())).collect(); | ||
let why = format!("{} has no public address", name.as_str()); | ||
if found.is_empty() { | ||
return Err(why.into()); | ||
} | ||
Ok(Box::new(found.into_iter()) as reqwest::dns::Addrs) | ||
}) | ||
} | ||
} | ||
| ||
pub struct Backfill { | ||
index: Arc<Index>, | ||
ctx: Ctx, | ||
| @@ -291,17 +228,10 @@ pub struct Backfill { | ||
| ||
impl Backfill { | ||
pub fn new(index: Arc<Index>, ctx: Ctx, config: Config) -> Arc<Backfill> { | ||
// No redirects: one could lead anywhere a DID document could not. | ||
let http = reqwest::Client::builder() | ||
.user_agent(concat!("grain/", env!("CARGO_PKG_VERSION"))) | ||
.connect_timeout(Duration::from_secs(10)) | ||
.redirect(reqwest::redirect::Policy::none()); | ||
let http = if config.private { | ||
http | ||
} else { | ||
http.dns_resolver(Arc::new(PublicOnly)) | ||
}; | ||
let http = http | ||
.connect_timeout(Duration::from_secs(10)); | ||
let http = grain::guard::client(http, ctx.env.allow_private) | ||
.build() | ||
.expect("the HTTP client configuration is static"); | ||
let hosts = Hosts { | ||
| @@ -404,13 +334,7 @@ impl Backfill { | ||
async fn read_and_apply(&self, did: &str) -> Result<Option<Done>, Failure> { | ||
let doc = identity::document(&self.ctx, did).await.map_err(retry)?; | ||
let key = identity::signing_key(&doc).map_err(give_up)?; | ||
let pds = identity::service(&doc, "#atproto_pds"); | ||
let pds = pds | ||
.with_context(|| format!("{did} names no PDS")) | ||
.map_err(give_up)?; | ||
if !self.config.private { | ||
may_ask(&pds).map_err(give_up)?; | ||
} | ||
let pds = identity::pds(&self.ctx, did, &doc).map_err(give_up)?; | ||
let host = host_of(&pds); | ||
if self | ||
.config | ||
| @@ -647,74 +571,3 @@ fn apply(index: &Index, did: &str, read: Fetched, mirrored: bool) -> Result<Done | ||
.map_err(retry)?; | ||
Ok(done) | ||
} | ||
| ||
#[cfg(test)] | ||
mod tests { | ||
use super::*; | ||
use reqwest::dns::Resolve; | ||
| ||
#[test] | ||
fn only_public_addresses() { | ||
for ip in ["8.8.8.8", "1.1.1.1", "2606:4700::1111", "::ffff:8.8.8.8"] { | ||
assert!(public(ip.parse().unwrap()), "{ip}"); | ||
} | ||
let inward = [ | ||
"127.0.0.1", | ||
"10.1.2.3", | ||
"172.16.0.1", | ||
"192.168.1.1", | ||
"169.254.169.254", | ||
"100.64.0.1", | ||
"0.0.0.0", | ||
"255.255.255.255", | ||
"240.0.0.1", | ||
"198.18.0.1", | ||
"192.0.0.8", | ||
"192.0.2.1", | ||
"224.0.0.1", | ||
"::1", | ||
"::", | ||
"fc00::1", | ||
"fd12::1", | ||
"fe80::1", | ||
"ff02::1", | ||
"2001:db8::1", | ||
"64:ff9b::a00:1", | ||
"::ffff:127.0.0.1", | ||
]; | ||
for ip in inward { | ||
assert!(!public(ip.parse().unwrap()), "{ip}"); | ||
} | ||
} | ||
| ||
#[test] | ||
fn pdses_asked() { | ||
assert!(may_ask("https://pds.example").is_ok()); | ||
assert!(may_ask("https://8.8.8.8").is_ok()); | ||
let refused = |pds| may_ask(pds).unwrap_err().to_string(); | ||
assert_eq!( | ||
refused("http://pds.example"), | ||
"its PDS, http://pds.example, is not on https" | ||
); | ||
for pds in [ | ||
"https://127.0.0.1:2583", | ||
"https://[::1]", | ||
"https://10.0.0.2", | ||
] { | ||
let why = format!("its PDS, {pds}, is not a public address"); | ||
assert_eq!(refused(pds), why); | ||
} | ||
assert!(may_ask("not a url").is_err()); | ||
} | ||
| ||
#[tokio::test] | ||
async fn names_resolve_to_public_addresses_only() { | ||
let resolve = |name: &str| PublicOnly.resolve(name.parse().unwrap()); | ||
let found: Vec<SocketAddr> = resolve("8.8.8.8").await.unwrap().collect(); | ||
assert_eq!(found, ["8.8.8.8:0".parse().unwrap()]); | ||
for name in ["127.0.0.1", "localhost"] { | ||
let why = resolve(name).await.err().unwrap().to_string(); | ||
assert_eq!(why, format!("{name} has no public address")); | ||
} | ||
} | ||
} | ||
crates/grain-serve/src/sync.rs+41−3
| @@ -8,6 +8,10 @@ | ||
//! from the PDS the repo's DID document names, and put by its URI and CID, | ||
//! so Jetstream delivering the same record later changes nothing. A record | ||
//! the PDS no longer has is deleted from the index. | ||
//! | ||
//! Whoever holds a service token names the PDS asked, by their DID | ||
//! document, so it is asked only as [`grain::guard`] allows: on https, at | ||
//! a public address, following no redirect. | ||
| ||
use crate::App; | ||
use crate::index::{Index, PutError, record_uri, records}; | ||
| @@ -47,6 +51,8 @@ pub enum ReadBackError { | ||
RepoMismatch(String), | ||
/// A record of a collection the index does not keep. | ||
UnknownCollection(String), | ||
/// The repo's PDS is not one grain asks ([`grain::guard`]). | ||
Unaskable(grain::guard::Refused), | ||
/// The PDS failed to answer for the record, other than to say it has | ||
/// no such record. | ||
Pds { | ||
| @@ -69,6 +75,7 @@ impl std::fmt::Display for ReadBackError { | ||
ReadBackError::UnknownCollection(uri) => { | ||
write!(f, "{uri} is of a collection grain does not index") | ||
} | ||
ReadBackError::Unaskable(why) => write!(f, "the repo's PDS is not asked: {why}"), | ||
ReadBackError::Pds { uri, error } => write!(f, "reading {uri}: {error:#}"), | ||
ReadBackError::Refused { uri, why } => { | ||
write!(f, "{uri} does not fit its lexicon: {why}") | ||
| @@ -87,6 +94,7 @@ impl From<ReadBackError> for Failure { | ||
ReadBackError::NotARecord(_) => (StatusCode::BAD_REQUEST, "InvalidRequest"), | ||
ReadBackError::RepoMismatch(_) => (StatusCode::BAD_REQUEST, "RepoMismatch"), | ||
ReadBackError::UnknownCollection(_) => (StatusCode::BAD_REQUEST, "UnknownCollection"), | ||
ReadBackError::Unaskable(_) => (StatusCode::BAD_REQUEST, "InvalidRequest"), | ||
ReadBackError::Pds { .. } => (StatusCode::BAD_GATEWAY, "UpstreamFailure"), | ||
ReadBackError::Refused { .. } => (StatusCode::BAD_REQUEST, "InvalidRequest"), | ||
ReadBackError::Index(_) => (StatusCode::INTERNAL_SERVER_ERROR, "InternalServerError"), | ||
| @@ -101,7 +109,8 @@ impl From<ReadBackError> for Failure { | ||
/// on, so that Jetstream keeps what it writes next. | ||
/// | ||
/// A URI outside `did`'s repo, or of a collection the index does not keep, | ||
/// refuses the whole call before anything is read. The records are read | ||
/// refuses the whole call before anything is read, as does a `pds` that | ||
/// [`Ctx::may_ask`] does not let be asked. The records are read | ||
/// at once, and each one read is applied whatever became of the others, | ||
/// so a call that fails part way can be made again as it was. A URI named | ||
/// twice is read once. | ||
| @@ -127,7 +136,8 @@ pub async fn read_back( | ||
named.push((uri, collection, rkey)); | ||
} | ||
} | ||
let pds = Xrpc::new(ctx.http.clone(), pds); | ||
ctx.may_ask(pds).map_err(ReadBackError::Unaskable)?; | ||
let pds = Xrpc::new(ctx.guarded.clone(), pds); | ||
let read = named | ||
.iter() | ||
.map(|&(_, collection, rkey)| get_record(&pds, did, collection, rkey)); | ||
| @@ -232,13 +242,23 @@ pub async fn index_records( | ||
} | ||
let pds = grain::identity::resolve_pds(&app.ctx, &did) | ||
.await | ||
.map_err(|e| Failure::new(StatusCode::BAD_GATEWAY, "UpstreamFailure", format!("{e:#}")))?; | ||
.map_err(unresolved)?; | ||
let done = read_back(&app.ctx, index, &did, &pds, &input.uris).await?; | ||
tracing::info!(%did, indexed = done.indexed.len(), not_found = done.not_found.len(), | ||
"indexRecords"); | ||
Ok(Json(done)) | ||
} | ||
| ||
/// The caller's PDS not found, as `indexRecords` answers it: the caller's | ||
/// to fix if their DID document names one grain does not ask, else the | ||
/// network's. | ||
fn unresolved(e: anyhow::Error) -> Failure { | ||
match e.downcast::<grain::guard::Refused>() { | ||
Ok(refused) => ReadBackError::Unaskable(refused).into(), | ||
Err(e) => Failure::new(StatusCode::BAD_GATEWAY, "UpstreamFailure", format!("{e:#}")), | ||
} | ||
} | ||
| ||
#[cfg(test)] | ||
mod tests { | ||
use super::*; | ||
| @@ -254,6 +274,13 @@ mod tests { | ||
400, | ||
"UnknownCollection", | ||
), | ||
( | ||
ReadBackError::Unaskable(grain::guard::Refused( | ||
"http://pds is not on https".into(), | ||
)), | ||
400, | ||
"InvalidRequest", | ||
), | ||
( | ||
ReadBackError::Pds { | ||
uri: uri(), | ||
| @@ -276,6 +303,17 @@ mod tests { | ||
"InternalServerError", | ||
), | ||
]; | ||
let refused = grain::guard::Refused("http://pds is not on https".into()); | ||
let failure = unresolved(refused.into()); | ||
assert_eq!( | ||
(failure.status.as_u16(), failure.error), | ||
(400, "InvalidRequest") | ||
); | ||
let failure = unresolved(anyhow::anyhow!("did:plc:a names no #atproto_pds service")); | ||
assert_eq!( | ||
(failure.status.as_u16(), failure.error), | ||
(502, "UpstreamFailure") | ||
); | ||
for (e, status, error) in cases { | ||
let said = e.to_string(); | ||
let failure = Failure::from(e); | ||
crates/grain-serve/tests/backfill.rs+2−7
| @@ -147,8 +147,6 @@ fn config(stand: &str, mirror: bool) -> Config { | ||
total: Duration::from_secs(60), | ||
rediscover: Duration::from_secs(3600), | ||
poll: Duration::from_millis(50), | ||
// The reference PDS and the stand-in are on loopback, over http. | ||
private: true, | ||
} | ||
} | ||
| ||
| @@ -443,9 +441,8 @@ async fn failures_are_tried_again_or_given_up() { | ||
| ||
// Unless private hosts are allowed, a PDS on loopback over http, as a | ||
// DID document may name, is given up on at once and never asked. | ||
let mut public = config(&url, false); | ||
public.private = false; | ||
let public = Backfill::new(index.clone(), ctx(), public); | ||
let public = pds::ctx(dir.path(), &[("GRAIN_ALLOW_PRIVATE", "0")]); | ||
let public = Backfill::new(index.clone(), public, config(&url, false)); | ||
index.resync(&f.did).unwrap(); | ||
let why = public.backfill(&f.did).await.unwrap_err().to_string(); | ||
assert!(why.ends_with("is not on https"), "{why}"); | ||
| @@ -519,10 +516,8 @@ fn settings() { | ||
("GRAIN_BACKFILL_MIRROR", ""), | ||
("GRAIN_BACKFILL_SKIP", ""), | ||
("GRAIN_BACKFILL_CONCURRENCY", "0"), | ||
("GRAIN_BACKFILL_PRIVATE", "1"), | ||
])) | ||
.unwrap(); | ||
assert!(set.private); | ||
assert_eq!(set.directories, ["https://a.example", "https://b.example"]); | ||
assert_eq!((set.mirror, set.skip.len(), set.concurrency), (None, 0, 1)); | ||
let settings = grain_serve::Settings::from_vars(vars(&[ | ||
crates/grain-serve/tests/index_records.rs+45−1
| @@ -44,11 +44,16 @@ struct Caller { | ||
/// A fresh account on the reference PDS, logged in, and grain-serve with | ||
/// an index of its own, taking tokens for [`AUD`]. | ||
async fn caller() -> Caller { | ||
caller_with(&[]).await | ||
} | ||
| ||
/// [`caller`], with grain-serve's context set with `extra` too. | ||
async fn caller_with(extra: &[(&str, &str)]) -> Caller { | ||
let dir = tempfile::tempdir().unwrap(); | ||
let ctx = net::ctx(dir.path(), &[]); | ||
let index = Arc::new(Index::memory().unwrap()); | ||
let served = common::served_app(App { | ||
ctx: ctx.clone(), | ||
ctx: net::ctx(&dir.path().join("serve"), extra), | ||
index: Some(index.clone()), | ||
did: Some(AUD.into()), | ||
}) | ||
| @@ -347,3 +352,42 @@ async fn a_pds_that_does_not_answer_fails_the_read_back() { | ||
other => panic!("{other:?}"), | ||
} | ||
} | ||
| ||
/// The reference PDS is on loopback, over http, as any DID document may | ||
/// name one: unless private hosts are allowed, it is not asked. | ||
#[tokio::test] | ||
async fn a_caller_whose_pds_is_not_public_is_refused() { | ||
let c = caller_with(&[("GRAIN_ALLOW_PRIVATE", "0")]).await; | ||
let (uri, _) = c.write("social.grain.gallery", gallery("Inward")).await; | ||
let (status, body) = c.index_records(&[&uri]).await; | ||
assert_eq!( | ||
(status, body["error"].as_str()), | ||
(400, Some("InvalidRequest")), | ||
"{body}" | ||
); | ||
let why = body["message"].as_str().unwrap(); | ||
assert!(why.ends_with("is not on https"), "{why}"); | ||
assert!(c.index.get::<Gallery>(&uri).unwrap().is_none()); | ||
| ||
// Named outright, a PDS on https at loopback is not asked, nor one | ||
// whose name resolves to it. | ||
let dir = tempfile::tempdir().unwrap(); | ||
let guarded = net::ctx(dir.path(), &[("GRAIN_ALLOW_PRIVATE", "0")]); | ||
let uris = std::slice::from_ref(&uri); | ||
let read = |pds: &'static str| read_back(&guarded, &c.index, &c.me.did, pds, uris); | ||
match read("https://127.0.0.1:2583").await { | ||
Err(ReadBackError::Unaskable(why)) => assert_eq!( | ||
why.to_string(), | ||
"https://127.0.0.1:2583 is not at a public address" | ||
), | ||
other => panic!("{other:?}"), | ||
} | ||
match read("https://localhost:2583").await { | ||
Err(ReadBackError::Pds { error, .. }) => { | ||
let why = format!("{error:?}"); | ||
assert!(why.contains("localhost has no public address"), "{why}"); | ||
} | ||
other => panic!("{other:?}"), | ||
} | ||
assert!(c.index.get::<Gallery>(&uri).unwrap().is_none()); | ||
} | ||