@@ -189,10 +189,11 @@ async def fake_route(pattern, handler):
189189 "Host" : "example.com" ,
190190 }
191191
192- def _route (self , url , method = "GET" ):
192+ def _route (self , url , method = "GET" , resource_type = "document" ):
193193 route = MagicMock ()
194194 route .request .url = url
195195 route .request .method = method
196+ route .request .resource_type = resource_type
196197 route .request .headers = dict (self ._BROWSER_HEADERS )
197198 route .abort = AsyncMock ()
198199 route .fulfill = AsyncMock ()
@@ -209,8 +210,9 @@ async def run():
209210
210211 asyncio .run (run ())
211212
212- @patch ("datascraper.ssrf_guard.safe_get" )
213- def test_route_guard_aborts_blocked_request (self , m_get ):
213+ @patch ("datascraper.ssrf_guard._PinnedSessionCache" )
214+ def test_route_guard_aborts_blocked_request (self , m_cache ):
215+ m_get = m_cache .return_value .fetch
214216 m_get .side_effect = UnsafeURLError ("blocked" )
215217 route = self ._route ("http://evil.example.test/x" )
216218 self ._drive (route )
@@ -219,8 +221,9 @@ def test_route_guard_aborts_blocked_request(self, m_get):
219221 # Never delegate the fetch back to Chromium (would re-resolve DNS).
220222 route .continue_ .assert_not_awaited ()
221223
222- @patch ("datascraper.ssrf_guard.safe_get" )
223- def test_route_guard_fulfills_public_request_from_pinned_fetch (self , m_get ):
224+ @patch ("datascraper.ssrf_guard._PinnedSessionCache" )
225+ def test_route_guard_fulfills_public_request_from_pinned_fetch (self , m_cache ):
226+ m_get = m_cache .return_value .fetch
224227 resp = _FakeResp (
225228 status_code = 200 ,
226229 headers = {"Content-Type" : "text/html" , "Content-Encoding" : "gzip" },
@@ -243,8 +246,9 @@ def test_route_guard_fulfills_public_request_from_pinned_fetch(self, m_get):
243246 m_get .assert_called_once ()
244247 self .assertEqual (m_get .call_args .args [0 ], "http://example.com/x" )
245248
246- @patch ("datascraper.ssrf_guard.safe_get" )
247- def test_route_guard_forwards_browser_headers_to_pinned_fetch (self , m_get ):
249+ @patch ("datascraper.ssrf_guard._PinnedSessionCache" )
250+ def test_route_guard_forwards_browser_headers_to_pinned_fetch (self , m_cache ):
251+ m_get = m_cache .return_value .fetch
248252 # The pinned fetch must present the browser's own User-Agent (the
249253 # context deliberately sets a Chrome UA to avoid bot-gating); SSRF
250254 # safety comes from IP-pinning, not from hiding the UA. Framing/encoding
@@ -264,15 +268,26 @@ def test_route_guard_forwards_browser_headers_to_pinned_fetch(self, m_get):
264268 self .assertNotIn ("host" , lowered )
265269 self .assertNotIn ("accept-encoding" , lowered )
266270
267- @patch ("datascraper.ssrf_guard.safe_get" )
268- def test_route_guard_aborts_non_get (self , m_get ):
269- # safe_get is GET-only; non-GET in-browser requests fail closed.
271+ @patch ("datascraper.ssrf_guard._PinnedSessionCache" )
272+ def test_route_guard_aborts_non_get (self , m_cache ):
273+ m_get = m_cache .return_value .fetch
274+ # cache.fetch is GET-only; non-GET in-browser requests fail closed.
270275 route = self ._route ("http://example.com/api" , method = "POST" )
271276 self ._drive (route )
272277 route .abort .assert_awaited_once ()
273278 route .fulfill .assert_not_awaited ()
274279 m_get .assert_not_called ()
275280
281+ @patch ("datascraper.ssrf_guard._PinnedSessionCache" )
282+ def test_route_guard_aborts_skipped_resource (self , m_cache ):
283+ # image/media/font are aborted before any fetch — they don't feed
284+ # inner_text, so we never spend DNS+TLS or egress on them.
285+ route = self ._route ("http://example.com/logo.png" , resource_type = "image" )
286+ self ._drive (route )
287+ route .abort .assert_awaited_once ()
288+ route .fulfill .assert_not_awaited ()
289+ m_cache .return_value .fetch .assert_not_called ()
290+
276291
277292class SyncRouteGuardTests (SimpleTestCase ):
278293 """install_route_guard_sync mirrors the async guard for the sync
@@ -287,10 +302,11 @@ def route(pattern, handler):
287302 page .route = route
288303 return page
289304
290- def _route (self , url , method = "GET" ):
305+ def _route (self , url , method = "GET" , resource_type = "document" ):
291306 route = MagicMock ()
292307 route .request .url = url
293308 route .request .method = method
309+ route .request .resource_type = resource_type
294310 route .request .headers = {
295311 "User-Agent" : "Mozilla/5.0 (Windows NT 10.0; Win64; x64) Chrome/120.0.0.0" ,
296312 "Accept-Encoding" : "gzip, deflate, br" ,
@@ -304,16 +320,18 @@ def _drive(self, route):
304320 ssrf_guard .install_route_guard_sync (page )
305321 captured ["handler" ](route )
306322
307- @patch ("datascraper.ssrf_guard.safe_get" )
308- def test_sync_guard_aborts_blocked_request (self , m_get ):
323+ @patch ("datascraper.ssrf_guard._PinnedSessionCache" )
324+ def test_sync_guard_aborts_blocked_request (self , m_cache ):
325+ m_get = m_cache .return_value .fetch
309326 m_get .side_effect = UnsafeURLError ("blocked" )
310327 route = self ._route ("http://evil.example.test/x" )
311328 self ._drive (route )
312329 route .abort .assert_called_once ()
313330 route .fulfill .assert_not_called ()
314331
315- @patch ("datascraper.ssrf_guard.safe_get" )
316- def test_sync_guard_fulfills_public_request (self , m_get ):
332+ @patch ("datascraper.ssrf_guard._PinnedSessionCache" )
333+ def test_sync_guard_fulfills_public_request (self , m_cache ):
334+ m_get = m_cache .return_value .fetch
317335 resp = _FakeResp (status_code = 200 , headers = {"Content-Type" : "text/html" })
318336 resp ._content = b"hi"
319337 m_get .return_value = resp
@@ -323,8 +341,9 @@ def test_sync_guard_fulfills_public_request(self, m_get):
323341 self .assertEqual (route .fulfill .call_args .kwargs ["body" ], b"hi" )
324342 route .abort .assert_not_called ()
325343
326- @patch ("datascraper.ssrf_guard.safe_get" )
327- def test_sync_guard_forwards_browser_user_agent (self , m_get ):
344+ @patch ("datascraper.ssrf_guard._PinnedSessionCache" )
345+ def test_sync_guard_forwards_browser_user_agent (self , m_cache ):
346+ m_get = m_cache .return_value .fetch
328347 resp = _FakeResp (status_code = 200 , headers = {"Content-Type" : "text/html" })
329348 resp ._content = b"hi"
330349 m_get .return_value = resp
@@ -337,13 +356,22 @@ def test_sync_guard_forwards_browser_user_agent(self, m_get):
337356 self .assertNotIn ("host" , lowered )
338357 self .assertNotIn ("accept-encoding" , lowered )
339358
340- @patch ("datascraper.ssrf_guard.safe_get" )
341- def test_sync_guard_aborts_non_get (self , m_get ):
359+ @patch ("datascraper.ssrf_guard._PinnedSessionCache" )
360+ def test_sync_guard_aborts_non_get (self , m_cache ):
361+ m_get = m_cache .return_value .fetch
342362 route = self ._route ("http://example.com/api" , method = "POST" )
343363 self ._drive (route )
344364 route .abort .assert_called_once ()
345365 m_get .assert_not_called ()
346366
367+ @patch ("datascraper.ssrf_guard._PinnedSessionCache" )
368+ def test_sync_guard_aborts_skipped_resource (self , m_cache ):
369+ route = self ._route ("http://example.com/font.woff2" , resource_type = "font" )
370+ self ._drive (route )
371+ route .abort .assert_called_once ()
372+ route .fulfill .assert_not_called ()
373+ m_cache .return_value .fetch .assert_not_called ()
374+
347375
348376class PinnedSessionCacheTests (SimpleTestCase ):
349377 """The per-page cache reuses validated DNS + keep-alive sessions per host,
0 commit comments