diff --git a/src/endpoints.py b/src/endpoints.py index dd9785a..65cddf2 100644 --- a/src/endpoints.py +++ b/src/endpoints.py @@ -92,10 +92,20 @@ async def read_item(request: LinkRequest, dep: BrowserDep) -> LinkResponse: page_html=page_html, ) + 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/src/utils.py b/src/utils.py index e34bca9..abc4ff2 100644 --- a/src/utils.py +++ b/src/utils.py @@ -96,6 +96,7 @@ async def get_browser( proxy=proxy_config, humanize=True, locale=BROWSER_LOCALE or "auto", + extra_prefs={"devtools.jsonview.enabled": False}, ) as browser_raw: # InvisiblePlaywright yields a Browser instance browser = cast("Browser", browser_raw) diff --git a/tests/main_test.py b/tests/main_test.py index 17d211b..b3136ac 100644 --- a/tests/main_test.py +++ b/tests/main_test.py @@ -59,6 +59,34 @@ def test_bypass(website: str): assert response.status_code == HTTPStatus.OK +def test_json_api(): + """JSON APIs must return 200, not crash on the UA evaluate. + + Firefox renders application/json in a built-in viewer whose CSP blocks + Playwright's eval-based evaluate() (issue #394). The browser must be + launched with the viewer disabled so /v1 works and returns the raw JSON. + """ + url = "https://api.ipify.org?format=json" + test_request = httpx2.get(url) + if test_request.status_code >= HTTPStatus.INTERNAL_SERVER_ERROR: + pytest.skip( + f"Skipping JSON API test - upstream error ({test_request.status_code})" + ) + + response = client.post( + "/v1", + json=LinkRequest.model_construct(url=url, cmd="request.get").model_dump(), + ) + + if response.status_code == HTTPStatus.REQUEST_TIMEOUT: + pytest.skip("Skipping JSON API test - timed out (upstream issue)") + + assert response.status_code == HTTPStatus.OK + solution = response.json()["solution"] + assert solution["userAgent"] + assert '"ip"' in solution["response"] + + def test_health_check(): """ Tests the health check endpoint. @@ -111,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" @@ -158,3 +188,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"