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
22 changes: 14 additions & 8 deletions crates/design-http/src/api.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
Expand Down Expand Up @@ -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.
Expand Down
36 changes: 33 additions & 3 deletions crates/design-sanitize/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<img src="https://chain.joinbase.ai/.../index.png">`
/// without proxying PNG bytes through Vercel.
#[must_use]
pub fn viewer_headers(frame_ancestors: &str) -> Vec<(&'static str, String)> {
let csp = format!(
Expand All @@ -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()),
(
Expand All @@ -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)]
Expand Down Expand Up @@ -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();
Expand Down
65 changes: 55 additions & 10 deletions crates/gateway/src/proxy.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down Expand Up @@ -226,21 +226,40 @@ 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(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
/// 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, view_path: &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(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"));
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);
}
Expand Down Expand Up @@ -280,6 +299,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]
Expand All @@ -291,7 +312,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
Expand All @@ -308,4 +329,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")
);
}
}
51 changes: 51 additions & 0 deletions crates/gateway/tests/proxy_view_lockdown.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(());
}
54 changes: 29 additions & 25 deletions docs/DESIGN_CHALLENGE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
```
Comment on lines +231 to +236

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Add a language identifier to the fenced block.

Static analysis reports MD040 at Line 231. Mark this block as http so Markdown linting accepts it.

🧰 Tools
🪛 markdownlint-cli2 (0.23.2)

[warning] 231-231: Fenced code blocks should have a language specified

(MD040, fenced-code-language)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@docs/DESIGN_CHALLENGE.md` around lines 231 - 236, Add the http language
identifier to the fenced code block containing the security response headers,
while leaving the block’s contents unchanged.

Source: Linters/SAST tools


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; `<img src>`
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 <allowlist>
Expand All @@ -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 <allowlist>` 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`)

Expand All @@ -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 `<img src>`
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):
Expand Down
9 changes: 7 additions & 2 deletions docs/SITE_API.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<img src>` (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.
Expand Down
Loading