Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/holger

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}"
    );
}