holger-estate-front/tests/estate_links_say_https_through_the_front.rs
//! ★ **On an estate, the links a box writes say `https://` and the repository host**
//! (HOL-DOCFIND, Holger 3.1.1).
//!
//! The estate's doors are clear text on the overlay; TLS ends at the front. A link a box
//! writes without a `ron_base_url` (an npm packument's tarballs, a crates source's `dl`)
//! takes its scheme and host from `X-Forwarded-Proto`/`-Host`, believed only from the
//! endpoint's `ron_trusted_proxies`, which the estate declaration sets to the front
//! (`estate_ron::FRONT_TRUSTED_PROXY`, the front's overlay address). The front (edda
//! `estate-front` 0.3.4) writes both headers and drops whatever a client sent in them.
//!
//! Here: the REAL front router (`estate_front::web::serve`, the repository route) in front of
//! a REAL Holger with pull-through npm and crates sources, whose door trusts the front's
//! address (loopback in this process, where the estate has `10.44.0.1`). A client that asks
//! `repo.test3.holger.rs` through the front, and sends `X-Forwarded-Proto: http` and
//! `X-Forwarded-Host: evil.example` of its own, gets `https://repo.test3.holger.rs/…` links
//! from npm and cargo.
use std::io::{Read, Write};
use std::net::{SocketAddrV4, TcpListener};
use std::time::Duration;
use tower::ServiceExt as _;
const REPO_HOST: &str = "repo.test3.holger.rs";
/// The estate declaration's trusted proxy (`holger_recipe::estate_ron::FRONT_TRUSTED_PROXY`),
/// read from its source: this crate does not link the recipe.
fn front_trusted_proxy() -> String {
let src = include_str!("../../holger-recipe/src/estate_ron.rs");
let line = src
.lines()
.find(|l| l.starts_with("pub const FRONT_TRUSTED_PROXY: &str = "))
.expect("holger-recipe declares FRONT_TRUSTED_PROXY");
line.split('"').nth(1).expect("a quoted CIDR").to_string()
}
/// The estate's doors trust the front, and the front is the overlay's `.1` (the address it
/// dials the boxes from). `holger-recipe`'s own test holds that the declaration names it.
#[test]
fn the_estate_declaration_trusts_the_front() {
assert_eq!(
front_trusted_proxy(),
format!("{}/32", nordisk_pairing::OVERLAY_FRONT),
"the trusted proxy is the address the front dials the boxes from"
);
}
/// A registry that answers one packument (`is-number`, 7.0.0) and 404 for everything else.
fn npm_upstream() -> String {
let l = TcpListener::bind("127.0.0.1:0").unwrap();
let base = format!("http://{}", l.local_addr().unwrap());
let b2 = base.clone();
std::thread::spawn(move || {
for mut s in l.incoming().flatten() {
let mut buf = [0u8; 4096];
let n = s.read(&mut buf).unwrap_or(0);
let req = String::from_utf8_lossy(&buf[..n]).to_string();
let path = req.split_whitespace().nth(1).unwrap_or("/").to_string();
let (status, body) = if path.trim_end_matches('/').ends_with("/is-number") {
(
200,
serde_json::json!({
"name": "is-number",
"dist-tags": { "latest": "7.0.0" },
"versions": { "7.0.0": { "name": "is-number", "version": "7.0.0",
"dist": { "tarball": format!("{b2}/is-number/-/is-number-7.0.0.tgz"),
"shasum": "0000000000000000000000000000000000000000",
"integrity": "sha512-AAAA" } } },
})
.to_string(),
)
} else {
(404, "{}".to_string())
};
let _ = write!(
s,
"HTTP/1.1 {status} X\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
body.len()
);
}
});
base
}
/// A Holger with the intake chain and pull-through npm and crates sources, whose door
/// trusts `trusted` (the front's address).
fn primary(npm: &str, trusted: &str) -> (tempfile::TempDir, holger_server_lib::Holger, u16) {
for _ in 0..8 {
let dir = tempfile::tempdir().unwrap();
let d = dir.path().display().to_string();
let free = || {
std::net::TcpListener::bind("127.0.0.1:0")
.unwrap()
.local_addr()
.unwrap()
.port()
};
let (grpc, http) = (free(), free());
let repo = |name: &str| {
format!(
r#"(ron_name: "{name}", ron_repo_type: "raw", ron_upstreams: [], ron_in: None,
ron_out: Some((ron_storage_endpoint: "storage", ron_exposed_endpoint: "doors")), ron_data_dir: Some("{d}/{name}")),"#
)
};
let ron = format!(
r#"(
repositories: [ {} {} {} {} ],
exposed_endpoints: [ (ron_name: "doors", ron_url: "127.0.0.1:{grpc}", ron_http_url: Some("127.0.0.1:{http}"),
ron_trusted_proxies: ["{trusted}"]) ],
storage_endpoints: [ (ron_name: "storage", ron_storage_type: "znippy", ron_path: "{d}") ],
audit: (dir: "{d}/audit", enabled: true),
auth: Some(( methods: [ StaticToken(subject: "bob", token: Some("bob-token-of-sufficient-length")) ],
duties: {{ "bob": [SecurityApprover] }} )),
promotion: Some((
ladder_name: "intake",
stages: [ (name: "incoming", repo: "incoming"), (name: "quarantine", repo: "quarantine"),
(name: "approved", repo: "approved"), (name: "production", repo: "production") ],
intake: Some(( incoming: "incoming", quarantine: "quarantine", approved: "approved", production: "production",
rules: (sandbox: off), approvals: 1, scan: None )),
)),
pull_through: Some((
sources: [ (name: "npmjs", kind: npm, url: "{npm}/"),
(name: "crates-io", kind: crates, url: "sparse+{npm}/index/") ],
rules: (require_signed: false), retry_after_secs: Some(1),
)),
)"#,
repo("incoming"),
repo("quarantine"),
repo("approved"),
repo("production"),
);
let cfg = dir.path().join("holger.ron");
std::fs::write(&cfg, ron).unwrap();
let started = (|| -> Result<holger_server_lib::Holger, String> {
let mut h = holger_server_lib::read_ron_config(&cfg).map_err(|e| e.to_string())?;
h.instantiate_backends().map_err(|e| e.to_string())?;
holger_server_lib::wire_holger(&mut h).map_err(|e| format!("{e:#}"))?;
h.start().map_err(|e| format!("{e:#}"))?;
Ok(h)
})();
match started {
Ok(h) => return (dir, h, http),
Err(e) => eprintln!("primary did not start: {e}"),
}
}
panic!("the primary never started");
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn npm_and_cargo_links_say_https_and_the_repository_host_through_the_front() {
// In this process the front dials from loopback; on an estate from the overlay's `.1`.
let trusted =
front_trusted_proxy().replace(&nordisk_pairing::OVERLAY_FRONT.to_string(), "127.0.0.1");
assert_eq!(trusted, "127.0.0.1/32");
let npm = npm_upstream();
let (_dir, _holger, http) = primary(&npm, &trusted);
let up: SocketAddrV4 = format!("127.0.0.1:{http}").parse().unwrap();
let app =
estate_front::web::serve::App::new(axum::Router::new().fallback(|| async { "console" }));
app.set_routed(Some(estate_front::web::serve::Routed {
hosts: vec![REPO_HOST.into()],
http: up,
grpc: up,
grpc_services: Vec::new(),
}));
// A client of the front, with forwarded headers of its own that must change nothing.
let through_front = |path: &str| {
let req = http::Request::get(path)
.header("host", REPO_HOST)
.header("x-forwarded-proto", "http")
.header("x-forwarded-host", "evil.example")
.header("authorization", "Bearer bob-token-of-sufficient-length")
.body(axum::body::Body::empty())
.unwrap();
let r = app.router_now();
async move {
let resp = r.oneshot(req).await.unwrap();
let status = resp.status().as_u16();
let body = axum::body::to_bytes(resp.into_body(), 1 << 20)
.await
.unwrap();
(status, String::from_utf8_lossy(&body).to_string())
}
};
// cargo: the crates source's config.json.
let (status, body) = through_front("/production/crates-io/config.json").await;
assert_eq!(status, 200, "{body}");
let cfg: serde_json::Value = serde_json::from_str(&body).unwrap();
assert_eq!(
cfg["dl"],
format!("https://{REPO_HOST}/production/crates-io/{{crate}}/{{crate}}-{{version}}.crate"),
"cargo downloads over https from the repository host: {body}"
);
// npm: the packument (pulled, held: its version rides deprecated with its tarball link).
let mut packument = String::new();
for _ in 0..100 {
let (status, body) = through_front("/production/npmjs/is-number").await;
if status == 200 && body.contains("\"tarball\"") {
packument = body;
break;
}
tokio::time::sleep(Duration::from_millis(200)).await;
}
let doc: serde_json::Value = serde_json::from_str(&packument)
.unwrap_or_else(|e| panic!("no packument through the front ({e}): {packument}"));
let tarball = doc["versions"]["7.0.0"]["dist"]["tarball"]
.as_str()
.unwrap_or_else(|| panic!("a tarball link: {packument}"))
.to_string();
assert_eq!(
tarball,
format!("https://{REPO_HOST}/production/npmjs/is-number/-/is-number-7.0.0.tgz"),
"npm fetches the tarball over https from the repository host"
);
assert!(
!packument.contains("evil.example") && !packument.contains("http://"),
"{packument}"
);
// The same door asked directly (no front, no forwarded headers): its own clear-text face.
let mut s = std::net::TcpStream::connect(("127.0.0.1", http)).unwrap();
write!(
s,
"GET /production/crates-io/config.json HTTP/1.1\r\nHost: 127.0.0.1:{http}\r\nAuthorization: Bearer bob-token-of-sufficient-length\r\nConnection: close\r\n\r\n"
)
.unwrap();
let mut raw = String::new();
s.read_to_string(&mut raw).unwrap();
assert!(
raw.contains(&format!(
"\"dl\":\"http://127.0.0.1:{http}/production/crates-io/"
)),
"{raw}"
);
}