Skip to content

Commit 9ffdff9

Browse files
committed
fix(design): isolate screenshot Chromium via egress proxy
Force --no-sandbox file:// captures through design-egress-proxy (incl. loopback), neutralize SSRF-ish href/src in sanitize, and keep host Sim fail-closed on staging/prod.
1 parent 1e688ad commit 9ffdff9

7 files changed

Lines changed: 333 additions & 24 deletions

File tree

crates/design-challenge/src/screenshot.rs

Lines changed: 121 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,16 @@
66
//! sandbox CSP meant for browser embedding, and the stored artifact is the
77
//! capture source of truth. `--no-sandbox` is required because Chromium's
88
//! renderer sandbox needs user namespaces / `CAP_SYS_ADMIN`, which Docker
9-
//! containers do not grant; the container boundary plus the scriptless
10-
//! sanitized artifact is the sandbox.
9+
//! containers do not grant.
10+
//!
11+
//! Network isolation: Chromium shares the design-challenge netns (high-trust
12+
//! `base` network). All `http(s)` subresource / navigation attempts are forced
13+
//! through `design-egress-proxy` (`--proxy-server` + `--proxy-bypass-list=
14+
//! <-loopback>`) so the same internal-target blocklist that guards miner
15+
//! sandboxes also covers screenshot SSRF (gateway admin, metadata, postgres,
16+
//! socket-proxy). Capture documents also carry a nonce-locked CSP that blocks
17+
//! unintended scripts and navigations (CLI Chromium has no Playwright route
18+
//! hooks).
1119
1220
use std::path::{Path, PathBuf};
1321
use std::process::{Command, Output, Stdio};
@@ -29,10 +37,29 @@ const DEFAULT_MAX_HEIGHT: u32 = 12_000;
2937
const DEFAULT_TIMEOUT_SECS: u64 = 90;
3038
/// Virtual-time budget per render so remote images settle (ms).
3139
const VIRTUAL_TIME_BUDGET_MS: u32 = 10_000;
40+
/// Default forward proxy for screenshot Chromium (`DESIGN_SCREENSHOT_PROXY`).
41+
const DEFAULT_SCREENSHOT_PROXY: &str = "http://design-egress-proxy:8094";
3242
/// Marker the height probe writes into `<title>`.
3343
const HEIGHT_MARKER: &str = "SHOTH=";
3444
/// Height probe appended to the throwaway capture document (never shipped).
35-
const MEASURE_SCRIPT: &str = "<script>addEventListener('load',function(){setTimeout(function(){var d=document,e=d.documentElement,b=d.body;d.title='SHOTH='+Math.max(e.scrollHeight,b?b.scrollHeight:0)},50)})</script>";
45+
/// Nonce `designshot1` must match `CAPTURE_CSP` `script-src`.
46+
const MEASURE_SCRIPT: &str = "<script nonce=\"designshot1\">addEventListener('load',function(){setTimeout(function(){var d=document,e=d.documentElement,b=d.body;d.title='SHOTH='+Math.max(e.scrollHeight,b?b.scrollHeight:0)},50)})</script>";
47+
/// Capture-document CSP: nonce script only; block connect/nav; allow public
48+
/// img/font/style so CDN assets still paint (they still traverse the egress
49+
/// proxy blocklist).
50+
const CAPTURE_CSP: &str = "default-src 'none'; \
51+
img-src data: https: http:; \
52+
style-src 'unsafe-inline' data: https: http:; \
53+
font-src data: https: http:; \
54+
script-src 'nonce-designshot1'; \
55+
connect-src 'none'; \
56+
frame-src 'none'; \
57+
object-src 'none'; \
58+
media-src 'none'; \
59+
worker-src 'none'; \
60+
base-uri 'none'; \
61+
form-action 'none'; \
62+
navigate-to 'none'";
3663

3764
/// Capture knobs resolved from env at call time (tests build them directly).
3865
#[derive(Debug, Clone)]
@@ -45,6 +72,9 @@ struct CaptureConfig {
4572
max_height: u32,
4673
/// Total attempts (initial + retries).
4774
attempts: u32,
75+
/// Forward proxy URL (`None` / empty = direct; prod compose always sets
76+
/// `design-egress-proxy`).
77+
proxy: Option<String>,
4878
}
4979

