diff --git a/AGENTS.md b/AGENTS.md index 2fc89f9..7147f90 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -205,9 +205,9 @@ This file defines how coding agents should work in this repository. dashboard/static fallback. - Missing dashboard asset URLs, including raw, decoded, or repeatedly decoded `GET`/`HEAD` requests under `/assets/*`, dot-segment aliases that normalize - into `/assets`, and extension-bearing static paths, must return structured - JSON `404` errors instead of the dashboard HTML shell; `HEAD` responses must - not include a body. + into `/assets`, and paths with recognized static-asset extension components, + must return structured JSON `404` errors instead of asset bytes or the + dashboard HTML shell; `HEAD` responses must not include a body. - `HEAD` requests for GET-able dashboard/static paths such as `/` and concrete static assets must return the same success headers as `GET` with no body, while API/RPC `HEAD` requests keep the structured JSON 405/404 contract. diff --git a/docs/guides/mcp.md b/docs/guides/mcp.md index fae9802..896f587 100644 --- a/docs/guides/mcp.md +++ b/docs/guides/mcp.md @@ -20,9 +20,9 @@ Exact API collection routes such as `/api/gpus` and `/api/sessions` do not accept query strings or path parameters unless documented; query-shaped collection URLs return JSON `404` responses. Missing packaged asset URLs, including raw, encoded, or repeatedly encoded -`/assets/*` aliases and dot-segment aliases that normalize into the packaged -asset directory, also return JSON `404` responses instead of the dashboard -shell. +`/assets/*` aliases, dot-segment aliases that normalize into the packaged asset +directory, and paths with recognized static-asset extension components, also +return JSON `404` responses instead of asset bytes or the dashboard shell. ## Start service diff --git a/src/keep_gpu/mcp/server.py b/src/keep_gpu/mcp/server.py index 5eeb81e..1ec6742 100644 --- a/src/keep_gpu/mcp/server.py +++ b/src/keep_gpu/mcp/server.py @@ -79,6 +79,9 @@ STATIC_DIR = Path(__file__).resolve().parent / "static" MAX_JSON_BODY_BYTES = 1_000_000 ROUTE_PATH_DECODE_MAX_PASSES = 16 +STATIC_ASSET_SUFFIXES = frozenset( + {".css", ".html", ".ico", ".js", ".json", ".map", ".png", ".svg"} +) MCP_PROTOCOL_VERSION = "2025-06-18" JSONRPC_PARSE_ERROR = -32700 JSONRPC_INVALID_REQUEST = -32600 @@ -1348,6 +1351,17 @@ def _serve_static(self, request_path: str, write_body: bool = True) -> None: relative = request_path.lstrip("/") decoded_relative = unquote(relative) + decoded_components = [ + component for component in decoded_relative.split("/") if component + ] + has_dot_segment = any( + component in (".", "..") for component in decoded_components + ) + has_extension_component = any( + Path(component).suffix.lower() in STATIC_ASSET_SUFFIXES + for component in decoded_components + if component not in (".", "..") + ) requested = (STATIC_DIR / decoded_relative).resolve() static_root = STATIC_DIR.resolve() is_asset_route_alias = any( @@ -1374,13 +1388,28 @@ def _serve_static(self, request_path: str, write_body: bool = True) -> None: is_asset_prefix_request = ( decoded_relative == "assets" or decoded_relative.startswith("assets/") ) + is_canonical_asset_request = ( + is_asset_prefix_request + and (relative == "assets" or relative.startswith("assets/")) + and not has_dot_segment + ) + is_noncanonical_resolved_asset = ( + is_resolved_asset_request + and is_asset_route_alias + and not is_canonical_asset_request + ) + if is_noncanonical_resolved_asset: + self._json_response( + 404, + {"error": {"message": "Static asset not found"}}, + write_body=write_body, + ) + return is_asset_request = ( is_asset_prefix_request or is_resolved_asset_request or is_asset_route_alias - or ( - bool(Path(decoded_relative).suffix) and decoded_relative != "index.html" - ) + or (has_extension_component and decoded_relative != "index.html") ) if ( is_asset_prefix_request diff --git a/tests/mcp/test_http_api.py b/tests/mcp/test_http_api.py index a0aa797..bbe397d 100644 --- a/tests/mcp/test_http_api.py +++ b/tests/mcp/test_http_api.py @@ -1342,7 +1342,32 @@ class _Server(TCPServer): thread.join(timeout=2) -@pytest.mark.parametrize("path", ["/assets/missing.js", "/missing.keepgpu-test.js"]) +@pytest.mark.parametrize("path", ["/dashboard/settings", "/v1.0/dashboard"]) +def test_http_dashboard_client_routes_fall_back_to_static_index(path): + server = make_server() + httpd, thread, base = _start_http_server(server) + + try: + status, headers, body = _request_http_response("GET", f"{base}{path}") + finally: + httpd.shutdown() + httpd.server_close() + server.shutdown() + thread.join(timeout=2) + + assert status == 200 + assert headers.get_content_type() == "text/html" + assert b"KeepGPU Control Deck" in body + + +@pytest.mark.parametrize( + "path", + [ + "/assets/missing.js", + "/missing.keepgpu-test.js", + "/missing.keepgpu-test.js/extra", + ], +) def test_http_missing_static_asset_returns_404_not_dashboard_index(path): server = make_server() httpd, thread, base = _start_http_server(server) @@ -1390,7 +1415,10 @@ def test_http_normalized_asset_escape_returns_404_not_dashboard_index(target): } -@pytest.mark.parametrize("target", ["/foo/../assets/missing", "/foo/../assets"]) +@pytest.mark.parametrize( + "target", + ["/foo/../assets/missing", "/foo/../assets", "/foo/../assets/index.css"], +) def test_http_normalized_asset_alias_returns_404_not_dashboard_index(target): server = make_server() httpd, thread, _base = _start_http_server(server) @@ -1418,6 +1446,7 @@ def test_http_normalized_asset_alias_returns_404_not_dashboard_index(target): "/%252Fassets/missing", "/%2Fassets/index.css", "/%252Fassets/index.css", + "/assets%2Findex.css", ], ) def test_http_encoded_leading_slash_asset_alias_returns_404_not_dashboard_index( @@ -1442,7 +1471,14 @@ def test_http_encoded_leading_slash_asset_alias_returns_404_not_dashboard_index( } -@pytest.mark.parametrize("path", ["/assets/missing.js", "/missing.keepgpu-test.js"]) +@pytest.mark.parametrize( + "path", + [ + "/assets/missing.js", + "/missing.keepgpu-test.js", + "/missing.keepgpu-test.js/extra", + ], +) def test_http_head_missing_static_asset_returns_json_404_without_body(path): server = make_server() httpd, thread, base = _start_http_server(server) @@ -1485,7 +1521,10 @@ def test_http_head_normalized_asset_escape_returns_404_without_body(target): assert body == b"" -@pytest.mark.parametrize("target", ["/foo/../assets/missing", "/foo/../assets"]) +@pytest.mark.parametrize( + "target", + ["/foo/../assets/missing", "/foo/../assets", "/foo/../assets/index.css"], +) def test_http_head_normalized_asset_alias_returns_404_without_body(target): server = make_server() httpd, thread, _base = _start_http_server(server) @@ -1510,6 +1549,7 @@ def test_http_head_normalized_asset_alias_returns_404_without_body(target): "/%252Fassets/missing", "/%2Fassets/index.css", "/%252Fassets/index.css", + "/assets%2Findex.css", ], ) def test_http_head_encoded_leading_slash_asset_alias_returns_404_without_body(