Skip to content

Commit 0f42d63

Browse files
authored
fix(design): allow cross-origin PNG screenshots from chain (#80)
* fix(design): allow cross-origin PNG screenshots from chain Public gallery <img> 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. * 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.
1 parent f9902c0 commit 0f42d63

6 files changed

Lines changed: 189 additions & 48 deletions

File tree

crates/design-http/src/api.rs

Lines changed: 14 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -821,14 +821,14 @@ async fn view_page(
821821
header::CONTENT_TYPE,
822822
header::HeaderValue::from_static("image/png"),
823823
);
824-
headers.insert(
825-
header::CACHE_CONTROL,
826-
header::HeaderValue::from_static("private, no-store"),
827-
);
828-
headers.insert(
829-
header::HeaderName::from_static("x-content-type-options"),
830-
header::HeaderValue::from_static("nosniff"),
831-
);
824+
for (k, v) in design_sanitize::screenshot_headers() {
825+
if let (Ok(name), Ok(val)) = (
826+
header::HeaderName::try_from(k),
827+
header::HeaderValue::try_from(v),
828+
) {
829+
headers.insert(name, val);
830+
}
831+
}
832832
(StatusCode::OK, headers, bytes).into_response()
833833
}
834834
Ok(None) => json_err(StatusCode::NOT_FOUND, "not_found", "page"),
@@ -1468,6 +1468,12 @@ mod tests {
14681468
.and_then(|v| v.to_str().ok()),
14691469
Some("nosniff")
14701470
);
1471+
assert_eq!(
1472+
res.headers()
1473+
.get("cross-origin-resource-policy")
1474+
.and_then(|v| v.to_str().ok()),
1475+
Some("cross-origin")
1476+
);
14711477
let bytes = res.into_body().collect().await.unwrap().to_bytes();
14721478
assert_eq!(bytes.as_ref(), &png_bytes);
14731479
// Unknown png → 404.

crates/design-sanitize/src/lib.rs

Lines changed: 33 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -320,12 +320,17 @@ pub fn default_frame_ancestors() -> &'static str {
320320
"'self' https://joinbase.ai https://*.vercel.app http://localhost:*"
321321
}
322322

