Merge pull request #395 from ThePhaseless/fix/csp-json-viewer-eval

fix: /v1 must survive CSP-blocked evaluate (incl. Firefox JSON viewer)
This commit is contained in:
Jakub Orchowski
2026-08-11 11:57:47 +02:00
committed by GitHub
3 changed files with 62 additions and 2 deletions
+11 -1
View File
@@ -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,
+1
View File
@@ -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)
+50 -1
View File
@@ -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"