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"));