5080
impl CaptureConfig {
@@ -72,10 +102,21 @@ impl CaptureConfig {
72102
)),
73103
max_height: env_u32("DESIGN_SCREENSHOT_MAX_HEIGHT", DEFAULT_MAX_HEIGHT),
74104
attempts: 2,
105+
proxy: screenshot_proxy_from_env(),
75106
}
76107
}
77108
}
78109

110+
/// Resolve `DESIGN_SCREENSHOT_PROXY`. Unset → egress proxy default; empty
111+
/// string → disable (local stub tests / operators debugging without compose).
112+
fn screenshot_proxy_from_env() -> Option<String> {
113+
match std::env::var("DESIGN_SCREENSHOT_PROXY") {
114+
Ok(v) if v.is_empty() => None,
115+
Ok(v) => Some(v),
116+
Err(_) => Some(DEFAULT_SCREENSHOT_PROXY.to_owned()),
117+
}
118+
}
119+
79120
/// Capture a full-page PNG of sanitized `html` (best-effort).
80121
///
81122
/// Two Chromium passes per attempt: measure the rendered height (probe script
@@ -144,6 +185,7 @@ fn capture_once(
144185
stamp,
145186
attempt,
146187
cfg.timeout,
188+
cfg.proxy.as_deref(),
147189
)
148190
});
149191
if !ok {
@@ -173,7 +215,7 @@ fn measure_height(
173215
let profile = profile_dir(work_dir, stamp, attempt, "dom");
174216
let out = run_with_timeout(
175217
Command::new(bin)
176-
.args(base_args(&profile))
218+
.args(base_args(&profile, cfg.proxy.as_deref()))
177219
.arg("--dump-dom")
178220
.arg(url),
179221
cfg.timeout,
@@ -193,11 +235,12 @@ fn shoot(
193235
stamp: u128,
194236
attempt: u32,
195237
timeout: Duration,
238+
proxy: Option<&str>,
196239
) -> bool {
197240
let profile = profile_dir(work_dir, stamp, attempt, "png");
198241
let res = run_with_timeout(
199242
Command::new(bin)
200-
.args(base_args(&profile))
243+
.args(base_args(&profile, proxy))
201244
.arg(format!("--screenshot={}", out.display()))
202245
.arg(format!("--window-size={WIDTH},{height}"))
203246
.arg(url),
@@ -208,30 +251,40 @@ fn shoot(
208251
}
209252

210253
/// Shared headless flags. `--no-sandbox`: the renderer sandbox needs userns /
211-
/// `CAP_SYS_ADMIN`, unavailable in Docker — the container plus the scriptless
212-
/// sanitized artifact is the security boundary. `--disable-dev-shm-usage`:
213-
/// Docker caps `/dev/shm` at 64MiB, which crashes tall renders.
214-
fn base_args(profile: &Path) -> Vec<String> {
215-
[
254+
/// `CAP_SYS_ADMIN`, unavailable in Docker — network isolation is the egress
255+
/// proxy + capture CSP below. `--disable-dev-shm-usage`: Docker caps
256+
/// `/dev/shm` at 64MiB, which crashes tall renders.
257+
fn base_args(profile: &Path, proxy: Option<&str>) -> Vec<String> {
258+
let mut args: Vec<String> = [
216259
"--headless=new",
217260
"--no-sandbox",
218261
"--disable-setuid-sandbox",
219262
"--disable-dev-shm-usage",
220263
"--disable-gpu",
221264
"--disable-crash-reporter",
222265
"--disable-breakpad",
266+
"--disable-background-networking",
223267
"--no-first-run",
224268
"--hide-scrollbars",
225269
"--force-color-profile=srgb",
226270
"--run-all-compositor-stages-before-draw",
271+
"--block-new-web-contents",
227272
]
228273
.into_iter()
229274
.map(str::to_owned)
230275
.chain([
231276
format!("--virtual-time-budget={VIRTUAL_TIME_BUDGET_MS}"),
232277
format!("--user-data-dir={}", profile.display()),
233278
])
234-
.collect()
279+
.collect();
280+
if let Some(p) = proxy.filter(|s| !s.is_empty()) {
281+
// Route all http(s) — including loopback / link-local — through the
282+
// design-egress-proxy blocklist. `<-loopback>` removes Chrome's
283+
// implicit bypass of localhost (and related) targets.
284+
args.push(format!("--proxy-server={p}"));
285+
args.push("--proxy-bypass-list=<-loopback>".into());
286+
}
287+
args
235288
}
236289

237290
/// 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<Output> {
259312
}
260313

261314
/// Wrap the sanitized fragment (ammonia unwraps the html/head/body shell)
262-
/// into a capture document. The only script is our own height probe.
315+
/// into a capture document. The only script is our own height probe (CSP
316+
/// nonce); capture CSP blocks other scripts and navigations.
263317
fn render_doc(fragment: &str) -> String {
264318
format!(
265-
"<!DOCTYPE html><html><head><meta charset=\"utf-8\"><title>shot</title></head><body>{fragment}{MEASURE_SCRIPT}</body></html>"
319+
"<!DOCTYPE html><html><head><meta charset=\"utf-8\">\
320+
<meta http-equiv=\"Content-Security-Policy\" content=\"{CAPTURE_CSP}\">\
321+
<title>shot</title></head><body>{fragment}{MEASURE_SCRIPT}</body></html>"
266322
)
267323
}
268324

@@ -325,6 +381,9 @@ mod tests {
325381
timeout: Duration::from_secs(5),
326382
max_height: DEFAULT_MAX_HEIGHT,
327383
attempts: 1,
384+
// Stub browsers do not need a real proxy; empty env would still
385+
// default to design-egress-proxy in from_env().
386+
proxy: None,
328387
}
329388
}
330389

@@ -451,9 +510,58 @@ mod tests {
451510
assert!(doc.starts_with("<!DOCTYPE html>"));
452511
assert!(doc.contains("<main>hello</main>"));
453512
assert!(doc.contains("SHOTH="));
513+
assert!(doc.contains("Content-Security-Policy"));
514+
assert!(doc.contains("nonce-designshot1"));
515+
assert!(doc.contains("navigate-to 'none'"));
516+
assert!(doc.contains("nonce=\"designshot1\""));
454517
assert!(doc.ends_with("</body></html>"));
455518
}
456519

520+
#[test]
521+
fn base_args_force_egress_proxy_including_loopback() {
522+
let profile = PathBuf::from("/tmp/shot-profile");
523+
let args = base_args(&profile, Some("http://design-egress-proxy:8094"));
524+
assert!(args
525+
.iter()
526+
.any(|a| a == "--proxy-server=http://design-egress-proxy:8094"));
527+
assert!(args.iter().any(|a| a == "--proxy-bypass-list=<-loopback>"));
528+
assert!(args.iter().any(|a| a == "--block-new-web-contents"));
529+
let direct = base_args(&profile, None);
530+
assert!(direct.iter().all(|a| !a.starts_with("--proxy-server")));
531+
}
532+
533+
#[test]
534+
fn capture_passes_proxy_flags_to_browser() {
535+
let dir = std::env::temp_dir().join(format!("shot-proxy-{}", std::process::id()));
536+
let _ = std::fs::remove_dir_all(&dir);
537+
std::fs::create_dir_all(&dir).unwrap();
538+
let fake = dir.join("fake.png");
539+
std::fs::write(&fake, FAKE_PNG).unwrap();
540+
let args_log = dir.join("args.log");
541+
let stub = write_stub(
542+
&dir,
543+
&format!(
544+
"#!/bin/sh\nprintf '%s\\n' \"$*\" >> \"{log}\"\nout=\"\"\nfor a in \"$@\"; do case \"$a\" in --screenshot=*) out=\"${{a#--screenshot=}}\";; esac; done\ncase \"$*\" in *--dump-dom*) echo '<title>SHOTH=900</title>'; exit 0;; esac\nif [ -n \"$out\" ]; then cp \"{png}\" \"$out\"; exit 0; fi\nexit 1\n",
545+
log = args_log.display(),
546+
png = fake.display()
547+
),
548+
);
549+
let mut cfg = test_cfg(&stub);
550+
cfg.proxy = Some("http://design-egress-proxy:8094".into());
551+
let png = capture_with(&cfg, "<p>hi</p>", &dir.join("work"));
552+
assert_eq!(png.as_deref(), Some(FAKE_PNG));
553+
let logged = std::fs::read_to_string(&args_log).unwrap();
554+
assert!(
555+
logged.contains("--proxy-server=http://design-egress-proxy:8094"),
556+
"{logged}"
557+
);
558+
assert!(
559+
logged.contains("--proxy-bypass-list=<-loopback>"),
560+
"{logged}"
561+
);
562+
let _ = std::fs::remove_dir_all(&dir);
563+
}
564+
457565
#[test]
458566
fn png_tuple_sha_and_b64() {
459567
let (path, b64, raw, sha, bytes) = png_artifact_tuple(FAKE_PNG);

0 commit comments

Comments
 (0)