From 6a4b279e0afaace3b35d2c02907a6a0d9b6cacb8 Mon Sep 17 00:00:00 2001 From: Anders Alm Date: Thu, 1 Oct 2026 15:47:46 -0500 Subject: [PATCH 1/2] fix(gateway): isolate CID and user-site content --- .github/workflows/ci.yml | 12 + .../crates/elastos-server/src/api/gateway.rs | 25 +- .../elastos-server/src/api/gateway_site.rs | 43 ++ .../src/api/gateway_tests/content_sandbox.rs | 501 ++++++++++++++++++ .../src/api/gateway_tests/mod.rs | 1 + .../gateway-content-sandbox-browser-smoke.mjs | 171 ++++++ 6 files changed, 743 insertions(+), 10 deletions(-) create mode 100644 elastos/crates/elastos-server/src/api/gateway_tests/content_sandbox.rs create mode 100644 scripts/gateway-content-sandbox-browser-smoke.mjs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e6f5447f9..8d3692f74 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -141,6 +141,18 @@ jobs: run: cargo test --workspace --no-fail-fast # Network-sensitive tests are #[ignore] and won't run. # Run them explicitly with: cargo test --workspace -- --ignored + - uses: actions/setup-node@v4 + with: + node-version: '26' + - name: install content boundary browser fixture + run: | + npm install --prefix "${RUNNER_TEMP}/content-sandbox-browser" --no-save --package-lock=false playwright@1.59.1 + "${RUNNER_TEMP}/content-sandbox-browser/node_modules/.bin/playwright" install chromium --with-deps + - name: gateway content sandbox browser regression + working-directory: elastos + env: + ELASTOS_PLAYWRIGHT_MODULE: ${{ runner.temp }}/content-sandbox-browser/node_modules/playwright/index.mjs + run: cargo test -p elastos-server content_sandbox_browser -- --ignored --nocapture test-capsules: name: test-capsules diff --git a/elastos/crates/elastos-server/src/api/gateway.rs b/elastos/crates/elastos-server/src/api/gateway.rs index c8b16a0df..655c4db6f 100644 --- a/elastos/crates/elastos-server/src/api/gateway.rs +++ b/elastos/crates/elastos-server/src/api/gateway.rs @@ -587,8 +587,21 @@ fn apply_capsule_cors_headers(headers: &mut HeaderMap, origin: &HeaderValue) { } fn gateway_router_with_api_url(state: GatewayState, gateway_api_url: String) -> Router { - Router::new() + let user_content = Router::new() .route("/", get(serve_public_root)) + .route("/s/:cid", get(redirect_cid_root)) + .route("/s/:cid/", get(serve_cid_root)) + .route("/s/:cid/*path", get(serve_cid_file)) + .route("/content/:cid", get(serve_ipfs_cid_root)) + // IPFS-compatible paths so install.sh can use this gateway like ipfs.io. + .route("/ipfs/:cid", get(serve_ipfs_cid_root)) + .route("/ipfs/:cid/", get(serve_cid_root)) + .route("/ipfs/:cid/*path", get(serve_cid_file)) + .route("/*path", get(serve_public_site_path)) + .layer(axum::middleware::map_response(sandbox_content_response)); + + Router::new() + .merge(user_content) .route("/healthz", get(healthz)) .route( "/api/auth/evm/challenge", @@ -1231,17 +1244,9 @@ fn gateway_router_with_api_url(state: GatewayState, gateway_api_url: String) -> "/apps/:app/*path", get(super::browser_capsules::serve_browser_app_asset), ) - .route("/s/:cid", get(redirect_cid_root)) - .route("/s/:cid/", get(serve_cid_root)) - .route("/s/:cid/*path", get(serve_cid_file)) - .route("/content/:cid", get(serve_ipfs_cid_root)) - // IPFS-compatible paths so install.sh can use this gateway like ipfs.io - .route("/ipfs/:cid", get(serve_ipfs_cid_root)) - .route("/ipfs/:cid/", get(serve_cid_root)) - .route("/ipfs/:cid/*path", get(serve_cid_file)) - .route("/*path", get(serve_public_site_path)) .with_state(state) .layer(Extension(TrustedGatewayApiUrl(Arc::from(gateway_api_url)))) + .layer(axum::middleware::from_fn(refuse_content_api_resources)) .layer(axum::middleware::from_fn(capsule_origin_cors)) } diff --git a/elastos/crates/elastos-server/src/api/gateway_site.rs b/elastos/crates/elastos-server/src/api/gateway_site.rs index 3701d4fd8..df4924e65 100644 --- a/elastos/crates/elastos-server/src/api/gateway_site.rs +++ b/elastos/crates/elastos-server/src/api/gateway_site.rs @@ -1,5 +1,48 @@ use super::*; +pub(super) async fn sandbox_content_response(mut response: Response) -> Response { + // User content has an opaque origin and keeps its scripts and form controls. + // Its scripts cannot use the gateway's opaque-app CORS path to read APIs. + response.headers_mut().insert( + axum::http::header::CONTENT_SECURITY_POLICY, + HeaderValue::from_static( + "sandbox allow-scripts allow-forms allow-popups; connect-src 'none'", + ), + ); + response +} + +pub(super) async fn refuse_content_api_resources( + request: axum::extract::Request, + next: axum::middleware::Next, +) -> Response { + let headers = request.headers(); + let opaque_or_cross_site = headers.get("origin").is_some_and(|value| value == "null") + || headers + .get("sec-fetch-site") + .is_some_and(|value| value == "cross-site"); + let resource_or_navigation = headers + .get("sec-fetch-dest") + .is_some_and(|value| value != "empty") + || headers + .get("sec-fetch-mode") + .is_some_and(|value| value == "navigate"); + // Content can use forms and links, but API documents and resource loads + // cross the opaque boundary. App fetches and ticketed streams retain their + // own capability checks; untrusted content's CSP blocks those connections. + if request.uri().path().starts_with("/api/apps/") + && opaque_or_cross_site + && resource_or_navigation + { + return ( + StatusCode::FORBIDDEN, + "API resource access requires an app connection", + ) + .into_response(); + } + next.run(request).await +} + #[derive(Debug, Deserialize)] struct EdgeBinding { target: String, diff --git a/elastos/crates/elastos-server/src/api/gateway_tests/content_sandbox.rs b/elastos/crates/elastos-server/src/api/gateway_tests/content_sandbox.rs new file mode 100644 index 000000000..408f0dc45 --- /dev/null +++ b/elastos/crates/elastos-server/src/api/gateway_tests/content_sandbox.rs @@ -0,0 +1,501 @@ +use super::*; + +const CONTENT_POLICY: &str = "sandbox allow-scripts allow-forms allow-popups; connect-src 'none'"; +const PAGE: &str = r#"Content sandbox fixture + +
+
+Download"#; +const STYLE: &str = "#result { color: rgb(12, 34, 56); }"; +const SVG: &str = r#""#; +const DOWNLOAD: &[u8] = b"\x00sandbox-download\xff"; +const PROBE: &str = r#" +window.probe = { script: true, violations: [], fetches: [] }; +document.addEventListener('securitypolicyviolation', event => { + probe.violations.push(event.effectiveDirective); +}); +for (const [name, read] of Object.entries({ + cookie: () => document.cookie, + localStorage: () => localStorage.getItem('runtime-sandbox-sentinel'), + sessionStorage: () => sessionStorage.getItem('runtime-sandbox-sentinel'), + parentDocument: () => parent.document.title, + parentStorage: () => parent.localStorage.getItem('runtime-sandbox-sentinel') +})) { + try { probe[name] = { value: read(), denied: false }; } + catch (error) { probe[name] = { denied: error.name === 'SecurityError' }; } +} +document.querySelector('#form').addEventListener('submit', event => { + event.preventDefault(); + document.querySelector('#result').textContent = new FormData(event.target).get('message'); +}); +(async () => { + for (const [path, method, body] of [ + ['/api/apps/home/summary', 'GET'], + ['/api/apps/home/state', 'GET'], + ['/api/apps/home/state', 'POST', '{}'], + ['/api/apps/assistant/workspace', 'GET'], + ['/api/apps/assistant/workspace', 'PUT', '{"schema":"elastos.assistant.workspace/v1","if_revision":0,"draft":"attack"}'] + ]) { + try { + const response = await fetch(path, { method, body, credentials: 'include', + headers: body ? { 'content-type': 'application/json' } : undefined }); + probe.fetches.push({ path, method, blocked: false, status: response.status }); + } catch (error) { probe.fetches.push({ path, method, blocked: error.name === 'TypeError' }); } + } + probe.ready = true; +})(); +"#; + +fn write_content_fixture(root: &std::path::Path) { + std::fs::create_dir_all(root.join("directory")).unwrap(); + for (path, bytes) in [ + ("index.html", PAGE.as_bytes()), + ("directory/index.html", PAGE.as_bytes()), + ("probe.js", PROBE.as_bytes()), + ("style.css", STYLE.as_bytes()), + ("image.svg", SVG.as_bytes()), + ("download.bin", DOWNLOAD), + ] { + std::fs::write(root.join(path), bytes).unwrap(); + } +} + +fn assert_content_policy(response: &Response, path: &str) { + assert_eq!( + response + .headers() + .get("content-security-policy") + .and_then(|v| v.to_str().ok()), + Some(CONTENT_POLICY), + "content boundary at {path}" + ); +} + +#[tokio::test] +async fn content_sandbox_cid_aliases_preserve_bytes_and_types() { + let dir = tempfile::tempdir().unwrap(); + write_content_fixture(&dir.path().join(TEST_CIDV1)); + std::fs::write(dir.path().join(format!("{TEST_CIDV0}.raw")), DOWNLOAD).unwrap(); + let app = gateway_router(test_state(dir.path())); + for prefix in [format!("/s/{TEST_CIDV1}"), format!("/ipfs/{TEST_CIDV1}")] { + for (suffix, bytes, mime) in [ + ("/", PAGE.as_bytes(), "text/html; charset=utf-8"), + ("/index.html", PAGE.as_bytes(), "text/html; charset=utf-8"), + ( + "/directory/index.html", + PAGE.as_bytes(), + "text/html; charset=utf-8", + ), + ("/probe.js", PROBE.as_bytes(), "application/javascript"), + ("/style.css", STYLE.as_bytes(), "text/css"), + ("/image.svg", SVG.as_bytes(), "image/svg+xml"), + ("/download.bin", DOWNLOAD, "application/octet-stream"), + ] { + let path = format!("{prefix}{suffix}"); + let response = app + .clone() + .oneshot(Request::builder().uri(&path).body(Body::empty()).unwrap()) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::OK, "{path}"); + assert_content_policy(&response, &path); + assert_eq!(response.headers().get(CONTENT_TYPE).unwrap(), mime); + assert_eq!( + &axum::body::to_bytes(response.into_body(), usize::MAX) + .await + .unwrap()[..], + bytes, + "{path}" + ); + } + } + for path in [ + format!("/content/{TEST_CIDV1}"), + format!("/ipfs/{TEST_CIDV1}"), + ] { + let response = app + .clone() + .oneshot(Request::builder().uri(&path).body(Body::empty()).unwrap()) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::OK); + assert_content_policy(&response, &path); + assert_eq!( + &axum::body::to_bytes(response.into_body(), usize::MAX) + .await + .unwrap()[..], + PAGE.as_bytes() + ); + } + for path in [ + format!("/content/{TEST_CIDV0}"), + format!("/ipfs/{TEST_CIDV0}"), + ] { + let response = app + .clone() + .oneshot(Request::builder().uri(&path).body(Body::empty()).unwrap()) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::OK); + assert_content_policy(&response, &path); + assert_eq!( + response.headers().get(CONTENT_TYPE).unwrap(), + "application/octet-stream" + ); + assert_eq!( + &axum::body::to_bytes(response.into_body(), usize::MAX) + .await + .unwrap()[..], + DOWNLOAD + ); + } + for (path, status) in [ + (format!("/s/{TEST_CIDV1}"), StatusCode::PERMANENT_REDIRECT), + ("/s/invalid/".to_string(), StatusCode::BAD_REQUEST), + ("/ipfs/invalid".to_string(), StatusCode::BAD_REQUEST), + ("/content/invalid".to_string(), StatusCode::BAD_REQUEST), + ( + format!("/s/{TEST_CIDV1}/missing.html"), + StatusCode::NOT_FOUND, + ), + (format!("/s/{TEST_CIDV1}/directory/"), StatusCode::NOT_FOUND), + ( + format!("/ipfs/{TEST_CIDV1}/../escape"), + StatusCode::BAD_REQUEST, + ), + (format!("/s/{TEST_CIDV0}/"), StatusCode::NOT_FOUND), + ] { + let response = app + .clone() + .oneshot(Request::builder().uri(&path).body(Body::empty()).unwrap()) + .await + .unwrap(); + assert_eq!(response.status(), status, "{path}"); + assert_content_policy(&response, &path); + } + for (path, mime, cached_path) in [ + ( + format!("/s/{TEST_CIDV1}/index.html"), + "text/html; charset=utf-8", + std::path::PathBuf::from(TEST_CIDV1).join("index.html"), + ), + ( + format!("/content/{TEST_CIDV1}"), + "application/octet-stream", + std::path::PathBuf::from(format!("{TEST_CIDV1}.raw")), + ), + ] { + let fetched_dir = tempfile::tempdir().unwrap(); + let fetched = gateway_router(content_test_state(fetched_dir.path()).await) + .oneshot(Request::builder().uri(&path).body(Body::empty()).unwrap()) + .await + .unwrap(); + assert_eq!(fetched.status(), StatusCode::OK); + assert_content_policy(&fetched, &path); + assert_eq!(fetched.headers().get(CONTENT_TYPE).unwrap(), mime); + let bytes = axum::body::to_bytes(fetched.into_body(), usize::MAX) + .await + .unwrap(); + assert!(!bytes.is_empty()); + assert_eq!( + &bytes[..], + std::fs::read(fetched_dir.path().join(cached_path)).unwrap() + ); + } +} + +#[tokio::test] +async fn content_sandbox_site_files_and_admitted_bundle_preserve_provenance() { + let dir = tempfile::tempdir().unwrap(); + write_content_fixture(&my_website_root_path(dir.path())); + write_content_fixture(&dir.path().join(TEST_CIDV1)); + let app = gateway_router(test_state(dir.path())); + for bundle in [false, true] { + if bundle { + let head_path = edge_site_head_path(dir.path(), MY_WEBSITE_URI); + std::fs::create_dir_all(head_path.parent().unwrap()).unwrap(); + std::fs::write(head_path, serde_json::to_vec(&json!({ + "payload": {"schema":"elastos.site.head.v1", "target": MY_WEBSITE_URI, + "bundle_cid": TEST_CIDV1, "release_name":"fixture", "channel_name":"test", + "content_digest":"sha256:fixture", "entry_count":6, "total_bytes":100, "activated_at":1}, + "signature":"fixture", "signer_did":"did:key:fixture" + })).unwrap()).unwrap(); + } + for (path, bytes, mime) in [ + ("/", PAGE.as_bytes(), "text/html; charset=utf-8"), + ("/directory", PAGE.as_bytes(), "text/html; charset=utf-8"), + ("/probe.js", PROBE.as_bytes(), "application/javascript"), + ("/style.css", STYLE.as_bytes(), "text/css"), + ("/image.svg", SVG.as_bytes(), "image/svg+xml"), + ("/download.bin", DOWNLOAD, "application/octet-stream"), + ] { + let response = app + .clone() + .oneshot(Request::builder().uri(path).body(Body::empty()).unwrap()) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::OK, "{path}"); + assert_content_policy(&response, path); + assert_eq!(response.headers().get(CONTENT_TYPE).unwrap(), mime); + assert_eq!( + response.headers().get("x-elastos-site-origin").unwrap(), + MY_WEBSITE_URI + ); + if bundle { + for (header, value) in [ + ("x-elastos-site-head-schema", "elastos.site.head.v1"), + ("x-elastos-site-head-digest", "sha256:fixture"), + ("x-elastos-site-head-cid", TEST_CIDV1), + ("x-elastos-site-head-signer", "did:key:fixture"), + ("x-elastos-site-head-release", "fixture"), + ("x-elastos-site-head-channel", "test"), + ] { + assert_eq!(response.headers().get(header).unwrap(), value); + } + } + assert_eq!( + &axum::body::to_bytes(response.into_body(), usize::MAX) + .await + .unwrap()[..], + bytes + ); + } + let missing = app + .clone() + .oneshot( + Request::builder() + .uri("/missing.html") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(missing.status(), StatusCode::NOT_FOUND); + assert_content_policy(&missing, "/missing.html"); + } + let home = app + .clone() + .oneshot( + Request::builder() + .uri("/home/") + .header(HOST, "localhost:61180") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(home.status(), StatusCode::OK); + assert_eq!(home.headers().get("content-security-policy").unwrap(), + "default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' blob: data:; connect-src 'self'; frame-src 'self'; object-src 'none'; base-uri 'none'; form-action 'self'; frame-ancestors 'none'"); + let health = app + .oneshot( + Request::builder() + .uri("/healthz") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(health.status(), StatusCode::OK); + assert!(health.headers().get("content-security-policy").is_none()); +} + +#[tokio::test] +async fn content_sandbox_opaque_cookie_requests_require_explicit_launch_authority() { + let dir = tempfile::tempdir().unwrap(); + let app = gateway_router(test_state(dir.path())); + let bootstrap = app + .clone() + .oneshot( + test_browser_request("localhost:61180", "http://localhost:61180") + .uri("/api/apps/home/summary") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!( + bootstrap.status(), + StatusCode::OK, + "same-origin Home bootstrap remains public" + ); + for role in [ + crate::auth::RuntimePrincipalRole::Admin, + crate::auth::RuntimePrincipalRole::Guest, + ] { + let authority = passkey_authority_with_name_role(dir.path(), Some("sandbox-test"), role); + let headers = test_browser_request("localhost:61180", "null") + .body(Body::empty()) + .unwrap() + .into_parts() + .0 + .headers; + let cookie = format!( + "{}={}", + home_session_cookie_name(&headers).unwrap(), + authority.home_token + ); + let assistant_token = app_token_for_authority(dir.path(), "assistant", &authority); + let before = app + .clone() + .oneshot( + test_browser_request("localhost:61180", "null") + .uri("/api/apps/assistant/workspace") + .header("x-elastos-home-token", &assistant_token) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!( + before.status(), + StatusCode::OK, + "authorized opaque app fetch remains available" + ); + let before = axum::body::to_bytes(before.into_body(), usize::MAX) + .await + .unwrap(); + for (path, method, body) in [ + ("/api/apps/home/state", "GET", ""), + ("/api/apps/home/state", "POST", "{}"), + ("/api/apps/assistant/workspace", "GET", ""), + ( + "/api/apps/assistant/workspace", + "PUT", + r#"{"schema":"elastos.assistant.workspace/v1","if_revision":0,"draft":"attack"}"#, + ), + ] { + let response = app + .clone() + .oneshot( + test_browser_request("localhost:61180", "null") + .method(method) + .uri(path) + .header(COOKIE, &cookie) + .header(CONTENT_TYPE, "application/json") + .body(Body::from(body)) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!( + response.status(), + StatusCode::FORBIDDEN, + "cookie-only {method} {path}" + ); + } + for metadata in [ + [("origin", "null"), ("sec-fetch-mode", "navigate")], + [ + ("sec-fetch-site", "cross-site"), + ("sec-fetch-dest", "document"), + ], + [ + ("sec-fetch-site", "cross-site"), + ("sec-fetch-dest", "iframe"), + ], + ] { + let mut request = Request::builder() + .uri("/api/apps/home/summary") + .header(HOST, "localhost:61180") + .header(COOKIE, &cookie); + for (name, value) in metadata { + request = request.header(name, value); + } + let response = app + .clone() + .oneshot(request.body(Body::empty()).unwrap()) + .await + .unwrap(); + assert_eq!( + response.status(), + StatusCode::FORBIDDEN, + "opaque content navigation keeps Home authority private" + ); + } + let after = app + .clone() + .oneshot( + test_browser_request("localhost:61180", "null") + .uri("/api/apps/assistant/workspace") + .header("x-elastos-home-token", &assistant_token) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(after.status(), StatusCode::OK); + assert_eq!( + axum::body::to_bytes(after.into_body(), usize::MAX) + .await + .unwrap(), + before, + "refused content requests leave the app workspace unchanged" + ); + } +} + +fn copy_operator_site(source: &std::path::Path, destination: &std::path::Path) { + std::fs::create_dir_all(destination).unwrap(); + for entry in std::fs::read_dir(source).unwrap() { + let entry = entry.unwrap(); + if entry.file_type().unwrap().is_dir() { + copy_operator_site(&entry.path(), &destination.join(entry.file_name())); + } else { + std::fs::copy(entry.path(), destination.join(entry.file_name())).unwrap(); + } + } +} + +#[tokio::test] +#[ignore = "requires Playwright and Chromium; CI runs this browser boundary regression"] +async fn content_sandbox_browser() { + let dir = tempfile::tempdir().unwrap(); + write_content_fixture(&dir.path().join(TEST_CIDV1)); + let site_root = my_website_root_path(dir.path()); + write_content_fixture(&site_root); + let repo = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../.."); + copy_operator_site(&repo.join("website/elastos"), &site_root.join("operator")); + let app = gateway_router(test_state(dir.path())); + let authority = passkey_authority_with_name(dir.path(), Some("browser-sandbox-fixture")); + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + let mut headers = HeaderMap::new(); + headers.insert(HOST, addr.to_string().parse().unwrap()); + let cookie_name = home_session_cookie_name(&headers).unwrap(); + let (shutdown_tx, shutdown_rx) = tokio::sync::oneshot::channel::<()>(); + let mut server = tokio::spawn(async move { + axum::serve(listener, app) + .with_graceful_shutdown(async { + let _ = shutdown_rx.await; + }) + .await + }); + let mut command = tokio::process::Command::new("node"); + command + .arg(repo.join("scripts/gateway-content-sandbox-browser-smoke.mjs")) + .env("ELASTOS_CONTENT_SANDBOX_URL", format!("http://{addr}")) + .env("ELASTOS_CONTENT_SANDBOX_CID", TEST_CIDV1) + .env("ELASTOS_CONTENT_SANDBOX_COOKIE_NAME", cookie_name) + .env("ELASTOS_CONTENT_SANDBOX_COOKIE_VALUE", authority.home_token) + .kill_on_drop(true); + let result = tokio::time::timeout(std::time::Duration::from_secs(90), command.output()).await; + let _ = shutdown_tx.send(()); + if tokio::time::timeout(std::time::Duration::from_secs(5), &mut server) + .await + .is_err() + { + server.abort(); + let _ = server.await; + panic!("content sandbox gateway did not stop"); + } + let output = result + .expect("content sandbox browser timeout") + .expect("start Node browser smoke"); + assert!( + output.status.success(), + "browser boundary failed:\n{}\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); +} diff --git a/elastos/crates/elastos-server/src/api/gateway_tests/mod.rs b/elastos/crates/elastos-server/src/api/gateway_tests/mod.rs index 01c9b78b0..635dcc2b9 100644 --- a/elastos/crates/elastos-server/src/api/gateway_tests/mod.rs +++ b/elastos/crates/elastos-server/src/api/gateway_tests/mod.rs @@ -860,6 +860,7 @@ mod assistant_workspace_v2; mod browser_profile; mod browser_reconciliation; mod collaboration_presence; +mod content_sandbox; mod documents; mod esp; #[path = "../gateway_browser_route_tests.rs"] diff --git a/scripts/gateway-content-sandbox-browser-smoke.mjs b/scripts/gateway-content-sandbox-browser-smoke.mjs new file mode 100644 index 000000000..7428eeeab --- /dev/null +++ b/scripts/gateway-content-sandbox-browser-smoke.mjs @@ -0,0 +1,171 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import { pathToFileURL } from 'node:url'; + +const { chromium } = await import(process.env.ELASTOS_PLAYWRIGHT_MODULE + ? pathToFileURL(process.env.ELASTOS_PLAYWRIGHT_MODULE).href : 'playwright'); +const origin = process.env.ELASTOS_CONTENT_SANDBOX_URL; +const cid = process.env.ELASTOS_CONTENT_SANDBOX_CID; +assert.ok(origin && cid, 'the Rust fixture supplies its loopback gateway and CID'); +const expectedPolicy = "sandbox allow-scripts allow-forms allow-popups; connect-src 'none'"; +let browser; +const deadline = setTimeout(() => { + console.error('Content sandbox browser deadline exceeded'); + if (browser) void browser.close(); +}, 75_000); +try { + browser = await chromium.launch({ + headless: true, + ...(process.env.ELASTOS_CHROMIUM_EXECUTABLE + ? { executablePath: process.env.ELASTOS_CHROMIUM_EXECUTABLE } : {}), + }); + const context = await browser.newContext(); + context.setDefaultTimeout(10_000); + context.setDefaultNavigationTimeout(10_000); + // This signed test cookie stays in the trusted browser context. The hostile + // document receives neither the value nor a launch token in its HTML or URL. + await context.addCookies([{ + name: process.env.ELASTOS_CONTENT_SANDBOX_COOKIE_NAME, + value: process.env.ELASTOS_CONTENT_SANDBOX_COOKIE_VALUE, + url: origin, + httpOnly: false, + sameSite: 'Lax', + }]); + const page = await context.newPage(); + const apiResponses = []; + context.on('response', response => { + if (new URL(response.url()).pathname.startsWith('/api/')) { + apiResponses.push({ url: response.url(), status: response.status() }); + } + }); + const paths = [`/s/${cid}/`, `/ipfs/${cid}/`, '/']; + for (const path of paths) { + const responsesBeforeContent = apiResponses.length; + await page.goto(`${origin}/home/`); + await page.evaluate(() => { + localStorage.setItem('runtime-sandbox-sentinel', 'trusted-home-storage'); + sessionStorage.setItem('runtime-sandbox-sentinel', 'trusted-home-session'); + }); + assert.ok(await page.evaluate(() => document.cookie.length > 0), 'trusted Home sees the test cookie'); + + // A trusted gateway document shares Home's origin and storage. Use the + // health document as parent because Home's COEP rejects untrusted frames. + await page.goto(`${origin}/healthz`); + // The content's own script must fail to read the trusted parent document. + await page.evaluate(path => { + const frame = document.createElement('iframe'); + frame.id = 'content-fixture'; + frame.src = path; + document.body.append(frame); + }, path); + const handle = await page.waitForSelector('#content-fixture'); + const frame = await handle.contentFrame(); + assert.ok(frame, 'content frame exists'); + await frame.waitForFunction(() => window.probe?.ready); + const framedProbe = await frame.evaluate(() => window.probe); + assert.equal(framedProbe.parentDocument.denied, true, `${path}: parent DOM isolation`); + assert.equal(framedProbe.parentStorage.denied, true, `${path}: parent storage isolation`); + + // A direct navigation also keeps the user content in an opaque origin. + const response = await page.goto(`${origin}${path}`); + assert.equal(response.status(), 200); + assert.equal(response.headers()['content-security-policy'], expectedPolicy); + await page.waitForFunction(() => window.probe?.ready); + const probe = await page.evaluate(() => window.probe); + assert.equal(probe.script, true, `${path}: ordinary scripts run`); + for (const key of ['cookie', 'localStorage', 'sessionStorage']) { + assert.equal(probe[key].denied, true, `${path}: ${key} isolation`); + } + assert.equal(probe.fetches.length, 5); + assert.ok(probe.fetches.every(request => request.blocked), `${path}: Home/app fetches blocked`); + await page.waitForFunction(() => window.probe.violations.length >= 5); + assert.ok((await page.evaluate(() => window.probe.violations)).every(value => value === 'connect-src')); + assert.equal(await page.locator('#result').evaluate(element => getComputedStyle(element).color), 'rgb(12, 34, 56)'); + await page.waitForFunction(() => document.querySelector('#image').naturalWidth === 8); + await page.locator('input').fill('Ordinary form UI works'); + await page.locator('button').click(); + assert.equal(await page.locator('#result').textContent(), 'Ordinary form UI works'); + assert.equal(await page.locator('#download').getAttribute('download'), ''); + assert.equal(apiResponses.length, responsesBeforeContent, `${path}: CSP fetch probes received no API response`); + + // Forms and document navigation have separate browser channels from fetch. + // Exercise each channel against the public summary through the real router. + for (const channel of ['form', 'frame', 'popup']) { + const target = `${origin}/api/apps/home/summary?sandbox_probe=${channel}`; + const deniedResponse = context.waitForEvent('response', { + predicate: response => response.url() === target, + }); + if (channel === 'form') { + await page.evaluate(target => { + const receiver = document.createElement('iframe'); + receiver.name = 'form-api-receiver'; + document.body.append(receiver); + const form = document.createElement('form'); + form.action = target; + form.method = 'GET'; + form.target = receiver.name; + const marker = document.createElement('input'); + marker.name = 'sandbox_probe'; + marker.value = 'form'; + form.append(marker); + document.body.append(form); + form.submit(); + }, target); + } else if (channel === 'frame') { + await page.evaluate(target => { + const receiver = document.createElement('iframe'); + receiver.src = target; + document.body.append(receiver); + }, target); + } else { + const popupPromise = page.waitForEvent('popup'); + await page.evaluate(target => { + const link = document.createElement('a'); + link.id = 'api-popup-probe'; + link.href = target; + link.target = '_blank'; + link.textContent = 'Open API probe'; + document.body.append(link); + }, target); + await page.locator('#api-popup-probe').click(); + const popup = await popupPromise; + try { + const response = await deniedResponse; + assert.equal(response.status(), 403, `${path}: ${channel} authority refused`); + } finally { + await popup.close(); + } + continue; + } + assert.equal((await deniedResponse).status(), 403, `${path}: ${channel} authority refused`); + } + assert.equal(apiResponses.length, responsesBeforeContent + 3, `${path}: all navigation channels reached the authority gate`); + assert.ok(apiResponses.slice(responsesBeforeContent).every(response => response.status === 403)); + } + + // Exercise the actual operator site assets under the same response policy. + const operatorResponse = await page.goto(`${origin}/operator/`); + assert.equal(operatorResponse.headers()['content-security-policy'], expectedPolicy); + await page.locator('#mac-arm').click(); + assert.equal(await page.locator('#mac-arm').getAttribute('aria-selected'), 'true'); + assert.equal(await page.locator('#install-panel').getAttribute('aria-labelledby'), 'mac-arm'); + assert.ok((await page.locator('#install-guide').getAttribute('href')).endsWith('/docs/MAC.md')); + await page.locator('#mac-arm').press('ArrowLeft'); + assert.equal(await page.locator('#linux-arm').getAttribute('aria-selected'), 'true'); + assert.ok(await page.locator('.hero-mark').evaluate(image => image.complete && image.naturalWidth > 0)); + assert.notEqual(await page.locator('html').evaluate(element => getComputedStyle(element).backgroundColor), 'rgba(0, 0, 0, 0)'); + // The production installer control is release-gated. Enable it only in this + // browser fixture to test the existing selection recovery in the opaque page. + await page.locator('#copy-install').evaluate(button => { + button.disabled = false; + document.querySelector('#install-command').closest('pre').hidden = false; + }); + await page.locator('#copy-install').click(); + await page.waitForFunction(() => document.querySelector('#copy-status').textContent.includes('Command selected.')); + assert.equal(await page.evaluate(() => window.getSelection().toString()), + await page.locator('#install-command').textContent()); + console.log('PASS: real gateway CID/site sandbox, Home/app isolation, content UI, and operator site recovery'); +} finally { + clearTimeout(deadline); + if (browser) await browser.close(); +} From acfe57f14b0c6b066d556d6bbde628e907de39dc Mon Sep 17 00:00:00 2001 From: Anders Alm Date: Thu, 1 Oct 2026 17:01:14 -0500 Subject: [PATCH 2/2] fix(ci): use Playwright extraction fix on Node 26 --- .github/workflows/ci.yml | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8d3692f74..be20b23c3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -145,9 +145,13 @@ jobs: with: node-version: '26' - name: install content boundary browser fixture + timeout-minutes: 5 + env: + DEBUG: pw:install run: | - npm install --prefix "${RUNNER_TEMP}/content-sandbox-browser" --no-save --package-lock=false playwright@1.59.1 - "${RUNNER_TEMP}/content-sandbox-browser/node_modules/.bin/playwright" install chromium --with-deps + # 1.60 fixes ZIP extraction hanging on Node 26 (Playwright #40747). + npm install --prefix "${RUNNER_TEMP}/content-sandbox-browser" --no-save --package-lock=false playwright@1.60.0 + "${RUNNER_TEMP}/content-sandbox-browser/node_modules/.bin/playwright" install chromium --with-deps --only-shell - name: gateway content sandbox browser regression working-directory: elastos env: