From 99e23d83259e59f5c2c95abad38ad281a45f17d7 Mon Sep 17 00:00:00 2001 From: richo-anyscale Date: Fri, 14 Nov 2025 10:21:51 -0800 Subject: [PATCH 1/4] Add denial of fetch headers (#58553) This causes the dashboard to be more thorough in it's attempts to deny browsers access to the job creation APIs --------- Signed-off-by: Richo Healey Signed-off-by: Edward Oakes --- python/ray/dashboard/http_server_head.py | 9 ++- python/ray/dashboard/optional_utils.py | 18 ++++++ python/ray/dashboard/tests/test_dashboard.py | 59 +++++++++++++++++++- 3 files changed, 84 insertions(+), 2 deletions(-) diff --git a/python/ray/dashboard/http_server_head.py b/python/ray/dashboard/http_server_head.py index 49f748309271..59e7f1e6d197 100644 --- a/python/ray/dashboard/http_server_head.py +++ b/python/ray/dashboard/http_server_head.py @@ -182,10 +182,17 @@ async def path_clean_middleware(self, request, handler): @aiohttp.web.middleware async def browsers_no_post_put_middleware(self, request, handler): + # Allow whitelisted paths + if request.path in whitelisted_paths: + return await handler(request) + if ( # A best effort test for browser traffic. All common browsers # start with Mozilla at the time of writing. - dashboard_optional_utils.is_browser_request(request) + ( + dashboard_optional_utils.is_browser_request(request) + or dashboard_optional_utils.has_sec_fetch_headers(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 191f64f61c46..b59739fc825b 100644 --- a/python/ray/dashboard/optional_utils.py +++ b/python/ray/dashboard/optional_utils.py @@ -138,12 +138,30 @@ def is_browser_request(req: Request) -> bool: return req.headers["User-Agent"].startswith("Mozilla") +def has_sec_fetch_headers(req: Request) -> bool: + """Checks for the existance of any of the sec-fetch-* headers""" + return any( + h in req.headers + for h in ( + "Sec-Fetch-Mode", + "Sec-Fetch-Dest", + "Sec-Fetch-Site", + "Sec-Fetch-User", + ) + ) + + def deny_browser_requests() -> Callable: """Reject any requests that appear to be made by a browser""" 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 is_browser_request(req): return Response( text="Browser requests not allowed", diff --git a/python/ray/dashboard/tests/test_dashboard.py b/python/ray/dashboard/tests/test_dashboard.py index 3219606b96bc..7795fc97c386 100644 --- a/python/ray/dashboard/tests/test_dashboard.py +++ b/python/ray/dashboard/tests/test_dashboard.py @@ -419,10 +419,16 @@ def test_browser_no_post_no_put(enable_test_module, ray_start_with_dashboard): webui_url = ray_start_with_dashboard["webui_url"] webui_url = format_web_url(webui_url) + def dashboard_available(): + try: + return requests.get(webui_url).status_code == 200 + except Exception: + return False + timeout_seconds = 30 start_time = time.time() + wait_for_condition(dashboard_available) while True: - time.sleep(3) try: # Starting and getting jobs should be fine from API clients response = requests.post( @@ -458,6 +464,57 @@ def test_browser_no_post_no_put(enable_test_module, ray_start_with_dashboard): raise Exception("Timed out while testing.") +@pytest.mark.skipif( + os.environ.get("RAY_MINIMAL") == "1", + reason="This test is not supposed to work for minimal installation.", +) +def test_deny_fetch_requests(enable_test_module, ray_start_with_dashboard): + assert wait_until_server_available(ray_start_with_dashboard["webui_url"]) is True + webui_url = ray_start_with_dashboard["webui_url"] + webui_url = format_web_url(webui_url) + + def dashboard_available(): + try: + return requests.get(webui_url).status_code == 200 + 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() + + # 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.") + + @pytest.mark.skipif( os.environ.get("RAY_MINIMAL") == "1", reason="This test is not supposed to work for minimal installation.", From 45b0c01aa9309b790c60cebbff7be490b1d58de9 Mon Sep 17 00:00:00 2001 From: Edward Oakes Date: Mon, 17 Nov 2025 16:36:15 -0600 Subject: [PATCH 2/4] Cleanup dashboard test and header validation (#58648) Getting rid of the excessive `while True` loops & timeouts in the tests (we already wait for the dashboard to be up). Also just cleaned up some comments and naming while I was poking around. --------- Signed-off-by: Edward Oakes --- python/ray/dashboard/http_server_head.py | 9 +- python/ray/dashboard/optional_utils.py | 30 +++--- python/ray/dashboard/tests/test_dashboard.py | 106 ++++++++----------- 3 files changed, 57 insertions(+), 88 deletions(-) diff --git a/python/ray/dashboard/http_server_head.py b/python/ray/dashboard/http_server_head.py index 59e7f1e6d197..0cb09d8aa385 100644 --- a/python/ray/dashboard/http_server_head.py +++ b/python/ray/dashboard/http_server_head.py @@ -187,12 +187,9 @@ async def browsers_no_post_put_middleware(self, 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. - ( - dashboard_optional_utils.is_browser_request(request) - or dashboard_optional_utils.has_sec_fetch_headers(request) - ) + # 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] ): return aiohttp.web.Response( diff --git a/python/ray/dashboard/optional_utils.py b/python/ray/dashboard/optional_utils.py index b59739fc825b..93c131f5ac38 100644 --- a/python/ray/dashboard/optional_utils.py +++ b/python/ray/dashboard/optional_utils.py @@ -128,19 +128,17 @@ def _update_cache(task): def is_browser_request(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. + """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 the existance of any of the sec-fetch-* headers""" - return any( + return req.headers.get("User-Agent", "").startswith("Mozilla") or any( h in req.headers for h in ( "Sec-Fetch-Mode", @@ -152,21 +150,17 @@ 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) 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 is_browser_request(req): return Response( text="Browser requests not allowed", status=aiohttp.web.HTTPMethodNotAllowed.status_code, ) + return await f(self, req) return decorator diff --git a/python/ray/dashboard/tests/test_dashboard.py b/python/ray/dashboard/tests/test_dashboard.py index 7795fc97c386..07c5177cb12e 100644 --- a/python/ray/dashboard/tests/test_dashboard.py +++ b/python/ray/dashboard/tests/test_dashboard.py @@ -425,43 +425,32 @@ 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 +468,29 @@ 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 9ca58e704e7913128212affe0f1edda89265d459 Mon Sep 17 00:00:00 2001 From: richo-anyscale Date: Thu, 27 Nov 2025 15:02:14 -0500 Subject: [PATCH 3/4] [core] Test for more browser-specific headers in dashboard browser rejection logic (#59042) ## Description Adds more headers to the denylist for recognising browser requests and denying them ## Related issues Supercedes #59040 Signed-off-by: Richo Healey --- python/ray/dashboard/optional_utils.py | 12 +- python/ray/dashboard/tests/test_dashboard.py | 249 ++++++++++++++++++- 2 files changed, 247 insertions(+), 14 deletions(-) diff --git a/python/ray/dashboard/optional_utils.py b/python/ray/dashboard/optional_utils.py index 93c131f5ac38..a15d63573610 100644 --- a/python/ray/dashboard/optional_utils.py +++ b/python/ray/dashboard/optional_utils.py @@ -130,21 +130,31 @@ def _update_cache(task): def is_browser_request(req: Request) -> bool: """Best-effort detection if the request was made by a browser. - Uses two heuristics: + Uses three 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. + 3) If any of the various CORS headers are present """ return req.headers.get("User-Agent", "").startswith("Mozilla") or any( h in req.headers for h in ( + # Origin and Referer are sent by browser user agents to give + # information about the requesting origin + "Referer", + "Origin", + # Sec-Fetch headers are sent with many but not all `fetch` + # requests, and will eventually be sent on all requests. "Sec-Fetch-Mode", "Sec-Fetch-Dest", "Sec-Fetch-Site", "Sec-Fetch-User", + # CORS headers specifying which other headers are modified + "Access-Control-Request-Method", + "Access-Control-Request-Headers", ) ) diff --git a/python/ray/dashboard/tests/test_dashboard.py b/python/ray/dashboard/tests/test_dashboard.py index 07c5177cb12e..0ebfda1f2ad3 100644 --- a/python/ray/dashboard/tests/test_dashboard.py +++ b/python/ray/dashboard/tests/test_dashboard.py @@ -419,6 +419,234 @@ def test_browser_no_post_no_put(enable_test_module, ray_start_with_dashboard): webui_url = ray_start_with_dashboard["webui_url"] webui_url = format_web_url(webui_url) + testcases = ( + # chrome-invalid-tls.json + { + "Host": "localtest.me", + "Connection": "keep-alive", + "Content-Length": "0", + "Sec-Ch-Ua-Platform": '"macOS"', + "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/142.0.0.0 Safari/537.36", + "Sec-Ch-Ua": '"Chromium";v="142", "Google Chrome";v="142", "Not_A Brand";v="99"', + "Sec-Ch-Ua-Mobile": "?0", + "Accept": "*/*", + "Origin": "https://localtest.me", + "Sec-Fetch-Site": "same-origin", + "Sec-Fetch-Mode": "cors", + "Sec-Fetch-Dest": "empty", + "Referer": "https://localtest.me/", + "Accept-Encoding": "gzip, deflate, br, zstd", + "Accept-Language": "en-US,en;q=0.9", + }, + # chrome-localhost-notls.json + { + "Host": "localhost:5000", + "Connection": "keep-alive", + "Content-Length": "0", + "Sec-Ch-Ua-Platform": '"macOS"', + "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/142.0.0.0 Safari/537.36", + "Sec-Ch-Ua": '"Chromium";v="142", "Google Chrome";v="142", "Not_A Brand";v="99"', + "Sec-Ch-Ua-Mobile": "?0", + "Accept": "*/*", + "Origin": "http://localhost:5000", + "Sec-Fetch-Site": "same-origin", + "Sec-Fetch-Mode": "cors", + "Sec-Fetch-Dest": "empty", + "Referer": "http://localhost:5000/", + "Accept-Encoding": "gzip, deflate, br, zstd", + "Accept-Language": "en-US,en;q=0.9", + }, + # chrome-notlocalhost-notls.json + { + "Host": "localtest.me:5000", + "Connection": "keep-alive", + "Content-Length": "0", + "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/142.0.0.0 Safari/537.36", + "Accept": "*/*", + "Origin": "http://localtest.me:5000", + "Referer": "http://localtest.me:5000/", + "Accept-Encoding": "gzip, deflate", + "Accept-Language": "en-US,en;q=0.9", + }, + # chrome-notlocalhost-port80-notls.json + { + "Host": "localtest.me", + "Connection": "keep-alive", + "Content-Length": "0", + "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/142.0.0.0 Safari/537.36", + "Accept": "*/*", + "Origin": "http://localtest.me", + "Referer": "http://localtest.me/", + "Accept-Encoding": "gzip, deflate", + "Accept-Language": "en-US,en;q=0.9", + }, + # firefox-invalid-tls.json + { + "Host": "localtest.me", + "User-Agent": "Fake", + "Accept": "*/*", + "Accept-Language": "en-US,en;q=0.5", + "Accept-Encoding": "gzip, deflate, br, zstd", + "Referer": "https://localtest.me/", + "Origin": "https://localtest.me", + "Connection": "keep-alive", + "Sec-Fetch-Dest": "empty", + "Sec-Fetch-Mode": "cors", + "Sec-Fetch-Site": "same-origin", + "Priority": "u=0", + "Content-Length": "0", + }, + # firefox-localhost-notls.json + { + "Host": "localhost:5000", + "User-Agent": "Fake", + "Accept": "*/*", + "Accept-Language": "en-US,en;q=0.5", + "Accept-Encoding": "gzip, deflate, br, zstd", + "Referer": "http://localhost:5000/", + "Origin": "http://localhost:5000", + "Connection": "keep-alive", + "Sec-Fetch-Dest": "empty", + "Sec-Fetch-Mode": "cors", + "Sec-Fetch-Site": "same-origin", + "Priority": "u=0", + "Content-Length": "0", + }, + # firefox-notlocalhost-notls.json + { + "Host": "localtest.me:5000", + "User-Agent": "Fake", + "Accept": "*/*", + "Accept-Language": "en-US,en;q=0.5", + "Accept-Encoding": "gzip, deflate", + "Referer": "http://localtest.me:5000/", + "Origin": "http://localtest.me:5000", + "Connection": "keep-alive", + "Priority": "u=0", + "Content-Length": "0", + }, + # firefox-notlocalhost-port80-notls.json + { + "Host": "localtest.me", + "User-Agent": "Fake", + "Accept": "*/*", + "Accept-Language": "en-US,en;q=0.5", + "Accept-Encoding": "gzip, deflate", + "Referer": "http://localtest.me/", + "Origin": "http://localtest.me", + "Connection": "keep-alive", + "Priority": "u=0", + "Content-Length": "0", + }, + # safari-invalid-tls.json + { + "Host": "localtest.me", + "Accept": "*/*", + "Origin": "https://localtest.me", + "Sec-Fetch-Site": "same-origin", + "Sec-Fetch-Mode": "cors", + "User-Agent": "Fake", + "Referer": "https://localtest.me/", + "Sec-Fetch-Dest": "empty", + "Content-Length": "0", + "Accept-Language": "en-US,en;q=0.9", + "Priority": "u=3, i", + "Accept-Encoding": "gzip, deflate, br", + "Connection": "keep-alive", + }, + # safari-localhost-notls.json + { + "Host": "localhost:5000", + "Accept": "*/*", + "Origin": "http://localhost:5000", + "Sec-Fetch-Site": "same-origin", + "Sec-Fetch-Mode": "cors", + "User-Agent": "Fake", + "Referer": "http://localhost:5000/", + "Sec-Fetch-Dest": "empty", + "Content-Length": "0", + "Accept-Language": "en-US,en;q=0.9", + "Priority": "u=3, i", + "Accept-Encoding": "gzip, deflate", + "Connection": "keep-alive", + }, + # safari-notlocalhost-notls.json + { + "Host": "localtest.me:5000", + "User-Agent": "Fake", + "Accept": "*/*", + "Content-Length": "0", + "Referer": "http://localtest.me:5000/", + "Origin": "http://localtest.me:5000", + "Accept-Language": "en-US,en;q=0.9", + "Priority": "u=3, i", + "Accept-Encoding": "gzip, deflate", + "Connection": "keep-alive", + }, + # safari-notlocalhost-port80-notls.json + { + "Host": "localtest.me", + "User-Agent": "Fake", + "Accept": "*/*", + "Content-Length": "0", + "Referer": "http://localtest.me/", + "Origin": "http://localtest.me", + "Accept-Language": "en-US,en;q=0.9", + "Priority": "u=3, i", + "Accept-Encoding": "gzip, deflate", + "Connection": "keep-alive", + }, + # edge-valid-tls.json + { + "Content-Length": "0", + "Sec-Ch-Ua-Platform": '"Windows"', + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/142.0.0.0 Safari/537.36 Edg/142.0.0.0", + "Sec-Ch-Ua": '"Chromium";v="142", "Microsoft Edge";v="142", "Not_A Brand";v="99"', + "Sec-Ch-Ua-Mobile": "?0", + "Accept": "*/*", + "Origin": "https://testing-dark-field-5895.fly.dev", + "Sec-Fetch-Site": "same-origin", + "Sec-Fetch-Mode": "cors", + "Sec-Fetch-Dest": "empty", + "Referer": "https://testing-dark-field-5895.fly.dev/", + "Accept-Encoding": "gzip, deflate, br, zstd", + "Accept-Language": "en-US,en;q=0.9", + "Priority": "u=1, i", + "X-Request-Start": "t=1764259434792741", + "Host": "testing-dark-field-5895.fly.dev", + }, + # edge-notlocalhost-notls + { + "Host": "localtest.me:5000", + "Connection": "keep-alive", + "Content-Length": "0", + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/142.0.0.0 Safari/537.36 Edg/142.0.0.0", + "Accept": "*/*", + "Origin": "http://localtest.me:5000", + "Referer": "http://localtest.me:5000/", + "Accept-Encoding": "gzip, deflate", + "Accept-Language": "en-US,en;q=0.9", + }, + # edge-localhost-notls + { + "Host": "localhost:5000", + "Connection": "keep-alive", + "Content-Length": "0", + "Sec-Ch-Ua-Platform": '"Windows"', + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/142.0.0.0 Safari/537.36 Edg/142.0.0.0", + "Sec-Ch-Ua": '"Chromium";v="142", "Microsoft Edge";v="142", "Not_A Brand";v="99"', + "Sec-Ch-Ua-Mobile": "?0", + "Accept": "*/*", + "Origin": "http://localhost:5000", + "Sec-Fetch-Site": "same-origin", + "Sec-Fetch-Mode": "cors", + "Sec-Fetch-Dest": "empty", + "Referer": "http://localhost:5000/", + "Accept-Encoding": "gzip, deflate, br, zstd", + "Accept-Language": "en-US,en;q=0.9", + }, + ) + def dashboard_available(): try: return requests.get(webui_url).status_code == 200 @@ -434,19 +662,14 @@ def dashboard_available(): 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() + for testcase in testcases: + response = requests.post( + webui_url + "/api/jobs/", + json={"entrypoint": "ls"}, + headers=testcase, + ) + with pytest.raises(HTTPError): + response.raise_for_status() # Getting jobs should be fine for browsers response = requests.get(webui_url + "/api/jobs/") From 0389d57aa4914caeca41eba379d653ef4572a8c7 Mon Sep 17 00:00:00 2001 From: Edward Oakes Date: Thu, 27 Nov 2025 14:06:20 -0600 Subject: [PATCH 4/4] fix Signed-off-by: Edward Oakes --- python/ray/dashboard/http_server_head.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/python/ray/dashboard/http_server_head.py b/python/ray/dashboard/http_server_head.py index 0cb09d8aa385..ae617654cc82 100644 --- a/python/ray/dashboard/http_server_head.py +++ b/python/ray/dashboard/http_server_head.py @@ -182,10 +182,6 @@ async def path_clean_middleware(self, request, handler): @aiohttp.web.middleware async def browsers_no_post_put_middleware(self, request, handler): - # Allow whitelisted paths - if request.path in whitelisted_paths: - return await handler(request) - if ( # Deny mutating requests from browsers. # See `is_browser_request` for details of the check.