diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index e6f5447f9..be20b23c3 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -141,6 +141,22 @@ 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
+ timeout-minutes: 5
+ env:
+ DEBUG: pw:install
+ run: |
+ # 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:
+ 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();
+}