symbi-runtime 1.21.1

Agent Runtime System for the Symbi platform
use super::*;

fn scope() -> BrowserScopeDef {
    BrowserScopeDef {
        allowed_domains: vec![],
        blocked_domains: vec![],
        allow_external: true,
    }
}

fn request(url: &str, method: &str) -> Value {
    json!({"requestId":"request-1", "request":{"url":url,"method":method,"headers":{}}})
}

#[test]
fn private_capabilities_are_exact_and_do_not_replace_scope() {
    let default = BrowserNetworkPolicy::new(&scope(), None).unwrap();
    for url in [
        "http://127.0.0.1:8080/",
        "http://10.0.0.1/",
        "http://localhost/",
        "http://[::1]/",
        "http://169.254.169.254/",
    ] {
        assert!(default.check_destination(url, "GET").is_err(), "{url}");
    }
    assert!(default
        .check_destination("https://example.com/", "GET")
        .is_ok());
    let network = BrowserNetworkDef {
        private_origins: vec![
            "http://127.0.0.1:8080".into(),
            "https://[fd12::1]:8443".into(),
        ],
        ..Default::default()
    };
    let policy = BrowserNetworkPolicy::new(&scope(), Some(&network)).unwrap();
    for url in [
        "http://127.1:8080/path",
        "http://0x7f000001:8080/",
        "https://[fd12::1]:8443/",
    ] {
        assert!(policy.check_destination(url, "GET").is_ok(), "{url}");
    }
    for url in [
        "http://127.0.0.1:8081/",
        "https://127.0.0.1:8080/",
        "http://localhost:8080/",
        "http://127.0.0.2:8080/",
        "http://[::ffff:127.0.0.1]:8080/",
    ] {
        assert!(policy.check_destination(url, "GET").is_err(), "{url}");
    }
    let blocked = BrowserScopeDef {
        blocked_domains: vec!["127.0.0.1".into()],
        ..scope()
    };
    assert!(BrowserNetworkPolicy::new(&blocked, Some(&network))
        .unwrap()
        .check_destination("http://127.1:8080/", "GET")
        .is_err());
}

#[test]
fn malformed_or_overbroad_capabilities_fail_closed() {
    for origin in [
        "http://localhost:80",
        "http://169.254.169.254",
        "http://[fd00:ec2::254]",
        "http://0.0.0.0",
        "http://224.0.0.1",
        "http://[fe80::1]",
        "http://[::ffff:10.0.0.1]",
        "https://example.com",
        "http://127.0.0.1/path",
        "http://127.0.0.1?query",
        "http://127.0.0.1#hash",
        "http://user@127.0.0.1",
        "http://127.0.0.1:0",
    ] {
        let network = BrowserNetworkDef {
            private_origins: vec![origin.into()],
            ..Default::default()
        };
        assert!(
            BrowserNetworkPolicy::new(&scope(), Some(&network)).is_err(),
            "{origin}"
        );
    }
    for methods in [
        vec![],
        vec!["CONNECT"],
        vec!["TRACE"],
        vec!["get"],
        vec!["GET", "GET"],
    ] {
        let network = BrowserNetworkDef {
            allowed_methods: methods.into_iter().map(String::from).collect(),
            ..Default::default()
        };
        assert!(BrowserNetworkPolicy::new(&scope(), Some(&network)).is_err());
    }
    let policy = BrowserNetworkPolicy::new(&scope(), None).unwrap();
    assert!(policy
        .prepare_request(&request("https://example.com/", "POST"))
        .is_err());
    assert!(toml::from_str::<BrowserNetworkDef>("allow_private=true").is_err());
}

#[test]
fn request_translation_binds_authority_and_preserves_complete_body_bytes() {
    let policy = BrowserNetworkPolicy::new(
        &scope(),
        Some(&BrowserNetworkDef {
            allowed_methods: vec!["GET".into(), "POST".into()],
            ..Default::default()
        }),
    )
    .unwrap();
    let mut input = request("https://ExAMPLe.com:443/a/../effect#fragment", "POST");
    input["request"]["headers"] = json!({"Host":"metadata.invalid", "Connection":"X-Hop", "X-Hop":"removed", "Proxy-Authorization":"removed", "Content-Length":"999", "Transfer-Encoding":"chunked", "Expect":"100-continue", "X-Exact":"preserved", "Content-Type":"application/octet-stream"});
    input["request"]["hasPostData"] = json!(true);
    input["request"]["postDataEntries"] =
        json!([{"bytes":STANDARD.encode([0, 255])},{"bytes":STANDARD.encode(b" exact ")}]);
    let output = policy.prepare_request(&input).unwrap();
    assert_eq!(output.url().as_str(), "https://example.com/effect");
    assert_eq!(output.body().unwrap().as_bytes().unwrap(), b"\0\xff exact ");
    assert_eq!(output.headers().len(), 2);
    assert_eq!(output.headers()["x-exact"], "preserved");
    for change in [
        json!({"responseStatusCode":200}),
        json!({"responseErrorReason":"Failed"}),
    ] {
        let mut changed = input.clone();
        changed
            .as_object_mut()
            .unwrap()
            .extend(change.as_object().unwrap().clone());
        assert!(policy.prepare_request(&changed).is_err());
    }
}

#[test]
fn ambiguous_incomplete_and_oversized_requests_are_refused() {
    let policy = BrowserNetworkPolicy::new(
        &scope(),
        Some(&BrowserNetworkDef {
            allowed_methods: vec!["POST".into()],
            ..Default::default()
        }),
    )
    .unwrap();
    let cases = [
        json!({"hasPostData":true}),
        json!({"hasPostData":false,"postData":"present"}),
        json!({"hasPostData":"true"}),
        json!({"postDataEntries":[{}]}),
        json!({"hasPostData":true,"postDataEntries":[]}),
        json!({"postDataEntries":[{"bytes":"YQ==","file":"omitted"}]}),
        json!({"postDataEntries":[{"bytes":"bad base64"}]}),
        json!({"postDataEntries":[{"bytes":"YQ=="}],"postData":"different"}),
        json!({"postData": "x".repeat(MAX_REQUEST_BYTES + 1)}),
        json!({"headers":{"Content-Type":"multipart/form-data; boundary=test"}}),
        json!({"headers":{"Content-Type":" \tMuLtIpArT/form-data; boundary=test"}}),
        json!({"headers":{"X-Duplicate":"one","x-duplicate":"two"}}),
        json!({"headers":{"X-Invalid":"value\r\nInjected: yes"}}),
        json!({"headers":{":authority":"metadata.invalid"}}),
        json!({"headers":{"X-Large":"x".repeat(MAX_HEADER_BYTES + 1)}}),
    ];
    for change in cases {
        let mut input = request("https://example.com/", "POST");
        input["request"]
            .as_object_mut()
            .unwrap()
            .extend(change.as_object().unwrap().clone());
        assert!(policy.prepare_request(&input).is_err());
    }
}

#[test]
fn response_translation_preserves_redirects_cookies_and_binary_values() {
    let mut headers = HeaderMap::new();
    headers.insert(
        "location",
        HeaderValue::from_static("http://127.0.0.1/blocked"),
    );
    headers.insert("connection", HeaderValue::from_static("x-hop"));
    headers.insert("x-hop", HeaderValue::from_static("removed"));
    headers.insert("content-length", HeaderValue::from_static("999"));
    headers.append("set-cookie", HeaderValue::from_static("first=one"));
    headers.append("set-cookie", HeaderValue::from_static("second=two"));
    headers.insert("x-binary", HeaderValue::from_bytes(&[255]).unwrap());
    let output = fulfill_response("paused-1", 302, &headers, b"encoded body").unwrap();
    assert_eq!(output["responseCode"], 302);
    assert_eq!(
        STANDARD.decode(output["body"].as_str().unwrap()).unwrap(),
        b"encoded body"
    );
    let bytes = STANDARD
        .decode(output["binaryResponseHeaders"].as_str().unwrap())
        .unwrap();
    let fields: Vec<_> = bytes.split(|byte| *byte == 0).collect();
    assert_eq!(fields.len(), 4);
    assert!(fields.contains(&b"set-cookie: first=one".as_slice()));
    assert!(fields.contains(&b"set-cookie: second=two".as_slice()));
    assert!(fields.contains(&b"x-binary: \xff".as_slice()));
    assert!(fulfill_response("id", 101, &headers, &[]).is_err());
    assert!(fulfill_response("id", 200, &headers, &vec![0; MAX_RESPONSE_BYTES + 1]).is_err());
}