fix(media): narrow MIME_MAP to only .ts/.tsx text/plain, add regression tests
- Add only .ts→text/plain and .tsx→text/plain to MIME_MAP (exclude .js, .jsx, .css and other unsafe mappings that would make workspace files executable as same-origin scripts) - Add test_ts_artifact_served_as_text_plain_with_attachment — asserts Content-Type: text/plain, Content-Disposition: attachment, and X-Content-Type-Options: nosniff for .ts files via /api/media - Add test_tsx_artifact_served_as_text_plain_with_attachment — same for .tsx - Add test_file_raw_js_not_served_as_inline_executable — proves that a workspace .js file falls through to application/octet-stream instead of text/javascript via /api/file/raw Closes #6372
This commit is contained in:
parent
c0d9d81512
commit
700bd976c1
2 changed files with 125 additions and 0 deletions
|
|
@ -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) ─────────────────────────
|
||||
|
|
|
|||
|
|
@ -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 = () => <div>Hello</div>;\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")
|
||||
|
|
|
|||
Loading…
Reference in a new issue