diff --git a/repeater/web/http_server.py b/repeater/web/http_server.py index 93c8e6e..337ca7a 100644 --- a/repeater/web/http_server.py +++ b/repeater/web/http_server.py @@ -286,7 +286,7 @@ class StatsApp: raise cherrypy.NotFound() root = Path(root_dir).resolve() target = (root.joinpath(*relative_parts)).resolve() - if not str(target).startswith(str(root)) or not target.is_file(): + if not target.is_relative_to(root) or not target.is_file(): raise cherrypy.NotFound() guessed_type, _ = mimetypes.guess_type(str(target)) cherrypy.response.headers["Content-Type"] = guessed_type or "application/octet-stream" diff --git a/tests/test_frontend_static_confinement.py b/tests/test_frontend_static_confinement.py new file mode 100644 index 0000000..547ca24 --- /dev/null +++ b/tests/test_frontend_static_confinement.py @@ -0,0 +1,81 @@ +"""Static frontend roots must use path components, not string prefixes.""" + +from http.client import HTTPConnection +from threading import Thread +from types import SimpleNamespace +from wsgiref.simple_server import make_server + +import cherrypy +import pytest + +from repeater.web.http_server import StatsApp + + +@pytest.mark.parametrize("directory", ["assets", "_next"]) +@pytest.mark.parametrize("symlink", [False, True]) +def test_static_rejects_prefix_sharing_sibling(tmp_path, monkeypatch, directory, symlink): + root = tmp_path / directory + root.mkdir() + private = tmp_path / (directory + "-private") + private.mkdir() + (private / "probe.txt").write_text("OUTSIDE-ROOT") + parts = ("..", private.name, "probe.txt") + if symlink: + (root / "linked").symlink_to(private, target_is_directory=True) + parts = ("linked", "probe.txt") + monkeypatch.setattr(cherrypy, "response", SimpleNamespace(headers={})) + app = object.__new__(StatsApp) + with pytest.raises(cherrypy.NotFound): + app._serve_static_file(str(root), parts) + + +def test_static_keeps_nested_assets(tmp_path, monkeypatch): + (tmp_path / "nested").mkdir() + (tmp_path / "nested" / "app.js").write_text("PUBLIC-SCRIPT") + monkeypatch.setattr(cherrypy, "response", SimpleNamespace(headers={})) + app = object.__new__(StatsApp) + assert app._serve_static_file(str(tmp_path), ("nested", "app.js")) == b"PUBLIC-SCRIPT" + + +@pytest.mark.parametrize("directory", ["assets", "_next"]) +def test_public_dispatch_rejects_encoded_sibling_paths(tmp_path, monkeypatch, directory): + root = tmp_path / directory + root.mkdir() + (root / "app.js").write_text("PUBLIC-SCRIPT") + private = tmp_path / (directory + "-private") + private.mkdir() + (private / "probe.txt").write_text("OUTSIDE-ROOT") + monkeypatch.setattr(cherrypy, "request", cherrypy._ThreadLocalProxy("request")) + monkeypatch.setattr(cherrypy, "response", cherrypy._ThreadLocalProxy("response")) + app = object.__new__(StatsApp) + app.html_dir = str(tmp_path) + monkeypatch.setattr(app, "_resolve_html_dir", lambda: app.html_dir) + application = cherrypy.Application(app, script_name="") + with make_server("127.0.0.1", 0, application) as server: + worker = Thread(target=server.serve_forever, daemon=True) + worker.start() + try: + for path in ( + f"/{directory}/../{directory}-private/probe.txt", + f"/{directory}/..%2f{directory}-private%2fprobe.txt", + f"/{directory}/%2e%2e/{directory}-private/probe.txt", + ): + connection = HTTPConnection("127.0.0.1", server.server_port, timeout=3) + try: + connection.request("GET", path) + response = connection.getresponse() + assert response.status == 404, path + assert b"OUTSIDE-ROOT" not in response.read() + finally: + connection.close() + connection = HTTPConnection("127.0.0.1", server.server_port, timeout=3) + try: + connection.request("GET", f"/{directory}/app.js") + response = connection.getresponse() + assert response.status == 200 + assert response.read() == b"PUBLIC-SCRIPT" + finally: + connection.close() + finally: + server.shutdown() + worker.join(timeout=3)