diff --git a/api/config.py b/api/config.py index 015bb870..f0534811 100644 --- a/api/config.py +++ b/api/config.py @@ -1052,6 +1052,10 @@ MIME_MAP = { ".m4v": "video/mp4", ".webm": "video/webm", ".ogv": "video/ogg", + # TypeScript source files — served as text/plain to avoid XSS from + # same-origin inline execution in _handle_file_raw. + ".ts": "text/plain", + ".tsx": "text/plain", } # ── Toolsets (from config.yaml or hardcoded default) ───────────────────────── diff --git a/tests/test_media_inline.py b/tests/test_media_inline.py index 2c69ed0c..64f97cfc 100644 --- a/tests/test_media_inline.py +++ b/tests/test_media_inline.py @@ -941,6 +941,127 @@ class TestMediaEndpointIntegration(unittest.TestCase): finally: pathlib.Path(tmp_path).unlink(missing_ok=True) + def test_ts_artifact_served_as_text_plain_with_attachment(self): + """.ts file via /api/media must have text/plain Content-Type, + Content-Disposition: attachment, and X-Content-Type-Options: nosniff. + Regression for PR #6372 — ensures narrow MIME_MAP fix is live.""" + ts_bytes = b"const x: number = 42;\nconsole.log(x);\n" + with tempfile.NamedTemporaryFile( + suffix=".ts", prefix="hermes_test_", dir=_media_fixture_dir(), delete=False + ) as f: + f.write(ts_bytes) + tmp_path = f.name + try: + body, status, headers = self._get( + f"/api/media?path={urllib.parse.quote(tmp_path)}" + ) + self.assertEqual(status, 200) + ct = headers.get("Content-Type", "") + self.assertIn( + "text/plain", ct, + f"Expected text/plain Content-Type for .ts, got {ct}", + ) + self.assertNotIn( + "text/javascript", ct, + f".ts must NOT be served as text/javascript, got {ct}", + ) + disp = headers.get("Content-Disposition", "") + self.assertIn( + "attachment", disp, + f"Expected attachment Content-Disposition for .ts, got {disp}", + ) + self.assertEqual( + headers.get("X-Content-Type-Options"), + "nosniff", + "X-Content-Type-Options: nosniff must be set on media responses", + ) + self.assertEqual(body, ts_bytes) + finally: + pathlib.Path(tmp_path).unlink(missing_ok=True) + + def test_tsx_artifact_served_as_text_plain_with_attachment(self): + """.tsx file via /api/media must also have text/plain Content-Type + and attachment disposition. Regression for PR #6372.""" + tsx_bytes = b"const App: React.FC = () =>
Hello
;\n" + with tempfile.NamedTemporaryFile( + suffix=".tsx", prefix="hermes_test_", dir=_media_fixture_dir(), delete=False + ) as f: + f.write(tsx_bytes) + tmp_path = f.name + try: + body, status, headers = self._get( + f"/api/media?path={urllib.parse.quote(tmp_path)}" + ) + self.assertEqual(status, 200) + ct = headers.get("Content-Type", "") + self.assertIn( + "text/plain", ct, + f"Expected text/plain Content-Type for .tsx, got {ct}", + ) + self.assertNotIn( + "text/javascript", ct, + f".tsx must NOT be served as text/javascript, got {ct}", + ) + disp = headers.get("Content-Disposition", "") + self.assertIn( + "attachment", disp, + f"Expected attachment Content-Disposition for .tsx, got {disp}", + ) + self.assertEqual( + headers.get("X-Content-Type-Options"), + "nosniff", + ) + self.assertEqual(body, tsx_bytes) + finally: + pathlib.Path(tmp_path).unlink(missing_ok=True) + + def test_file_raw_js_not_served_as_inline_executable(self): + """.js files served via /api/file/raw must NOT get text/javascript + Content-Type — they should fall through to application/octet-stream + since MIME_MAP intentionally omits .js. Regression for PR #6372.""" + # Create a session so we can use /api/file/raw + try: + req = urllib.request.Request( + BASE + "/api/session/new", + data=b"{}", + headers={"Content-Type": "application/json"}, + ) + with urllib.request.urlopen(req, timeout=10) as r: + sess_data = json.loads(r.read()) + except urllib.error.HTTPError as e: + sess_data = json.loads(e.read()) + self.fail(f"Cannot create test session: {sess_data}") + sid = sess_data.get("session_id") or sess_data.get("session", {}).get("session_id", "") + self.assertTrue(sid, f"No session_id in response: {sess_data}") + ws = pathlib.Path( + sess_data.get("workspace") or sess_data.get("session", {}).get("workspace", "") + ) + self.assertTrue(str(ws), f"No workspace in response: {sess_data}") + + js_bytes = b"const x = 1;\n" + js_file = ws / "exploit_test.js" + try: + js_file.write_bytes(js_bytes) + + encoded = urllib.parse.quote("exploit_test.js") + body, status, headers = self._get( + f"/api/file/raw?session_id={sid}&path={encoded}" + ) + self.assertEqual(status, 200) + ct = headers.get("Content-Type", "") + self.assertNotIn( + "text/javascript", ct, + f".js via /api/file/raw must NOT be text/javascript, got {ct}", + ) + # Without a .js MIME_MAP entry, it falls back to application/octet-stream + self.assertEqual( + ct, "application/octet-stream", + f"Expected application/octet-stream for unmapped .js, got {ct}", + ) + self.assertEqual(body, js_bytes) + finally: + js_file.unlink(missing_ok=True) + def test_health_check_still_works(self): """Sanity: server is up and /health works.""" body, status, _ = self._get("/health")