323-
/// Viewer response headers (CSP sandbox is the key guarantee).
323+
/// Viewer response headers for **non-PNG** `/v1/view/*` responses (CSP sandbox
324+
/// is the key guarantee against a stale upstream that still served miner HTML).
324325
///
325326
/// The `sandbox` directive is emitted **without** `allow-scripts` and without
326327
/// `allow-same-origin`: the document runs in an opaque origin with script
327328
/// execution disabled, so miner HTML can never touch the serving origin's
328329
/// cookies, storage, or DOM — even when embedded same-origin through a proxy.
330+
///
331+
/// Public screenshots use [`screenshot_headers`] instead (`CORP: cross-origin`)
332+
/// so joinbase.ai can `<img src="https://chain.joinbase.ai/.../index.png">`
333+
/// without proxying PNG bytes through Vercel.
329334
#[must_use]
330335
pub fn viewer_headers(frame_ancestors: &str) -> Vec<(&'static str, String)> {
331336
let csp = format!(
@@ -336,8 +341,8 @@ pub fn viewer_headers(frame_ancestors: &str) -> Vec<(&'static str, String)> {
336341
("Content-Security-Policy", csp),
337342
("X-Content-Type-Options", "nosniff".into()),
338343
("Referrer-Policy", "no-referrer".into()),
339-
// Viewer responses are only ever embedded same-origin (site proxies
340-
// the gateway under its own origin); cross-origin embedders get nothing.
344+
// Non-PNG view responses stay same-origin only (defense in depth if
345+
// HTML ever leaks through); PNGs use `screenshot_headers`.
341346
("Cross-Origin-Resource-Policy", "same-origin".into()),
342347
("Cross-Origin-Opener-Policy", "same-origin".into()),
343348
(
@@ -350,6 +355,21 @@ pub fn viewer_headers(frame_ancestors: &str) -> Vec<(&'static str, String)> {
350355
]
351356
}
352357

358+
/// Headers for public PNG screenshots (`index.png`).
359+
///
360+
/// `Cross-Origin-Resource-Policy: cross-origin` lets marketing/admin UIs load
361+
/// the image with a direct absolute URL to the gateway (no same-origin proxy
362+
/// required). PNGs are not executable documents; cookies are never set.
363+
#[must_use]
364+
pub fn screenshot_headers() -> Vec<(&'static str, String)> {
365+
vec![
366+
("X-Content-Type-Options", "nosniff".into()),
367+
("Referrer-Policy", "no-referrer".into()),
368+
("Cross-Origin-Resource-Policy", "cross-origin".into()),
369+
("Cache-Control", "private, no-store".into()),
370+
]
371+
}
372+
353373
#[cfg(test)]
354374
mod tests {
355375
#![allow(clippy::unwrap_used)]
@@ -427,6 +447,16 @@ mod tests {
427447
assert!(get("Set-Cookie").is_none());
428448
}
429449

450+
#[test]
451+
fn screenshot_headers_allow_cross_origin_img() {
452+
let h = screenshot_headers();
453+
let get = |name: &str| h.iter().find(|(k, _)| *k == name).map(|(_, v)| v.as_str());
454+
assert_eq!(get("Cross-Origin-Resource-Policy"), Some("cross-origin"));
455+
assert_eq!(get("X-Content-Type-Options"), Some("nosniff"));
456+
assert!(get("Content-Security-Policy").is_none());
457+
assert!(get("Cross-Origin-Opener-Policy").is_none());
458+
}
459+
430460
#[test]
431461
fn default_frame_ancestors_allowlist() {
432462
let fa = default_frame_ancestors();

crates/gateway/src/proxy.rs

Lines changed: 55 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -117,7 +117,7 @@ async fn proxy_inner(
117117
}
118118
st.registry.record_success(backend.id);
119119
if is_view_path(&rest) {
120-
apply_view_lockdown(&mut upstream_resp, &st.view_frame_ancestors);
120+
apply_view_lockdown(&mut upstream_resp, &st.view_frame_ancestors, &rest);
121121
}
122122
return upstream_resp;
123123
}
@@ -226,21 +226,40 @@ pub fn is_admin_path(rest: &str) -> bool {
226226
rest_norm.starts_with("v1/admin/") || rest_norm == "v1/admin"
227227
}
228228

229-
/// Miner-controlled HTML viewer paths (`/challenge/{id}/v1/view/{run}/{page}`).
229+
/// Miner-controlled viewer paths (`/challenge/{id}/v1/view/{run}/{page}`).
230230
#[must_use]
231231
pub fn is_view_path(rest: &str) -> bool {
232232
rest.trim_start_matches('/').starts_with("v1/view/")
233233
}
234234

235-
/// Re-apply the viewer lockdown header floor at the last serving layer
236-
/// (defense in depth): even a stale or misbehaving challenge upstream cannot
237-
/// serve miner HTML through the gateway without the CSP `sandbox` (opaque
238-
/// origin, no scripts), and `Set-Cookie` is stripped so these public
239-
/// capability-URL responses never touch origin cookies.
240-
fn apply_view_lockdown(resp: &mut Response, frame_ancestors: &str) {
235+
/// Captured PNG screenshot under `/v1/view/{run}/{page}.png`.
236+
#[must_use]
237+
pub fn is_view_png_path(path: &str) -> bool {
238+
is_view_path(path)
239+
&& std::path::Path::new(path.trim_start_matches('/'))
240+
.extension()
241+
.is_some_and(|ext| ext.eq_ignore_ascii_case("png"))
242+
}
243+
244+
/// Re-apply the viewer header floor at the last serving layer (defense in
245+
/// depth). Non-PNG paths get the full HTML lockdown (CSP `sandbox`, CORP
246+
/// same-origin). PNG screenshots get [`design_sanitize::screenshot_headers`]
247+
/// (`CORP: cross-origin`) so joinbase.ai can load them with a direct absolute
248+
/// URL and avoid proxying image bytes through Vercel. `Set-Cookie` is always
249+
/// stripped.
250+
fn apply_view_lockdown(resp: &mut Response, frame_ancestors: &str, view_path: &str) {
241251
let headers = resp.headers_mut();
242252
headers.remove(header::SET_COOKIE);
243-
for (k, v) in design_sanitize::viewer_headers(frame_ancestors) {
253+
let floor = if is_view_png_path(view_path) {
254+
// Drop HTML-only lockdown if a stale hop set them on a PNG response.
255+
headers.remove(header::CONTENT_SECURITY_POLICY);
256+
headers.remove(HeaderName::from_static("cross-origin-opener-policy"));
257+
headers.remove(HeaderName::from_static("permissions-policy"));
258+
design_sanitize::screenshot_headers()
259+
} else {
260+
design_sanitize::viewer_headers(frame_ancestors)
261+
};
262+
for (k, v) in floor {
244263
if let (Ok(name), Ok(val)) = (HeaderName::try_from(k), HeaderValue::try_from(v.as_str())) {
245264
headers.insert(name, val);
246265
}
@@ -280,6 +299,8 @@ mod tests {
280299
assert!(!is_view_path("v1/runs/abc"));
281300
assert!(!is_view_path("v1/viewx/abc"));
282301
assert!(!is_view_path("v1/admin/view"));
302+
assert!(is_view_png_path("v1/view/abc/index.png"));
303+
assert!(!is_view_png_path("v1/view/abc/index.html"));
283304
}
284305

285306
#[test]
@@ -291,7 +312,7 @@ mod tests {
291312
header::CONTENT_SECURITY_POLICY,
292313
HeaderValue::from_static("default-src *"),
293314
);
294-
apply_view_lockdown(&mut resp, "'none'");
315+
apply_view_lockdown(&mut resp, "'none'", "v1/view/abc/index.html");
295316
let h = resp.headers();
296317
assert!(h.get(header::SET_COOKIE).is_none());
297318
let csp = h
@@ -308,4 +329,28 @@ mod tests {
308329
Some("nosniff")
309330
);
310331
}
332+
333+
#[test]
334+
fn png_view_lockdown_allows_cross_origin_img() {
335+
let mut resp = Response::new(Body::from(vec![0x89_u8, 0x50, 0x4e, 0x47]));
336+
let h = resp.headers_mut();
337+
h.insert(header::SET_COOKIE, HeaderValue::from_static("session=evil"));
338+
h.insert(
339+
header::CONTENT_SECURITY_POLICY,
340+
HeaderValue::from_static("sandbox; default-src 'none'"),
341+
);
342+
h.insert(
343+
HeaderName::from_static("cross-origin-resource-policy"),
344+
HeaderValue::from_static("same-origin"),
345+
);
346+
apply_view_lockdown(&mut resp, "'none'", "v1/view/abc/index.png");
347+
let h = resp.headers();
348+
assert!(h.get(header::SET_COOKIE).is_none());
349+
assert!(h.get(header::CONTENT_SECURITY_POLICY).is_none());
350+
assert_eq!(
351+
h.get("cross-origin-resource-policy")
352+
.and_then(|v| v.to_str().ok()),
353+
Some("cross-origin")
354+
);
355+
}
311356
}

crates/gateway/tests/proxy_view_lockdown.rs

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -152,3 +152,54 @@ async fn view_response_leaves_gateway_sandboxed_without_cookies() {
152152

153153
let _ = shutdown.send(());
154154
}
155+
156+
#[tokio::test]
157+
async fn png_view_allows_cross_origin_resource_policy() {
158+
let upstream = MockServer::start().await;
159+
Mock::given(method("GET"))
160+
.and(path("/v1/view/run1/index.png"))
161+
.respond_with(
162+
ResponseTemplate::new(200)
163+
.insert_header("content-type", "image/png")
164+
// Stale lockdown from an older gateway hop must not stick.
165+
.insert_header("cross-origin-resource-policy", "same-origin")
166+
.insert_header("content-security-policy", "sandbox; default-src 'none'")
167+
.insert_header("set-cookie", "session=evil; Path=/")
168+
.set_body_bytes(vec![0x89, 0x50, 0x4e, 0x47]),
169+
)
170+
.mount(&upstream)
171+
.await;
172+
173+
let reg = Registry::shared(RegistryConfig {
174+
failure_threshold: 2,
175+
cooldown: Duration::from_millis(120),
176+
});
177+
reg.create(&CreateBackend {
178+
challenge_id: "design".into(),
179+
base_url: upstream.uri(),
180+
weight: 1,
181+
})
182+
.unwrap();
183+
184+
let (addr, shutdown) = spawn_gateway(reg).await;
185+
let client = reqwest::Client::new();
186+
let resp = client
187+
.get(format!(
188+
"http://{addr}/challenge/design/v1/view/run1/index.png"
189+
))
190+
.send()
191+
.await
192+
.expect("proxy png");
193+
assert_eq!(resp.status().as_u16(), 200);
194+
let headers = resp.headers();
195+
assert!(headers.get("set-cookie").is_none());
196+
assert!(headers.get("content-security-policy").is_none());
197+
assert_eq!(
198+
headers
199+
.get("cross-origin-resource-policy")
200+
.and_then(|v| v.to_str().ok()),
201+
Some("cross-origin")
202+
);
203+
204+
let _ = shutdown.send(());
205+
}

docs/DESIGN_CHALLENGE.md

Lines changed: 29 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -224,8 +224,23 @@ error, and `GET /v1/runs/{id}/bundle.json` no longer embeds page HTML (same
224224
`410 Gone` contract — use `/v1/runs/{id}/pages` for page metadata). Miner
225225
output reaches browsers exclusively as the captured `index.png` screenshot.
226226

227-
The full lockdown header set remains as the **gateway-enforced floor** on
228-
every `/challenge/{id}/v1/view/*` response (defense in depth — below):
227+
**PNG screenshots** (`*.png`) leave the gateway with a light header floor so
228+
marketing UIs can load them with a **direct absolute URL** (no Vercel proxy of
229+
image bytes):
230+
231+
```
232+
X-Content-Type-Options: nosniff
233+
Referrer-Policy: no-referrer
234+
Cross-Origin-Resource-Policy: cross-origin
235+
Cache-Control: private, no-store
236+
```
237+
238+
Example: `https://chain.joinbase.ai/challenge/design/v1/view/{run_id}/index.png`.
239+
JSON/site API calls may still use the site's `/gbase-api` rewrite; `<img src>`
240+
for screenshots should not.
241+
242+
**Non-PNG** `/challenge/{id}/v1/view/*` responses (e.g. HTML `410 Gone`, or a
243+
stale upstream that still served miner HTML) keep the full lockdown floor:
229244

230245
```
231246
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 <allowlist>
@@ -237,33 +252,19 @@ Permissions-Policy: accelerometer=(), camera=(), geolocation=(), gyroscope=(), m
237252
Cache-Control: private, no-store
238253
```
239254

240-
The `sandbox` directive is emitted **without** `allow-scripts` and without
255+
The `sandbox` directive is emitted without `allow-scripts` and without
241256
`allow-same-origin`: even if a stale or misbehaving challenge upstream served
242257
miner HTML through the gateway, it would run in an **opaque origin with script
243-
execution disabled**, unable to read the serving origin's cookies,
244-
storage, or DOM — even though joinbase.ai embeds view responses
245-
**same-origin** through the `/gbase-api` proxy. These responses **never carry
246-
`Set-Cookie`** (the gateway strips it); the endpoint stays public (`run_id` is
247-
the capability) and reads no auth cookie.
258+
execution disabled**, unable to read the serving origin's cookies, storage, or
259+
DOM. All view responses **never carry `Set-Cookie`** (the gateway strips it);
260+
the endpoint stays public (`run_id` is the capability) and reads no auth cookie.
248261

249262
`frame-ancestors <allowlist>` defaults to
250263
`'self' https://joinbase.ai https://*.vercel.app http://localhost:*`
251-
(`DESIGN_FRAME_ANCESTORS` override): the public site, Vercel preview deploys,
252-
and local dev may embed the viewer; everyone else is refused. `'self'` covers
253-
same-origin proxy embedding at any host (including staging consoles).
254-
255-
**Defense in depth — gateway re-injection.** The gateway proxy re-applies the
256-
full lockdown header set (and strips any `Set-Cookie`) on every
257-
`/challenge/{id}/v1/view/*` response
258-
(`BASE_GATEWAY_VIEW_FRAME_ANCESTORS` override, same default), so even a stale
259-
or misbehaving challenge upstream cannot serve miner HTML through the gateway
260-
without the sandbox floor. `Cross-Origin-Resource-Policy: same-origin` means
261-
cross-origin embedders get nothing: integrations must load screenshots through
262-
a same-origin proxy (as joinbase.ai does), not the bare gateway origin.
263-
264-
CSP `sandbox` (without `allow-scripts`) neutralizes script even if an integrator
265-
omits the iframe `sandbox` attribute. Screenshots-only serving makes miner
266-
HTML unreachable in the first place; the header floor is the second line.
264+
(`DESIGN_FRAME_ANCESTORS` / `BASE_GATEWAY_VIEW_FRAME_ANCESTORS` override).
265+
266+
Screenshots-only serving makes miner HTML unreachable in the first place; the
267+
non-PNG header floor is the second line.
267268

268269
### Full-page screenshot (`index.png`)
269270

@@ -278,7 +279,10 @@ boundary plus the scriptless sanitized artifact is the sandbox. Two passes
278279
hard process timeout, one retry; failure never fails the run. The PNG is
279280
stored as the `index.png` artifact (base64) and served at
280281
`GET /v1/view/{run_id}/index.png` (`image/png`, `private, no-store`,
281-
`nosniff`); run detail exposes `screenshot_url` when the artifact exists.
282+
`nosniff`, `Cross-Origin-Resource-Policy: cross-origin`); run detail exposes
283+
`screenshot_url` when the artifact exists. Public sites should point `<img src>`
284+
at the absolute gateway host (e.g. `https://chain.joinbase.ai/challenge/design/...`)
285+
rather than proxying PNG bytes through a CDN edge.
282286

283287
Backfill (idempotent; upserts on `(run_id, path)` so it can be re-run and can
284288
race a live capture safely):

docs/SITE_API.md

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,8 +15,13 @@ frontend `BaseApi` contract (`types.ts` / `contract.ts`).
1515
| Coding arena | `status: "paused"`, empty submissions / matrix / leaderboard |
1616

1717
Design submissions carry **no `url`** (produced HTML is never served); the
18-
public preview is `screenshotUrl``/challenge/design/v1/view/{runId}/index.png`,
19-
and runs without a captured screenshot are excluded from the submissions list.
18+
public preview is `screenshotUrl``/challenge/design/v1/view/{runId}/index.png`
19+
(relative path on the gateway). Marketing clients should resolve that path to the
20+
**absolute** gateway host for `<img src>` (e.g.
21+
`https://chain.joinbase.ai/challenge/design/v1/view/{runId}/index.png`) so PNG
22+
bytes are not proxied through the site's Vercel `/gbase-api` rewrite. JSON
23+
`/v1/site/*` calls may keep using the same-origin proxy. Runs without a captured
24+
screenshot are excluded from the submissions list.
2025
Leaderboard `elo` is the design
2126
`rating` field. Prism window series use real terminal `bpb` with a single
2227
`[final]` point when no step curve is stored.

0 commit comments

Comments
 (0)