VYPR
Medium severityNVD Advisory· Published May 13, 2026· Updated May 14, 2026

CVE-2026-44439

CVE-2026-44439

Description

PlaywrightCapture is a simple replacement for splash using playwright. Prior to 1.39.6, PlaywrightCapture did not sufficiently restrict navigations and resource requests initiated by rendered pages. An attacker-controlled page could abuse browser-side redirection mechanisms, such as window.location.href, to make the capture process open file:// URLs or request resources hosted on private, loopback, link-local, or otherwise non-public IP addresses. In deployments where PlaywrightCapture processes untrusted URLs, this could allow a remote attacker to perform server-side request forgery against internal services or attempt to access local files from the capture environment. Depending on what capture artifacts are generated and exposed, responses from those resources could potentially be leaked through screenshots, saved page content, logs, or other capture outputs. This vulnerability is fixed in 1.39.6.

Affected packages

Versions sourced from the GitHub Security Advisory.

PackageAffected versionsPatched versions
PlaywrightCapturePyPI
< 1.39.61.39.6

Affected products

1

Patches

1
49e289eba756

fix: Detect and block requests to internal ressources

https://github.com/Lookyloo/PlaywrightCaptureRaphaël VinotApr 29, 2026via ghsa
1 file changed · +63 1
  • playwrightcapture/capture.py+63 1 modified
    @@ -6,6 +6,7 @@
     import binascii
     # import hashlib
     import codecs
    +import ipaddress
     import logging
     import os
     import random
    @@ -34,6 +35,7 @@
     from playwright.async_api import TimeoutError as PlaywrightTimeoutError
     from playwright_stealth import Stealth, ALL_EVASIONS_DISABLED_KWARGS  # type: ignore[attr-defined]
     from pure_magic_rs import MagicDb
    +from pyfaup import Url
     from rfc3161_client import TimestampRequestBuilder, TimeStampRequest, HashAlgorithm
     from w3lib.html import strip_html5_whitespace
     from w3lib.url import canonicalize_url, safe_url_string
    @@ -160,19 +162,22 @@ class Capture():
         def __init__(self, loglevel: str | int='INFO', uuid: str | None=None, *,
                      capture_settings: CaptureSettings,
                      tt_settings: TrustedTimestampSettings | None=None,
    -                 env: dict[str, str | float | bool] | None=None):
    +                 env: dict[str, str | float | bool] | None=None,
    +                 only_global_lookup: bool=True):
             """Captures a page with Playwright.
     
             :param loglevel: Python loglevel
             :param uuid: The UUID of the capture.
             :param capture_settings: All the settings for the capture
             :param tt_settings: The trusted timestamp settings
             :param env: Optional env variables passed to the playwright process. For remote headfull captures, pass the DISPLAY env variable there
    +        :param only_global_lookup: If true, blocks all non-global lookups (local files, non-public IPs)
             """
             master_logger = logging.getLogger('playwrightcapture')
             master_logger.setLevel(loglevel)
             self.logger: Logger | PlaywrightCaptureLogAdapter
             self.uuid = uuid
    +        self.only_global_lookup = only_global_lookup
             if self.uuid is not None:
                 self.logger = PlaywrightCaptureLogAdapter(master_logger, {'uuid': self.uuid})
             else:
    @@ -263,6 +268,7 @@ async def __aenter__(self) -> Capture:
                 # add/override env variables
                 # NOTE: process.env in the playwright config means os.environ for python
                 launch_env = {**os.environ, **self._env}
    +
             self.browser = await self.playwright[self.browser_name].launch(
                 proxy=self.proxy if self.proxy else None,
                 channel="chromium" if self.browser_name == "chromium" else None,
    @@ -408,6 +414,8 @@ async def _override_content_disposition_handler(route: Route, request: Request)
                 await self.__dialog_tarteaucitron_clickthrough(page)
     
             page.set_default_timeout((self._capture_timeout - 2) * 1000)
    +        # for the navigation call, force them to be shorter.
    +        page.set_default_navigation_timeout((self._capture_timeout / 2) * 1000)
             # trigger a callback on each request to store it in a dict indexed by URL to get it back from the favicon fetcher
             self._store_request = store_request
     
    @@ -1278,6 +1286,60 @@ async def _finalize_capture(
                     self.logger.warning(f'Unable to get trusted timestamps: {e}')
     
         async def open_page(self, page: Page, url: str, errors: list[str], referer: str | None=None) -> None:
    +
    +        async def catch_file_route(route: Route, request: Request) -> None:
    +            if request.url == url:
    +                # the path we're trying to capture, it's fine
    +                await route.fallback()
    +            else:
    +                # block everything else
    +                self.logger.warning(f"Attempt to open a local file: {request.url}")
    +                await route.fulfill(status=404, content_type="text/plain", body=f"Attempted to open {request.url}, blocked.")
    +
    +        async def catch_local_route(route: Route, request: Request) -> None:
    +            if request.url == url:
    +                # the URL we want to capture, all good
    +                await route.fallback()
    +            else:
    +                # other URLs
    +                _url = Url(request.url)
    +                if not _url.host:
    +                    self.logger.warning(f"Missing Host: {request.url}")
    +                    return await route.fallback()
    +                try:
    +                    ip = ipaddress.ip_address(_url.host.try_into_ip())
    +                    if ip.is_global:
    +                        return await route.fallback()
    +                    # Non-global IP
    +                    self.logger.warning(f"Attempt to open a non-public IP: {request.url}")
    +                    return await route.fulfill(status=404, content_type="text/plain", body=f"Attempted to open {request.url}, blocked.")
    +                except ValueError:
    +                    # not an IP
    +                    pass
    +
    +                try:
    +                    hostname = _url.host.try_into_hostname()
    +                    if not hostname:
    +                        self.logger.warning(f"Missing hostname: {request.url}")
    +                        return await route.fallback()
    +                    if suffix := hostname.suffix:
    +                        if str(suffix) == 'local':
    +                            # Got a local domain
    +                            self.logger.warning(f"Attempt to open a local domain: {request.url}")
    +                            return await route.fulfill(status=404, content_type="text/plain", body=f"Attempted to open {request.url}, blocked.")
    +                    # NOTE: probably can add other things in there, but do not want to trigger a domain resolution.
    +                    return await route.fallback()
    +                except ValueError:
    +                    # not an hostname
    +                    pass
    +                # should not happen
    +                self.logger.warning(f"Opening a weird URL: {request.url}")
    +                return await route.fallback()
    +
    +        if self.only_global_lookup:
    +            await page.route("file://**/*", handler=catch_file_route)
    +            await page.route("**/*", handler=catch_local_route)
    +
             try:
                 await page.goto(url, wait_until='domcontentloaded', referer=referer if referer else '')
                 try:
    

Vulnerability mechanics

Generated by null/stub on May 9, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.

References

4

News mentions

0

No linked articles in our index yet.