From d3a828e8146b8594606dbe0e4989f20fc32a76e2 Mon Sep 17 00:00:00 2001 From: ThePhaseless Date: Tue, 11 Aug 2026 11:14:10 +0200 Subject: [PATCH] fix(v1): source User-Agent from request headers; evaluate only as fallback page.evaluate runs eval() in the page's main world, which fails with 'call to eval() blocked by CSP' under any CSP that disallows unsafe-eval - HTTP headers (already stripped), meta tags (not strippable), or internal viewer documents (#394). The navigation request already carries the UA the site actually saw, so take user_agent from page_request.request.headers and keep evaluate only as a best-effort fallback whose failure can no longer 500 the request. --- src/endpoints.py | 17 ++++++++++++++++- tests/main_test.py | 23 ++++++++++++++++++++++- 2 files changed, 38 insertions(+), 2 deletions(-) diff --git a/src/endpoints.py b/src/endpoints.py index 346b1b4..a44f6a5 100644 --- a/src/endpoints.py +++ b/src/endpoints.py @@ -153,10 +153,25 @@ async def read_item(request: LinkRequest, dep: BrowserDep) -> LinkResponse: else await dep.page.content() ) + # The User-Agent the target site actually saw, taken from the navigation + # request headers. page.evaluate falls through to eval() in the page's + # main world and fails whenever the page CSP disallows eval (e.g. the + # Firefox JSON viewer in #394, or uncatchable meta-tag CSP), so evaluate + # is only a fallback and its failure must not turn the request into a 500. + user_agent = ( + page_request.request.headers.get("user-agent") if page_request else None + ) + if user_agent is None: + try: + user_agent = await dep.page.evaluate("navigator.userAgent") + except Exception: + logger.warning("Could not determine User-Agent via page.evaluate") + user_agent = "" + return LinkResponse( message="Success", solution=Solution( - user_agent=await dep.page.evaluate("navigator.userAgent"), + user_agent=user_agent, url=final_url if final_url is not None else dep.page.url, status=status, cookies=cookies, diff --git a/tests/main_test.py b/tests/main_test.py index cc8a5dd..06bc993 100644 --- a/tests/main_test.py +++ b/tests/main_test.py @@ -139,7 +139,9 @@ def fake_dep(*, fail_states: set[str] | None = None) -> BrowserDepClass: page = AsyncMock() page.url = "https://example.test/login" page.goto.return_value = MagicMock( - status=HTTPStatus.OK, headers={"content-type": "text/html"} + status=HTTPStatus.OK, + headers={"content-type": "text/html"}, + request=MagicMock(headers={"user-agent": "UnitTestBrowser/1.0"}), ) page.title.return_value = "Login" page.evaluate.return_value = "UnitTestBrowser/1.0" @@ -181,3 +183,22 @@ async def test_domcontentloaded_timeout_returns_408(): ) assert exc.value.status_code == HTTPStatus.REQUEST_TIMEOUT + + +@pytest.mark.asyncio +async def test_user_agent_survives_csp_blocked_evaluate(): + """UA comes from request headers when page CSP blocks evaluate (#394). + + No CSP configuration (header, meta tag, or internal viewer document) may + turn /v1 into a 500. + """ + dep = fake_dep() + dep.page.evaluate.side_effect = Exception("call to eval() blocked by CSP") + + response = await read_item( + LinkRequest(url="https://example.test/login"), + dep, + ) + + assert response.status == "ok" + assert response.solution.user_agent == "UnitTestBrowser/1.0"