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"