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
1220use std:: path:: { Path , PathBuf } ;
1321use std:: process:: { Command , Output , Stdio } ;
@@ -29,10 +37,29 @@ const DEFAULT_MAX_HEIGHT: u32 = 12_000;
2937const DEFAULT_TIMEOUT_SECS : u64 = 90 ;
3038/// Virtual-time budget per render so remote images settle (ms).
3139const 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>`.
3343const 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
5080impl 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.
263317fn 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\n printf '%s\\ n' \" $*\" >> \" {log}\" \n out=\" \" \n for a in \" $@\" ; do case \" $a\" in --screenshot=*) out=\" ${{a#--screenshot=}}\" ;; esac; done\n case \" $*\" in *--dump-dom*) echo '<title>SHOTH=900</title>'; exit 0;; esac\n if [ -n \" $out\" ]; then cp \" {png}\" \" $out\" ; exit 0; fi\n exit 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