diff --git a/CHANGELOG.md b/CHANGELOG.md index 6fa25babc70..2e26fa9254b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Fixed + +- **PR #2382** by @Michaelyklam (closes #2380) — `api/file/raw` now falls back to the requesting session's attachment inbox when a chat-upload filename is not present in the workspace. This keeps existing `api/file/raw?session_id=...&path=` image URLs working after uploads moved under `~/.hermes/webui/attachments//`, while preserving traversal protection and cross-session isolation. + ## [v0.51.74] — 2026-05-16 — Release AX (stage-367 — 4-PR safe-lane batch — #2362 table-cell spacing + #2363 run-state-consistency RFC + #2365 custom_providers list-format + #2367 settings sidebar i18n) ### Added diff --git a/api/routes.py b/api/routes.py index 3ad444e92c7..d66970589a1 100644 --- a/api/routes.py +++ b/api/routes.py @@ -6360,10 +6360,34 @@ def _handle_media(handler, parsed): or html_inline_ok ) ) else "attachment" + # _serve_file_bytes sends Content-Security-Policy when csp is set. csp = "sandbox allow-scripts" if html_inline_ok else None return _serve_file_bytes(handler, target, mime, disposition, "private, max-age=3600", csp=csp) +def _file_raw_target(session, sid: str, rel: str) -> Path | None: + """Resolve /api/file/raw paths from the workspace or this session's uploads.""" + try: + target = safe_resolve(Path(session.workspace), rel) + except ValueError: + target = None + if target and target.exists() and target.is_file(): + return target + + # Chat uploads now live in a per-session attachment inbox outside the + # workspace. Keep the public URL stable while scoping fallback lookup to + # the requesting session's own attachment directory. + try: + from api.upload import _session_attachment_dir + + attachment_target = safe_resolve(_session_attachment_dir(sid), rel) + except Exception: + return None + if attachment_target.exists() and attachment_target.is_file(): + return attachment_target + return None + + def _handle_file_raw(handler, parsed): qs = parse_qs(parsed.query) sid = qs.get("session_id", [""])[0] @@ -6375,8 +6399,8 @@ def _handle_file_raw(handler, parsed): return bad(handler, "Session not found", 404) rel = qs.get("path", [""])[0] force_download = qs.get("download", [""])[0] == "1" - target = safe_resolve(Path(s.workspace), rel) - if not target.exists() or not target.is_file(): + target = _file_raw_target(s, sid, rel) + if target is None: return j(handler, {"error": "not found"}, status=404) ext = target.suffix.lower() mime = MIME_MAP.get(ext, "application/octet-stream") diff --git a/tests/test_sprint2.py b/tests/test_sprint2.py index aa5c5f2aa9b..efb2085bbd7 100644 --- a/tests/test_sprint2.py +++ b/tests/test_sprint2.py @@ -67,7 +67,7 @@ def test_raw_endpoint_path_traversal_blocked(cleanup_test_sessions): get_raw(f"/api/file/raw?session_id={sid}&path=../../etc/passwd") assert False except urllib.error.HTTPError as e: - assert e.code in (400, 500) + assert e.code in (400, 404, 500) def test_raw_endpoint_missing_file_returns_404(cleanup_test_sessions): sid, _ = make_session_tracked(cleanup_test_sessions) diff --git a/tests/test_sprint6.py b/tests/test_sprint6.py index 6c2ba1df95c..fcfbbe1e7ff 100644 --- a/tests/test_sprint6.py +++ b/tests/test_sprint6.py @@ -1,5 +1,5 @@ """Sprint 6 tests: Escape from editor, Phase D validation, HTML extraction, cron create, session export.""" -import json, uuid, pathlib, urllib.request, urllib.error +import json, uuid, pathlib, urllib.parse, urllib.request, urllib.error REPO_ROOT = pathlib.Path(__file__).parent.parent.resolve() from tests._pytest_port import BASE @@ -74,6 +74,37 @@ def test_file_raw_unknown_session(): except urllib.error.HTTPError as e: assert e.code == 404 +def test_file_raw_serves_session_attachment_inbox(cleanup_test_sessions): + from api.upload import _session_attachment_dir + + sid, workspace = make_session_tracked(cleanup_test_sessions) + filename = f"uploaded-chat-image-{uuid.uuid4().hex}.png" + attachment_dir = _session_attachment_dir(sid) + attachment_dir.mkdir(parents=True, exist_ok=True) + payload = b"fake-png-bytes" + (attachment_dir / filename).write_bytes(payload) + + assert not (workspace / filename).exists(), "regression must exercise attachment fallback" + raw, headers, status = get_raw( + f"/api/file/raw?session_id={sid}&path={urllib.parse.quote(filename)}" + ) + assert status == 200 + assert raw == payload + assert "image/png" in headers.get("Content-Type", "") + +def test_file_raw_attachment_fallback_rejects_traversal(cleanup_test_sessions): + from api.upload import _session_attachment_dir + + sid, _ = make_session_tracked(cleanup_test_sessions) + attachment_dir = _session_attachment_dir(sid) + attachment_dir.mkdir(parents=True, exist_ok=True) + (attachment_dir / "safe.txt").write_text("safe", encoding="utf-8") + try: + get_raw(f"/api/file/raw?session_id={sid}&path={urllib.parse.quote('../../safe.txt')}") + assert False, "Expected 404" + except urllib.error.HTTPError as e: + assert e.code == 404 + # ── Cron create ── def test_cron_create_requires_prompt():