Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 5 additions & 3 deletions python/ray/dashboard/http_server_head.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
8 changes: 4 additions & 4 deletions python/ray/dashboard/optional_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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 (
Expand All @@ -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)
Expand All @@ -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,
Expand Down
106 changes: 42 additions & 64 deletions python/ray/dashboard/tests/test_dashboard.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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(
Expand Down