Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
25 changes: 15 additions & 10 deletions elastos/crates/elastos-server/src/api/gateway.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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))
}

Expand Down
43 changes: 43 additions & 0 deletions elastos/crates/elastos-server/src/api/gateway_site.rs
Original file line number Diff line number Diff line change
@@ -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,
Expand Down
Loading
Loading