VYPR
Moderate severityNVD Advisory· Published Feb 21, 2026· Updated Feb 24, 2026

Ray: Dashboard DELETE endpoints allow unauthenticated browser-triggered DoS (Serve shutdown / job deletion)

CVE-2026-27482

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.

PackageAffected versionsPatched versions
rayPyPI
< 2.54.02.54.0

Affected products

1

Patches

1
0fda8b824cdc

[Core] Use whitelist approach to block mutation requests from browser (#60526)

https://github.com/ray-project/raySampan S NayakJan 27, 2026via ghsa
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

News mentions

0

No linked articles in our index yet.