From e58742bc7af334c395396a9be458ac021e2a428f Mon Sep 17 00:00:00 2001 From: Edward Oakes Date: Fri, 14 Nov 2025 12:31:58 -0600 Subject: [PATCH 1/6] fix Signed-off-by: Edward Oakes --- python/ray/dashboard/http_server_head.py | 8 +- python/ray/dashboard/optional_utils.py | 4 +- python/ray/dashboard/tests/test_dashboard.py | 110 ++++++++----------- 3 files changed, 53 insertions(+), 69 deletions(-) diff --git a/python/ray/dashboard/http_server_head.py b/python/ray/dashboard/http_server_head.py index fd5ac1fb614b..fc966389a9eb 100644 --- a/python/ray/dashboard/http_server_head.py +++ b/python/ray/dashboard/http_server_head.py @@ -305,10 +305,12 @@ async def browsers_no_post_put_middleware(request, handler): return await handler(request) if ( - # A best effort test for browser traffic. All common browsers - # start with Mozilla at the time of writing. + # Deny mutating requests from browsers using two checks: + # - Matching against the user agent (at the time of writing, all + # browsers' user agents start with 'Mozilla') + # - Check for sec-fetch-* headers that are populated by browsers. ( - dashboard_optional_utils.is_browser_request(request) + dashboard_optional_utils.has_browser_user_agent(request) or dashboard_optional_utils.has_sec_fetch_headers(request) ) and request.method in [hdrs.METH_POST, hdrs.METH_PUT] diff --git a/python/ray/dashboard/optional_utils.py b/python/ray/dashboard/optional_utils.py index b59739fc825b..ad6edbc50b94 100644 --- a/python/ray/dashboard/optional_utils.py +++ b/python/ray/dashboard/optional_utils.py @@ -127,7 +127,7 @@ def _update_cache(task): return _wrapper -def is_browser_request(req: Request) -> bool: +def has_browser_user_agent(req: Request) -> bool: """Checks if a request is made by a browser like user agent. This heuristic is very weak, but hard for a browser to bypass- eg, @@ -139,7 +139,7 @@ def is_browser_request(req: Request) -> bool: def has_sec_fetch_headers(req: Request) -> bool: - """Checks for the existance of any of the sec-fetch-* headers""" + """Checks for any of the sec-fetch-* headers that are populated by browsers.""" return any( h in req.headers for h in ( diff --git a/python/ray/dashboard/tests/test_dashboard.py b/python/ray/dashboard/tests/test_dashboard.py index 7795fc97c386..3352c6b6e015 100644 --- a/python/ray/dashboard/tests/test_dashboard.py +++ b/python/ray/dashboard/tests/test_dashboard.py @@ -425,43 +425,34 @@ def dashboard_available(): except Exception: return False - timeout_seconds = 30 - start_time = time.time() wait_for_condition(dashboard_available) - while True: - try: - # Starting and getting jobs should be fine from API clients - response = requests.post( - webui_url + "/api/jobs/", json={"entrypoint": "ls"} - ) - response.raise_for_status() - response = requests.get(webui_url + "/api/jobs/") - response.raise_for_status() - # Starting job should be blocked for browsers - response = requests.post( - webui_url + "/api/jobs/", - json={"entrypoint": "ls"}, - headers={ - "User-Agent": ( - "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) " - "AppleWebKit/537.36 (KHTML, like Gecko) " - "Chrome/119.0.0.0 Safari/537.36" - ) - }, + # Starting and getting jobs should be fine from API clients + response = requests.post( + webui_url + "/api/jobs/", json={"entrypoint": "ls"} + ) + response.raise_for_status() + response = requests.get(webui_url + "/api/jobs/") + response.raise_for_status() + + # Starting job should be blocked for browsers + response = requests.post( + webui_url + "/api/jobs/", + json={"entrypoint": "ls"}, + headers={ + "User-Agent": ( + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) " + "AppleWebKit/537.36 (KHTML, like Gecko) " + "Chrome/119.0.0.0 Safari/537.36" ) - with pytest.raises(HTTPError): - response.raise_for_status() + }, + ) + with pytest.raises(HTTPError): + response.raise_for_status() - # Getting jobs should be fine for browsers - response = requests.get(webui_url + "/api/jobs/") - response.raise_for_status() - break - except (AssertionError, requests.exceptions.ConnectionError) as e: - logger.info("Retry because of %s", e) - finally: - if time.time() > start_time + timeout_seconds: - raise Exception("Timed out while testing.") + # Getting jobs should be fine for browsers + response = requests.get(webui_url + "/api/jobs/") + response.raise_for_status() @pytest.mark.skipif( @@ -479,40 +470,31 @@ def dashboard_available(): except Exception: return False - timeout_seconds = 30 - start_time = time.time() wait_for_condition(dashboard_available) - while True: - try: - # Starting and getting jobs should be fine from API clients - response = requests.post( - webui_url + "/api/jobs/", json={"entrypoint": "ls"} - ) - response.raise_for_status() - response = requests.get(webui_url + "/api/jobs/") - response.raise_for_status() - # Starting job should be blocked for browsers - response = requests.post( - webui_url + "/api/jobs/", - json={"entrypoint": "ls"}, - headers={ - "User-Agent": ("Spurious User Agent"), - "Sec-Fetch-Site": ("cross-site"), - }, - ) - with pytest.raises(HTTPError): - response.raise_for_status() + # Starting and getting jobs should be fine from API clients + response = requests.post( + webui_url + "/api/jobs/", json={"entrypoint": "ls"} + ) + response.raise_for_status() + response = requests.get(webui_url + "/api/jobs/") + response.raise_for_status() - # Getting jobs should be fine for browsers - response = requests.get(webui_url + "/api/jobs/") - response.raise_for_status() - break - except (AssertionError, requests.exceptions.ConnectionError) as e: - logger.info("Retry because of %s", e) - finally: - if time.time() > start_time + timeout_seconds: - raise Exception("Timed out while testing.") + # Starting job should be blocked for browsers + response = requests.post( + webui_url + "/api/jobs/", + json={"entrypoint": "ls"}, + headers={ + "User-Agent": ("Spurious User Agent"), + "Sec-Fetch-Site": ("cross-site"), + }, + ) + with pytest.raises(HTTPError): + response.raise_for_status() + + # Getting jobs should be fine for browsers + response = requests.get(webui_url + "/api/jobs/") + response.raise_for_status() @pytest.mark.skipif( From 3490a62c09b05a0b13a92609a991ec637c7d1458 Mon Sep 17 00:00:00 2001 From: Edward Oakes Date: Fri, 14 Nov 2025 12:32:47 -0600 Subject: [PATCH 2/6] fix Signed-off-by: Edward Oakes --- python/ray/dashboard/optional_utils.py | 4 ++-- python/ray/dashboard/tests/test_dashboard.py | 8 ++------ 2 files changed, 4 insertions(+), 8 deletions(-) diff --git a/python/ray/dashboard/optional_utils.py b/python/ray/dashboard/optional_utils.py index ad6edbc50b94..dba9bf3543bb 100644 --- a/python/ray/dashboard/optional_utils.py +++ b/python/ray/dashboard/optional_utils.py @@ -152,7 +152,7 @@ def has_sec_fetch_headers(req: Request) -> bool: def deny_browser_requests() -> Callable: - """Reject any requests that appear to be made by a browser""" + """Reject any requests that appear to be made by a browser.""" def decorator_factory(f: Callable) -> Callable: @functools.wraps(f) @@ -162,7 +162,7 @@ async def decorator(self, req: Request): text="Browser requests not allowed", status=aiohttp.web.HTTPMethodNotAllowed.status_code, ) - if is_browser_request(req): + if has_browser_user_agent(req): return Response( text="Browser requests not allowed", status=aiohttp.web.HTTPMethodNotAllowed.status_code, diff --git a/python/ray/dashboard/tests/test_dashboard.py b/python/ray/dashboard/tests/test_dashboard.py index 3352c6b6e015..07c5177cb12e 100644 --- a/python/ray/dashboard/tests/test_dashboard.py +++ b/python/ray/dashboard/tests/test_dashboard.py @@ -428,9 +428,7 @@ def dashboard_available(): wait_for_condition(dashboard_available) # Starting and getting jobs should be fine from API clients - response = requests.post( - webui_url + "/api/jobs/", json={"entrypoint": "ls"} - ) + response = requests.post(webui_url + "/api/jobs/", json={"entrypoint": "ls"}) response.raise_for_status() response = requests.get(webui_url + "/api/jobs/") response.raise_for_status() @@ -473,9 +471,7 @@ def dashboard_available(): wait_for_condition(dashboard_available) # Starting and getting jobs should be fine from API clients - response = requests.post( - webui_url + "/api/jobs/", json={"entrypoint": "ls"} - ) + response = requests.post(webui_url + "/api/jobs/", json={"entrypoint": "ls"}) response.raise_for_status() response = requests.get(webui_url + "/api/jobs/") response.raise_for_status() From 2c17b4613b2cf1873a536783dbc7c825dd29d717 Mon Sep 17 00:00:00 2001 From: Edward Oakes Date: Fri, 14 Nov 2025 15:10:02 -0600 Subject: [PATCH 3/6] fix Signed-off-by: Edward Oakes --- python/ray/dashboard/http_server_head.py | 10 ++----- python/ray/dashboard/optional_utils.py | 33 ++++++++++-------------- 2 files changed, 15 insertions(+), 28 deletions(-) diff --git a/python/ray/dashboard/http_server_head.py b/python/ray/dashboard/http_server_head.py index fc966389a9eb..7485cd9b9852 100644 --- a/python/ray/dashboard/http_server_head.py +++ b/python/ray/dashboard/http_server_head.py @@ -305,14 +305,8 @@ async def browsers_no_post_put_middleware(request, handler): return await handler(request) if ( - # Deny mutating requests from browsers using two checks: - # - Matching against the user agent (at the time of writing, all - # browsers' user agents start with 'Mozilla') - # - Check for sec-fetch-* headers that are populated by browsers. - ( - dashboard_optional_utils.has_browser_user_agent(request) - or dashboard_optional_utils.has_sec_fetch_headers(request) - ) + # Deny mutating requests from browsers. + dashboard_optional_utils.is_browser_request(request) and request.method in [hdrs.METH_POST, hdrs.METH_PUT] ): return aiohttp.web.Response( diff --git a/python/ray/dashboard/optional_utils.py b/python/ray/dashboard/optional_utils.py index dba9bf3543bb..5a1406a0d04e 100644 --- a/python/ray/dashboard/optional_utils.py +++ b/python/ray/dashboard/optional_utils.py @@ -126,21 +126,18 @@ def _update_cache(task): else: return _wrapper - -def has_browser_user_agent(req: Request) -> bool: - """Checks if a request is made by a browser like user agent. - - This heuristic is very weak, but hard for a browser to bypass- eg, - fetch/xhr and friends cannot alter the user-agent, but requests made with - an http library can stumble into this if they choose to user a browser like - user agent. +def is_browser_request(req: Request) -> bool: + """Best-effort detection if the request was made by a browser. + + Uses two heuristics: + 1) If the `User-Agent` header starts with 'Mozilla'. This heuristic is weak, + but hard for a browser to bypass e.g., fetch/xhr and friends cannot alter the + user agent, but requests made with an HTTP library can stumble into this if + they choose to user a browser-like user agent. At the time of writing, all + common browsers' user agents start with 'Mozilla'. + 2) If any of the `Sec-Fetch-*` headers are present. """ - return req.headers["User-Agent"].startswith("Mozilla") - - -def has_sec_fetch_headers(req: Request) -> bool: - """Checks for any of the sec-fetch-* headers that are populated by browsers.""" - return any( + return req.headers["User-Agent"].startswith("Mozilla") or any( h in req.headers for h in ( "Sec-Fetch-Mode", @@ -157,16 +154,12 @@ def deny_browser_requests() -> Callable: def decorator_factory(f: Callable) -> Callable: @functools.wraps(f) async def decorator(self, req: Request): - if has_sec_fetch_headers(req): - return Response( - text="Browser requests not allowed", - status=aiohttp.web.HTTPMethodNotAllowed.status_code, - ) - if has_browser_user_agent(req): + if is_browser_request(req): return Response( text="Browser requests not allowed", status=aiohttp.web.HTTPMethodNotAllowed.status_code, ) + return await f(self, req) return decorator From e4b8d0cbec9b040bd626e0793f5685562bd26e5a Mon Sep 17 00:00:00 2001 From: Edward Oakes Date: Fri, 14 Nov 2025 15:10:09 -0600 Subject: [PATCH 4/6] fix Signed-off-by: Edward Oakes --- python/ray/dashboard/optional_utils.py | 1 + 1 file changed, 1 insertion(+) diff --git a/python/ray/dashboard/optional_utils.py b/python/ray/dashboard/optional_utils.py index 5a1406a0d04e..d2deda96c5b6 100644 --- a/python/ray/dashboard/optional_utils.py +++ b/python/ray/dashboard/optional_utils.py @@ -126,6 +126,7 @@ def _update_cache(task): else: return _wrapper + def is_browser_request(req: Request) -> bool: """Best-effort detection if the request was made by a browser. From 7f3b617d7d17aae06377924063d7c7fc38daa4be Mon Sep 17 00:00:00 2001 From: Edward Oakes Date: Fri, 14 Nov 2025 15:11:24 -0600 Subject: [PATCH 5/6] fix Signed-off-by: Edward Oakes --- python/ray/dashboard/http_server_head.py | 1 + 1 file changed, 1 insertion(+) diff --git a/python/ray/dashboard/http_server_head.py b/python/ray/dashboard/http_server_head.py index 7485cd9b9852..39d8dcb8d6dd 100644 --- a/python/ray/dashboard/http_server_head.py +++ b/python/ray/dashboard/http_server_head.py @@ -306,6 +306,7 @@ async def browsers_no_post_put_middleware(request, handler): if ( # Deny mutating requests from browsers. + # See `is_browser_request` for details of the check. dashboard_optional_utils.is_browser_request(request) and request.method in [hdrs.METH_POST, hdrs.METH_PUT] ): From 5814e4502255e2061e19eb47ae7af66235ee7fe9 Mon Sep 17 00:00:00 2001 From: Edward Oakes Date: Fri, 14 Nov 2025 15:22:59 -0600 Subject: [PATCH 6/6] fix Signed-off-by: Edward Oakes --- python/ray/dashboard/optional_utils.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/python/ray/dashboard/optional_utils.py b/python/ray/dashboard/optional_utils.py index d2deda96c5b6..93c131f5ac38 100644 --- a/python/ray/dashboard/optional_utils.py +++ b/python/ray/dashboard/optional_utils.py @@ -138,7 +138,7 @@ def is_browser_request(req: Request) -> bool: common browsers' user agents start with 'Mozilla'. 2) If any of the `Sec-Fetch-*` headers are present. """ - return req.headers["User-Agent"].startswith("Mozilla") or any( + return req.headers.get("User-Agent", "").startswith("Mozilla") or any( h in req.headers for h in ( "Sec-Fetch-Mode",