Ray: Dashboard DELETE endpoints allow unauthenticated browser-triggered DoS (Serve shutdown / job deletion)
Description
Ray is an AI compute engine. In versions 2.53.0 and below, thedashboard HTTP server blocks browser-origin POST/PUT but does not cover DELETE, and key DELETE endpoints are unauthenticated by default. If the dashboard/agent is reachable (e.g., --dashboard-host=0.0.0.0), a web page via DNS rebinding or same-network access can issue DELETE requests that shut down Serve or delete jobs without user interaction. This is a drive-by availability impact. The fix for this vulnerability is to update to Ray 2.54.0 or higher.
Affected packages
Versions sourced from the GitHub Security Advisory.
| Package | Affected versions | Patched versions |
|---|---|---|
rayPyPI | < 2.54.0 | 2.54.0 |
Affected products
1- Range: < 2.54.0
Patches
10fda8b824cdc[Core] Use whitelist approach to block mutation requests from browser (#60526)
6 files changed · +103 −62
python/ray/dashboard/http_server_agent.py+5 −1 modified@@ -105,7 +105,11 @@ async def start(self, modules: List) -> None: dashboard_optional_utils.DashboardAgentRouteTable.bind(c) app = aiohttp.web.Application( - middlewares=[get_token_auth_middleware(aiohttp, PUBLIC_EXACT_PATHS)] + middlewares=[ + get_token_auth_middleware(aiohttp, PUBLIC_EXACT_PATHS), + # Block all browser requests - agent is only accessed internally + dashboard_optional_utils.get_browser_request_middleware(aiohttp), + ] ) app.add_routes(routes=routes.bound_routes())
python/ray/dashboard/http_server_head.py+5 −39 modified@@ -8,7 +8,7 @@ import sys import time from math import floor -from typing import List, Set +from typing import List from packaging.version import Version @@ -282,37 +282,6 @@ async def path_clean_middleware(self, request, handler): raise aiohttp.web.HTTPForbidden() return await handler(request) - def get_browsers_no_post_put_middleware(self, whitelisted_paths: Set[str]): - """Create middleware that blocks POST/PUT requests from browsers. - - Args: - whitelisted_paths: Set of paths that are allowed to accept POST/PUT - from browsers (e.g., {"/api/authenticate"}) - - Returns: - An aiohttp middleware function - """ - - @aiohttp.web.middleware - async def browsers_no_post_put_middleware(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. - dashboard_optional_utils.is_browser_request(request) - and request.method in [hdrs.METH_POST, hdrs.METH_PUT] - ): - return aiohttp.web.Response( - status=405, text="Method Not Allowed for browser traffic." - ) - - return await handler(request) - - return browsers_no_post_put_middleware - @aiohttp.web.middleware async def metrics_middleware(self, request, handler): start_time = time.monotonic() @@ -379,11 +348,6 @@ async def run( } public_path_prefixes = ("/static/",) # Static assets (JS, CSS, images) - # Paths that are allowed to accept POST/PUT requests from browsers - browser_post_put_allowed_paths = { - "/api/authenticate", # Token authentication endpoint - } - # Http server should be initialized after all modules loaded. # working_dir uploads for job submission can be up to 100MiB. @@ -395,8 +359,10 @@ async def run( aiohttp, public_exact_paths, public_path_prefixes ), self.path_clean_middleware, - self.get_browsers_no_post_put_middleware( - browser_post_put_allowed_paths + dashboard_optional_utils.get_browser_request_middleware( + aiohttp, + allowed_methods={"GET", "HEAD", "OPTIONS"}, + allowed_paths=["/api/authenticate"], ), self.cache_control_static_middleware, ],
python/ray/dashboard/modules/job/job_agent.py+0 −2 modified@@ -30,7 +30,6 @@ def __init__(self, dashboard_agent): self._job_manager = None @routes.post("/api/job_agent/jobs/") - @optional_utils.deny_browser_requests() @optional_utils.init_ray_and_catch_exceptions() async def submit_job(self, req: Request) -> Response: result = await parse_and_validate_request(req, JobSubmitRequest) @@ -74,7 +73,6 @@ async def submit_job(self, req: Request) -> Response: ) @routes.post("/api/job_agent/jobs/{job_or_submission_id}/stop") - @optional_utils.deny_browser_requests() @optional_utils.init_ray_and_catch_exceptions() async def stop_job(self, req: Request) -> Response: job_or_submission_id = req.match_info["job_or_submission_id"]
python/ray/dashboard/modules/job/tests/test_job_agent.py+31 −4 modified@@ -172,8 +172,7 @@ def f(): yield { "runtime_env": {"py_modules": [str(Path(tmp_dir) / "test_module")]}, "entrypoint": ( - "python -c 'import test_module;" - "print(test_module.run_test())'" + "python -c 'import test_module;print(test_module.run_test())'" ), "expected_logs": "Hello from test_module!\n", } @@ -318,7 +317,35 @@ async def test_submit_job_rejects_browsers( with pytest.raises(RuntimeError) as exc: _ = await agent_client.submit_job_internal(request) - assert "status code 405" in str(exc.value) + assert "status code 403" in str(exc.value) + + +@pytest.mark.asyncio +async def test_delete_job_rejects_browsers(job_sdk_client, monkeypatch): + """Test that DELETE job requests from browsers are rejected.""" + monkeypatch.setenv("RAY_RUNTIME_ENV_LOCAL_DEV_MODE", "1") + + agent_client, head_client = job_sdk_client + + # First, submit a job using the normal client + runtime_env = RuntimeEnv().to_dict() + request = validate_request_type( + {"runtime_env": runtime_env, "entrypoint": "echo hello"}, + JobSubmitRequest, + ) + submit_result = await agent_client.submit_job_internal(request) + job_id = submit_result.submission_id + + # Now try to delete the job using browser-like headers + agent_address = agent_client._agent_address + browser_client = JobAgentSubmissionBrowserClient(agent_address) + + with pytest.raises(RuntimeError) as exc: + _ = await browser_client.delete_job_internal(job_id) + + assert "status code 403" in str(exc.value) + + await browser_client.close() @pytest.mark.asyncio @@ -609,7 +636,7 @@ async def test_non_default_dashboard_agent_http_port(tmp_path): import subprocess dashboard_agent_port = get_current_unused_port() - cmd = "ray start --head " f"--dashboard-agent-listen-port {dashboard_agent_port}" + cmd = f"ray start --head --dashboard-agent-listen-port {dashboard_agent_port}" subprocess.check_output(cmd, shell=True) try:
python/ray/dashboard/optional_utils.py+44 −14 modified@@ -2,6 +2,7 @@ Optional utils module contains utility methods that require optional dependencies. """ + import asyncio import collections import functools @@ -11,7 +12,8 @@ import time import traceback from collections import namedtuple -from typing import Callable, Union +from types import ModuleType +from typing import Callable, List, Optional, Set, Union from aiohttp.web import Request, Response @@ -159,23 +161,51 @@ def is_browser_request(req: Request) -> bool: ) -def deny_browser_requests() -> Callable: - """Reject any requests that appear to be made by a browser.""" +def get_browser_request_middleware( + aiohttp_module: ModuleType, + allowed_methods: Optional[Set[str]] = None, + allowed_paths: Optional[List[str]] = None, +): + """Create middleware that restricts browser access to specified HTTP methods. - def decorator_factory(f: Callable) -> Callable: - @functools.wraps(f) - async def decorator(self, req: Request): - if is_browser_request(req): - return Response( - text="Browser requests not allowed", - status=aiohttp.web.HTTPMethodNotAllowed.status_code, - ) + This middleware blocks browser requests to prevent DNS rebinding and CSRF + attacks. Only explicitly allowed methods are permitted from browsers. - return await f(self, req) + Args: + aiohttp_module: The aiohttp module to use + allowed_methods: Set of HTTP methods browsers are allowed to use. + allowed_paths: List of paths that bypass the method check entirely, + allowing any method from browsers. - return decorator + Returns: + An aiohttp middleware function + """ + allowed_methods = allowed_methods or set() + + @aiohttp_module.web.middleware + async def browser_request_middleware(request, handler): + if not is_browser_request(request): + return await handler(request) + + # Allow whitelisted paths to bypass the check + if allowed_paths and request.path in allowed_paths: + return await handler(request) + + if request.method not in allowed_methods: + # 403 if no methods allowed (all browsers blocked) + # 405 if some methods allowed but not this one + if allowed_methods: + return aiohttp_module.web.Response( + status=405, text="Method Not Allowed for browser traffic." + ) + else: + return aiohttp_module.web.Response( + status=403, text="Browser requests not allowed." + ) - return decorator_factory + return await handler(request) + + return browser_request_middleware def init_ray_and_catch_exceptions() -> Callable:
python/ray/dashboard/tests/test_dashboard.py+18 −2 modified@@ -416,7 +416,7 @@ def test_http_get(enable_test_module, ray_start_with_dashboard): os.environ.get("RAY_MINIMAL") == "1", reason="This test is not supposed to work for minimal installation.", ) -def test_browser_no_post_no_put(enable_test_module, ray_start_with_dashboard): +def test_browser_safe_methods_only(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) @@ -673,6 +673,23 @@ def dashboard_available(): with pytest.raises(HTTPError): response.raise_for_status() + # DELETE should be blocked for browsers + for testcase in testcases: + response = requests.delete( + webui_url + "/api/jobs/nonexistent-job-id", + headers=testcase, + ) + assert response.status_code == 405, "DELETE should be blocked for browsers" + + # PATCH should also be blocked for browsers + for testcase in testcases: + response = requests.patch( + webui_url + "/api/jobs/nonexistent-job-id", + headers=testcase, + json={}, + ) + assert response.status_code == 405, "PATCH should be blocked for browsers" + # Getting jobs should be fine for browsers response = requests.get(webui_url + "/api/jobs/") response.raise_for_status() @@ -1011,7 +1028,6 @@ def get_cluster_status(): indirect=True, ) def test_get_nodes_summary(call_ray_start): - # The sleep is needed since it seems a previous shutdown could be not yet # done when the next test starts. This prevents a previous cluster to be # connected the current test session.
Vulnerability mechanics
Generated by null/stub on May 9, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.
References
6- github.com/advisories/GHSA-q5fh-2hc8-f6rqghsaADVISORY
- nvd.nist.gov/vuln/detail/CVE-2026-27482ghsaADVISORY
- github.com/ray-project/ray/commit/0fda8b824cdc9dc6edd763bb28dfd7d1cc9b02a4ghsax_refsource_MISCWEB
- github.com/ray-project/ray/pull/60526ghsax_refsource_MISCWEB
- github.com/ray-project/ray/releases/tag/ray-2.54.0ghsax_refsource_MISCWEB
- github.com/ray-project/ray/security/advisories/GHSA-q5fh-2hc8-f6rqghsax_refsource_CONFIRMWEB
News mentions
0No linked articles in our index yet.