diff --git a/crates/design-challenge/src/screenshot.rs b/crates/design-challenge/src/screenshot.rs
index f5641ebe9..4a4585095 100644
--- a/crates/design-challenge/src/screenshot.rs
+++ b/crates/design-challenge/src/screenshot.rs
@@ -6,8 +6,16 @@
//! sandbox CSP meant for browser embedding, and the stored artifact is the
//! capture source of truth. `--no-sandbox` is required because Chromium's
//! renderer sandbox needs user namespaces / `CAP_SYS_ADMIN`, which Docker
-//! containers do not grant; the container boundary plus the scriptless
-//! sanitized artifact is the sandbox.
+//! containers do not grant.
+//!
+//! Network isolation: Chromium shares the design-challenge netns (high-trust
+//! `base` network). All `http(s)` subresource / navigation attempts are forced
+//! through `design-egress-proxy` (`--proxy-server` + `--proxy-bypass-list=
+//! <-loopback>`) so the same internal-target blocklist that guards miner
+//! sandboxes also covers screenshot SSRF (gateway admin, metadata, postgres,
+//! socket-proxy). Capture documents also carry a nonce-locked CSP that blocks
+//! unintended scripts and navigations (CLI Chromium has no Playwright route
+//! hooks).
use std::path::{Path, PathBuf};
use std::process::{Command, Output, Stdio};
@@ -29,10 +37,29 @@ const DEFAULT_MAX_HEIGHT: u32 = 12_000;
const DEFAULT_TIMEOUT_SECS: u64 = 90;
/// Virtual-time budget per render so remote images settle (ms).
const VIRTUAL_TIME_BUDGET_MS: u32 = 10_000;
+/// Default forward proxy for screenshot Chromium (`DESIGN_SCREENSHOT_PROXY`).
+const DEFAULT_SCREENSHOT_PROXY: &str = "http://design-egress-proxy:8094";
/// Marker the height probe writes into `
`.
const HEIGHT_MARKER: &str = "SHOTH=";
/// Height probe appended to the throwaway capture document (never shipped).
-const MEASURE_SCRIPT: &str = "";
+/// Nonce `designshot1` must match `CAPTURE_CSP` `script-src`.
+const MEASURE_SCRIPT: &str = "";
+/// Capture-document CSP: nonce script only; block connect/nav; allow public
+/// img/font/style so CDN assets still paint (they still traverse the egress
+/// proxy blocklist).
+const CAPTURE_CSP: &str = "default-src 'none'; \
+img-src data: https: http:; \
+style-src 'unsafe-inline' data: https: http:; \
+font-src data: https: http:; \
+script-src 'nonce-designshot1'; \
+connect-src 'none'; \
+frame-src 'none'; \
+object-src 'none'; \
+media-src 'none'; \
+worker-src 'none'; \
+base-uri 'none'; \
+form-action 'none'; \
+navigate-to 'none'";
/// Capture knobs resolved from env at call time (tests build them directly).
#[derive(Debug, Clone)]
@@ -45,6 +72,9 @@ struct CaptureConfig {
max_height: u32,
/// Total attempts (initial + retries).
attempts: u32,
+ /// Forward proxy URL (`None` / empty = direct; prod compose always sets
+ /// `design-egress-proxy`).
+ proxy: Option,
}
impl CaptureConfig {
@@ -72,10 +102,21 @@ impl CaptureConfig {
)),
max_height: env_u32("DESIGN_SCREENSHOT_MAX_HEIGHT", DEFAULT_MAX_HEIGHT),
attempts: 2,
+ proxy: screenshot_proxy_from_env(),
}
}
}
+/// Resolve `DESIGN_SCREENSHOT_PROXY`. Unset → egress proxy default; empty
+/// string → disable (local stub tests / operators debugging without compose).
+fn screenshot_proxy_from_env() -> Option {
+ match std::env::var("DESIGN_SCREENSHOT_PROXY") {
+ Ok(v) if v.is_empty() => None,
+ Ok(v) => Some(v),
+ Err(_) => Some(DEFAULT_SCREENSHOT_PROXY.to_owned()),
+ }
+}
+
/// Capture a full-page PNG of sanitized `html` (best-effort).
///
/// Two Chromium passes per attempt: measure the rendered height (probe script
@@ -144,6 +185,7 @@ fn capture_once(
stamp,
attempt,
cfg.timeout,
+ cfg.proxy.as_deref(),
)
});
if !ok {
@@ -173,7 +215,7 @@ fn measure_height(
let profile = profile_dir(work_dir, stamp, attempt, "dom");
let out = run_with_timeout(
Command::new(bin)
- .args(base_args(&profile))
+ .args(base_args(&profile, cfg.proxy.as_deref()))
.arg("--dump-dom")
.arg(url),
cfg.timeout,
@@ -193,11 +235,12 @@ fn shoot(
stamp: u128,
attempt: u32,
timeout: Duration,
+ proxy: Option<&str>,
) -> bool {
let profile = profile_dir(work_dir, stamp, attempt, "png");
let res = run_with_timeout(
Command::new(bin)
- .args(base_args(&profile))
+ .args(base_args(&profile, proxy))
.arg(format!("--screenshot={}", out.display()))
.arg(format!("--window-size={WIDTH},{height}"))
.arg(url),
@@ -208,11 +251,11 @@ fn shoot(
}
/// Shared headless flags. `--no-sandbox`: the renderer sandbox needs userns /
-/// `CAP_SYS_ADMIN`, unavailable in Docker — the container plus the scriptless
-/// sanitized artifact is the security boundary. `--disable-dev-shm-usage`:
-/// Docker caps `/dev/shm` at 64MiB, which crashes tall renders.
-fn base_args(profile: &Path) -> Vec {
- [
+/// `CAP_SYS_ADMIN`, unavailable in Docker — network isolation is the egress
+/// proxy + capture CSP below. `--disable-dev-shm-usage`: Docker caps
+/// `/dev/shm` at 64MiB, which crashes tall renders.
+fn base_args(profile: &Path, proxy: Option<&str>) -> Vec {
+ let mut args: Vec = [
"--headless=new",
"--no-sandbox",
"--disable-setuid-sandbox",
@@ -220,10 +263,12 @@ fn base_args(profile: &Path) -> Vec {
"--disable-gpu",
"--disable-crash-reporter",
"--disable-breakpad",
+ "--disable-background-networking",
"--no-first-run",
"--hide-scrollbars",
"--force-color-profile=srgb",
"--run-all-compositor-stages-before-draw",
+ "--block-new-web-contents",
]
.into_iter()
.map(str::to_owned)
@@ -231,7 +276,15 @@ fn base_args(profile: &Path) -> Vec {
format!("--virtual-time-budget={VIRTUAL_TIME_BUDGET_MS}"),
format!("--user-data-dir={}", profile.display()),
])
- .collect()
+ .collect();
+ if let Some(p) = proxy.filter(|s| !s.is_empty()) {
+ // Route all http(s) — including loopback / link-local — through the
+ // design-egress-proxy blocklist. `<-loopback>` removes Chrome's
+ // implicit bypass of localhost (and related) targets.
+ args.push(format!("--proxy-server={p}"));
+ args.push("--proxy-bypass-list=<-loopback>".into());
+ }
+ args
}
/// Spawn → poll → kill on timeout. `Command::wait_timeout` is unstable, so
@@ -259,10 +312,13 @@ fn run_with_timeout(cmd: &mut Command, timeout: Duration) -> Option