@@ -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]
231231pub 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}
0 commit comments