From 01b3b8b7d2f080df5170c9d1127820c1a1039601 Mon Sep 17 00:00:00 2001 From: echobt <154886644+echobt@users.noreply.github.com> Date: Fri, 7 Aug 2026 11:33:47 +0000 Subject: [PATCH 1/2] fix(design): allow cross-origin PNG screenshots from chain Public gallery tags should hit chain.joinbase.ai directly so Vercel does not proxy large screenshot bytes. Set CORP cross-origin on PNG view responses while keeping the HTML lockdown floor for non-PNG paths. --- crates/design-http/src/api.rs | 22 +++++--- crates/design-sanitize/src/lib.rs | 36 +++++++++++- crates/gateway/src/proxy.rs | 62 +++++++++++++++++---- crates/gateway/tests/proxy_view_lockdown.rs | 51 +++++++++++++++++ docs/DESIGN_CHALLENGE.md | 54 +++++++++--------- docs/SITE_API.md | 9 ++- 6 files changed, 186 insertions(+), 48 deletions(-) diff --git a/crates/design-http/src/api.rs b/crates/design-http/src/api.rs index bf4f6e8e4..4ad24a150 100644 --- a/crates/design-http/src/api.rs +++ b/crates/design-http/src/api.rs @@ -821,14 +821,14 @@ async fn view_page( header::CONTENT_TYPE, header::HeaderValue::from_static("image/png"), ); - headers.insert( - header::CACHE_CONTROL, - header::HeaderValue::from_static("private, no-store"), - ); - headers.insert( - header::HeaderName::from_static("x-content-type-options"), - header::HeaderValue::from_static("nosniff"), - ); + for (k, v) in design_sanitize::screenshot_headers() { + if let (Ok(name), Ok(val)) = ( + header::HeaderName::try_from(k), + header::HeaderValue::try_from(v), + ) { + headers.insert(name, val); + } + } (StatusCode::OK, headers, bytes).into_response() } Ok(None) => json_err(StatusCode::NOT_FOUND, "not_found", "page"), @@ -1468,6 +1468,12 @@ mod tests { .and_then(|v| v.to_str().ok()), Some("nosniff") ); + assert_eq!( + res.headers() + .get("cross-origin-resource-policy") + .and_then(|v| v.to_str().ok()), + Some("cross-origin") + ); let bytes = res.into_body().collect().await.unwrap().to_bytes(); assert_eq!(bytes.as_ref(), &png_bytes); // Unknown png → 404. diff --git a/crates/design-sanitize/src/lib.rs b/crates/design-sanitize/src/lib.rs index b083f05f6..030ae0672 100644 --- a/crates/design-sanitize/src/lib.rs +++ b/crates/design-sanitize/src/lib.rs @@ -320,12 +320,17 @@ pub fn default_frame_ancestors() -> &'static str { "'self' https://joinbase.ai https://*.vercel.app http://localhost:*" } -/// Viewer response headers (CSP sandbox is the key guarantee). +/// Viewer response headers for **non-PNG** `/v1/view/*` responses (CSP sandbox +/// is the key guarantee against a stale upstream that still served miner HTML). /// /// The `sandbox` directive is emitted **without** `allow-scripts` and without /// `allow-same-origin`: the document runs in an opaque origin with script /// execution disabled, so miner HTML can never touch the serving origin's /// cookies, storage, or DOM — even when embedded same-origin through a proxy. +/// +/// Public screenshots use [`screenshot_headers`] instead (`CORP: cross-origin`) +/// so joinbase.ai can `` +/// without proxying PNG bytes through Vercel. #[must_use] pub fn viewer_headers(frame_ancestors: &str) -> Vec<(&'static str, String)> { let csp = format!( @@ -336,8 +341,8 @@ pub fn viewer_headers(frame_ancestors: &str) -> Vec<(&'static str, String)> { ("Content-Security-Policy", csp), ("X-Content-Type-Options", "nosniff".into()), ("Referrer-Policy", "no-referrer".into()), - // Viewer responses are only ever embedded same-origin (site proxies - // the gateway under its own origin); cross-origin embedders get nothing. + // Non-PNG view responses stay same-origin only (defense in depth if + // HTML ever leaks through); PNGs use `screenshot_headers`. ("Cross-Origin-Resource-Policy", "same-origin".into()), ("Cross-Origin-Opener-Policy", "same-origin".into()), ( @@ -350,6 +355,21 @@ pub fn viewer_headers(frame_ancestors: &str) -> Vec<(&'static str, String)> { ] } +/// Headers for public PNG screenshots (`index.png`). +/// +/// `Cross-Origin-Resource-Policy: cross-origin` lets marketing/admin UIs load +/// the image with a direct absolute URL to the gateway (no same-origin proxy +/// required). PNGs are not executable documents; cookies are never set. +#[must_use] +pub fn screenshot_headers() -> Vec<(&'static str, String)> { + vec![ + ("X-Content-Type-Options", "nosniff".into()), + ("Referrer-Policy", "no-referrer".into()), + ("Cross-Origin-Resource-Policy", "cross-origin".into()), + ("Cache-Control", "private, no-store".into()), + ] +} + #[cfg(test)] mod tests { #![allow(clippy::unwrap_used)] @@ -427,6 +447,16 @@ mod tests { assert!(get("Set-Cookie").is_none()); } + #[test] + fn screenshot_headers_allow_cross_origin_img() { + let h = screenshot_headers(); + let get = |name: &str| h.iter().find(|(k, _)| *k == name).map(|(_, v)| v.as_str()); + assert_eq!(get("Cross-Origin-Resource-Policy"), Some("cross-origin")); + assert_eq!(get("X-Content-Type-Options"), Some("nosniff")); + assert!(get("Content-Security-Policy").is_none()); + assert!(get("Cross-Origin-Opener-Policy").is_none()); + } + #[test] fn default_frame_ancestors_allowlist() { let fa = default_frame_ancestors(); diff --git a/crates/gateway/src/proxy.rs b/crates/gateway/src/proxy.rs index ece17eeec..ae9e9240b 100644 --- a/crates/gateway/src/proxy.rs +++ b/crates/gateway/src/proxy.rs @@ -117,7 +117,7 @@ async fn proxy_inner( } st.registry.record_success(backend.id); if is_view_path(&rest) { - apply_view_lockdown(&mut upstream_resp, &st.view_frame_ancestors); + apply_view_lockdown(&mut upstream_resp, &st.view_frame_ancestors, &rest); } return upstream_resp; } @@ -226,21 +226,37 @@ pub fn is_admin_path(rest: &str) -> bool { rest_norm.starts_with("v1/admin/") || rest_norm == "v1/admin" } -/// Miner-controlled HTML viewer paths (`/challenge/{id}/v1/view/{run}/{page}`). +/// Miner-controlled viewer paths (`/challenge/{id}/v1/view/{run}/{page}`). #[must_use] pub fn is_view_path(rest: &str) -> bool { rest.trim_start_matches('/').starts_with("v1/view/") } -/// Re-apply the viewer lockdown header floor at the last serving layer -/// (defense in depth): even a stale or misbehaving challenge upstream cannot -/// serve miner HTML through the gateway without the CSP `sandbox` (opaque -/// origin, no scripts), and `Set-Cookie` is stripped so these public -/// capability-URL responses never touch origin cookies. -fn apply_view_lockdown(resp: &mut Response, frame_ancestors: &str) { +/// Captured PNG screenshot under `/v1/view/{run}/{page}.png`. +#[must_use] +pub fn is_view_png_path(rest: &str) -> bool { + is_view_path(rest) && rest.trim_start_matches('/').ends_with(".png") +} + +/// Re-apply the viewer header floor at the last serving layer (defense in +/// depth). Non-PNG paths get the full HTML lockdown (CSP `sandbox`, CORP +/// same-origin). PNG screenshots get [`design_sanitize::screenshot_headers`] +/// (`CORP: cross-origin`) so joinbase.ai can load them with a direct absolute +/// URL and avoid proxying image bytes through Vercel. `Set-Cookie` is always +/// stripped. +fn apply_view_lockdown(resp: &mut Response, frame_ancestors: &str, rest: &str) { let headers = resp.headers_mut(); headers.remove(header::SET_COOKIE); - for (k, v) in design_sanitize::viewer_headers(frame_ancestors) { + let floor = if is_view_png_path(rest) { + // Drop HTML-only lockdown if a stale hop set them on a PNG response. + headers.remove(header::CONTENT_SECURITY_POLICY); + headers.remove(HeaderName::from_static("cross-origin-opener-policy")); + headers.remove(HeaderName::from_static("permissions-policy")); + design_sanitize::screenshot_headers() + } else { + design_sanitize::viewer_headers(frame_ancestors) + }; + for (k, v) in floor { if let (Ok(name), Ok(val)) = (HeaderName::try_from(k), HeaderValue::try_from(v.as_str())) { headers.insert(name, val); } @@ -280,6 +296,8 @@ mod tests { assert!(!is_view_path("v1/runs/abc")); assert!(!is_view_path("v1/viewx/abc")); assert!(!is_view_path("v1/admin/view")); + assert!(is_view_png_path("v1/view/abc/index.png")); + assert!(!is_view_png_path("v1/view/abc/index.html")); } #[test] @@ -291,7 +309,7 @@ mod tests { header::CONTENT_SECURITY_POLICY, HeaderValue::from_static("default-src *"), ); - apply_view_lockdown(&mut resp, "'none'"); + apply_view_lockdown(&mut resp, "'none'", "v1/view/abc/index.html"); let h = resp.headers(); assert!(h.get(header::SET_COOKIE).is_none()); let csp = h @@ -308,4 +326,28 @@ mod tests { Some("nosniff") ); } + + #[test] + fn png_view_lockdown_allows_cross_origin_img() { + let mut resp = Response::new(Body::from(vec![0x89_u8, 0x50, 0x4e, 0x47])); + let h = resp.headers_mut(); + h.insert(header::SET_COOKIE, HeaderValue::from_static("session=evil")); + h.insert( + header::CONTENT_SECURITY_POLICY, + HeaderValue::from_static("sandbox; default-src 'none'"), + ); + h.insert( + HeaderName::from_static("cross-origin-resource-policy"), + HeaderValue::from_static("same-origin"), + ); + apply_view_lockdown(&mut resp, "'none'", "v1/view/abc/index.png"); + let h = resp.headers(); + assert!(h.get(header::SET_COOKIE).is_none()); + assert!(h.get(header::CONTENT_SECURITY_POLICY).is_none()); + assert_eq!( + h.get("cross-origin-resource-policy") + .and_then(|v| v.to_str().ok()), + Some("cross-origin") + ); + } } diff --git a/crates/gateway/tests/proxy_view_lockdown.rs b/crates/gateway/tests/proxy_view_lockdown.rs index daa275025..2f92dbf67 100644 --- a/crates/gateway/tests/proxy_view_lockdown.rs +++ b/crates/gateway/tests/proxy_view_lockdown.rs @@ -152,3 +152,54 @@ async fn view_response_leaves_gateway_sandboxed_without_cookies() { let _ = shutdown.send(()); } + +#[tokio::test] +async fn png_view_allows_cross_origin_resource_policy() { + let upstream = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/v1/view/run1/index.png")) + .respond_with( + ResponseTemplate::new(200) + .insert_header("content-type", "image/png") + // Stale lockdown from an older gateway hop must not stick. + .insert_header("cross-origin-resource-policy", "same-origin") + .insert_header("content-security-policy", "sandbox; default-src 'none'") + .insert_header("set-cookie", "session=evil; Path=/") + .set_body_bytes(vec![0x89, 0x50, 0x4e, 0x47]), + ) + .mount(&upstream) + .await; + + let reg = Registry::shared(RegistryConfig { + failure_threshold: 2, + cooldown: Duration::from_millis(120), + }); + reg.create(&CreateBackend { + challenge_id: "design".into(), + base_url: upstream.uri(), + weight: 1, + }) + .unwrap(); + + let (addr, shutdown) = spawn_gateway(reg).await; + let client = reqwest::Client::new(); + let resp = client + .get(format!( + "http://{addr}/challenge/design/v1/view/run1/index.png" + )) + .send() + .await + .expect("proxy png"); + assert_eq!(resp.status().as_u16(), 200); + let headers = resp.headers(); + assert!(headers.get("set-cookie").is_none()); + assert!(headers.get("content-security-policy").is_none()); + assert_eq!( + headers + .get("cross-origin-resource-policy") + .and_then(|v| v.to_str().ok()), + Some("cross-origin") + ); + + let _ = shutdown.send(()); +} diff --git a/docs/DESIGN_CHALLENGE.md b/docs/DESIGN_CHALLENGE.md index 9b6d16e73..8565e7af3 100644 --- a/docs/DESIGN_CHALLENGE.md +++ b/docs/DESIGN_CHALLENGE.md @@ -224,8 +224,23 @@ error, and `GET /v1/runs/{id}/bundle.json` no longer embeds page HTML (same `410 Gone` contract — use `/v1/runs/{id}/pages` for page metadata). Miner output reaches browsers exclusively as the captured `index.png` screenshot. -The full lockdown header set remains as the **gateway-enforced floor** on -every `/challenge/{id}/v1/view/*` response (defense in depth — below): +**PNG screenshots** (`*.png`) leave the gateway with a light header floor so +marketing UIs can load them with a **direct absolute URL** (no Vercel proxy of +image bytes): + +``` +X-Content-Type-Options: nosniff +Referrer-Policy: no-referrer +Cross-Origin-Resource-Policy: cross-origin +Cache-Control: private, no-store +``` + +Example: `https://chain.joinbase.ai/challenge/design/v1/view/{run_id}/index.png`. +JSON/site API calls may still use the site's `/gbase-api` rewrite; `` +for screenshots should not. + +**Non-PNG** `/challenge/{id}/v1/view/*` responses (e.g. HTML `410 Gone`, or a +stale upstream that still served miner HTML) keep the full lockdown floor: ``` Content-Security-Policy: sandbox; default-src 'none'; img-src data: https:; style-src 'unsafe-inline' https:; font-src data: https:; base-uri 'none'; form-action 'none'; frame-ancestors @@ -237,33 +252,19 @@ Permissions-Policy: accelerometer=(), camera=(), geolocation=(), gyroscope=(), m Cache-Control: private, no-store ``` -The `sandbox` directive is emitted **without** `allow-scripts` and without +The `sandbox` directive is emitted without `allow-scripts` and without `allow-same-origin`: even if a stale or misbehaving challenge upstream served miner HTML through the gateway, it would run in an **opaque origin with script -execution disabled**, unable to read the serving origin's cookies, -storage, or DOM — even though joinbase.ai embeds view responses -**same-origin** through the `/gbase-api` proxy. These responses **never carry -`Set-Cookie`** (the gateway strips it); the endpoint stays public (`run_id` is -the capability) and reads no auth cookie. +execution disabled**, unable to read the serving origin's cookies, storage, or +DOM. All view responses **never carry `Set-Cookie`** (the gateway strips it); +the endpoint stays public (`run_id` is the capability) and reads no auth cookie. `frame-ancestors ` defaults to `'self' https://joinbase.ai https://*.vercel.app http://localhost:*` -(`DESIGN_FRAME_ANCESTORS` override): the public site, Vercel preview deploys, -and local dev may embed the viewer; everyone else is refused. `'self'` covers -same-origin proxy embedding at any host (including staging consoles). - -**Defense in depth — gateway re-injection.** The gateway proxy re-applies the -full lockdown header set (and strips any `Set-Cookie`) on every -`/challenge/{id}/v1/view/*` response -(`BASE_GATEWAY_VIEW_FRAME_ANCESTORS` override, same default), so even a stale -or misbehaving challenge upstream cannot serve miner HTML through the gateway -without the sandbox floor. `Cross-Origin-Resource-Policy: same-origin` means -cross-origin embedders get nothing: integrations must load screenshots through -a same-origin proxy (as joinbase.ai does), not the bare gateway origin. - -CSP `sandbox` (without `allow-scripts`) neutralizes script even if an integrator -omits the iframe `sandbox` attribute. Screenshots-only serving makes miner -HTML unreachable in the first place; the header floor is the second line. +(`DESIGN_FRAME_ANCESTORS` / `BASE_GATEWAY_VIEW_FRAME_ANCESTORS` override). + +Screenshots-only serving makes miner HTML unreachable in the first place; the +non-PNG header floor is the second line. ### Full-page screenshot (`index.png`) @@ -278,7 +279,10 @@ boundary plus the scriptless sanitized artifact is the sandbox. Two passes hard process timeout, one retry; failure never fails the run. The PNG is stored as the `index.png` artifact (base64) and served at `GET /v1/view/{run_id}/index.png` (`image/png`, `private, no-store`, -`nosniff`); run detail exposes `screenshot_url` when the artifact exists. +`nosniff`, `Cross-Origin-Resource-Policy: cross-origin`); run detail exposes +`screenshot_url` when the artifact exists. Public sites should point `` +at the absolute gateway host (e.g. `https://chain.joinbase.ai/challenge/design/...`) +rather than proxying PNG bytes through a CDN edge. Backfill (idempotent; upserts on `(run_id, path)` so it can be re-run and can race a live capture safely): diff --git a/docs/SITE_API.md b/docs/SITE_API.md index 2a00f95e9..8df6d5f7a 100644 --- a/docs/SITE_API.md +++ b/docs/SITE_API.md @@ -15,8 +15,13 @@ frontend `BaseApi` contract (`types.ts` / `contract.ts`). | Coding arena | `status: "paused"`, empty submissions / matrix / leaderboard | Design submissions carry **no `url`** (produced HTML is never served); the -public preview is `screenshotUrl` → `/challenge/design/v1/view/{runId}/index.png`, -and runs without a captured screenshot are excluded from the submissions list. +public preview is `screenshotUrl` → `/challenge/design/v1/view/{runId}/index.png` +(relative path on the gateway). Marketing clients should resolve that path to the +**absolute** gateway host for `` (e.g. +`https://chain.joinbase.ai/challenge/design/v1/view/{runId}/index.png`) so PNG +bytes are not proxied through the site's Vercel `/gbase-api` rewrite. JSON +`/v1/site/*` calls may keep using the same-origin proxy. Runs without a captured +screenshot are excluded from the submissions list. Leaderboard `elo` is the design `rating` field. Prism window series use real terminal `bpb` with a single `[final]` point when no step curve is stored. From 47113a6a38a905dc50fa514e7e3e90acc0c3ef19 Mon Sep 17 00:00:00 2001 From: echobt <154886644+echobt@users.noreply.github.com> Date: Fri, 7 Aug 2026 11:36:29 +0000 Subject: [PATCH 2/2] fix(gateway): satisfy clippy on PNG view lockdown path Rename similar bindings and use Path::extension for case-insensitive .png detection so -D warnings CI can merge the CORP cross-origin fix. --- crates/gateway/src/proxy.rs | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/crates/gateway/src/proxy.rs b/crates/gateway/src/proxy.rs index ae9e9240b..bab21093e 100644 --- a/crates/gateway/src/proxy.rs +++ b/crates/gateway/src/proxy.rs @@ -234,8 +234,11 @@ pub fn is_view_path(rest: &str) -> bool { /// Captured PNG screenshot under `/v1/view/{run}/{page}.png`. #[must_use] -pub fn is_view_png_path(rest: &str) -> bool { - is_view_path(rest) && rest.trim_start_matches('/').ends_with(".png") +pub fn is_view_png_path(path: &str) -> bool { + is_view_path(path) + && std::path::Path::new(path.trim_start_matches('/')) + .extension() + .is_some_and(|ext| ext.eq_ignore_ascii_case("png")) } /// Re-apply the viewer header floor at the last serving layer (defense in @@ -244,10 +247,10 @@ pub fn is_view_png_path(rest: &str) -> bool { /// (`CORP: cross-origin`) so joinbase.ai can load them with a direct absolute /// URL and avoid proxying image bytes through Vercel. `Set-Cookie` is always /// stripped. -fn apply_view_lockdown(resp: &mut Response, frame_ancestors: &str, rest: &str) { +fn apply_view_lockdown(resp: &mut Response, frame_ancestors: &str, view_path: &str) { let headers = resp.headers_mut(); headers.remove(header::SET_COOKIE); - let floor = if is_view_png_path(rest) { + let floor = if is_view_png_path(view_path) { // Drop HTML-only lockdown if a stale hop set them on a PNG response. headers.remove(header::CONTENT_SECURITY_POLICY); headers.remove(HeaderName::from_static("cross-origin-opener-policy"));