diff --git a/src/akkudoktoreos/server/eos.py b/src/akkudoktoreos/server/eos.py index d8e53d54..39c8768e 100755 --- a/src/akkudoktoreos/server/eos.py +++ b/src/akkudoktoreos/server/eos.py @@ -10,7 +10,7 @@ import traceback from contextlib import asynccontextmanager from enum import Enum from typing import Annotated, Any, AsyncGenerator, Dict, List, Optional, Union -from urllib.parse import urlsplit +from urllib.parse import urljoin, urlsplit import psutil import uvicorn @@ -2387,7 +2387,13 @@ def _sanitize_redirect_path(path: str) -> Optional[str]: depth += 1 if depth < 0: return None - return "/".join(parts) + safe_path = "/".join(parts) + # urljoin treats a colon in the first segment as a scheme, even without "://". + # Check after normalization, which can expose a scheme hidden by "./". + parsed = urlsplit(safe_path) + if parsed.scheme or parsed.netloc: + return None + return safe_path def _trusted_request_hosts() -> set[str]: @@ -2480,8 +2486,10 @@ def redirect(request: Request, path: str) -> Union[HTMLResponse, RedirectRespons if get_config().server.eosdash_port: if base_url is None: return _eosdash_unknown_page(request, 404) - safe_path = _sanitize_redirect_path(path) or "" - url = f"{base_url}/{safe_path}" + safe_path = _sanitize_redirect_path(path) + if safe_path is None: + return _eosdash_unknown_page(request, 404) + url = urljoin(f"{base_url.rstrip('/')}/", safe_path) return RedirectResponse(url=url, status_code=303) # Redirect the root URL to the site map diff --git a/tests/test_server.py b/tests/test_server.py index c35bd093..4cb7d1f5 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -464,6 +464,31 @@ class TestEosdashRedirect: assert response.status_code == HTTPStatus.SEE_OTHER assert response.headers["location"] == "http://localhost:8504/eosdash/health" + @pytest.mark.parametrize( + "path", + [ + "/eosdash:payload", + "/eosdash+custom:payload", + "/eosdash:%09payload", + "/eosdash:%0Apayload", + ], + ) + def test_scheme_bearing_path_is_rejected(self, client, path): + """User paths must not replace the configured dashboard URL with a scheme.""" + response = client.get(path, headers={"Host": "localhost:8503"}) + assert response.status_code == HTTPStatus.NOT_FOUND + assert "location" not in response.headers + + @pytest.mark.parametrize( + "path", + ["eosdash:payload", "./eosdash:payload", "eosdash:\tpayload", "eosdash:\npayload"], + ) + def test_sanitizer_rejects_schemes_after_normalization(self, path): + """Normalization must not turn an accepted path into a scheme-bearing URL.""" + from akkudoktoreos.server.eos import _sanitize_redirect_path + + assert _sanitize_redirect_path(path) is None + def test_unknown_path_error_page_links_to_request_host(self, client): """The 404 page links to EOSdash on the host the client used.""" response = client.get("/no-such-page", headers={"Host": "localhost:8503"})