fix(gateway): serve XML artifacts as attachments to block same-origin script (#5353)

* fix(gateway): serve XML artifacts as attachments to block same-origin script

GET /api/threads/{id}/artifacts/{path} forced only text/html,
application/xhtml+xml and image/svg+xml to download. Every other XML
document was served inline from the application origin: `.xml` guesses
to text/xml or application/xml depending on the host's mime.types, and
both fell through to the inline text branches. Browsers render any XML
MIME type as a document and run an XHTML-namespaced <script> inside it,
so a report.xml written by a prompt-injected agent and opened from a
chat link executed with the viewer's session: the HttpOnly access_token
rides same-origin fetches, and the double-submit csrf_token cookie is
JS-readable, so state-changing calls are reachable as well.

Treat HTML plus every WHATWG XML MIME type (text/xml, application/xml,
any +xml subtype) and text/xsl, which Blink also renders as XML, as
active content. A single helper owns the rule for both the regular-file
and the .skill-archive-member branches. The artifacts panel already
previews .xml as code through a ranged fetch, so preview and editing
keep working against the attachment response.

* docs(frontend): name XML among the artifacts the Gateway downloads

Review follow-up on #5353: resolveArtifactOpenURL's comment still named
only HTML/SVG as the active content the Gateway serves as a download.
XML documents now join that bucket, so the frontend note matches the
Gateway rule. Comment-only; no behavior change.
This commit is contained in:
Hyeonsang Cho 2026-09-12 10:15:51 +09:00 committed by GitHub
parent a2011996d8
commit 806a5bd427
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 131 additions and 11 deletions

View File

@ -1492,6 +1492,16 @@ This section accumulates work toward the **2.1.0** milestone
environment — inheriting the host ssh-agent socket lets sandboxed code environment — inheriting the host ssh-agent socket lets sandboxed code
sign and authenticate with every key the agent holds — unless a skill sign and authenticate with every key the agent holds — unless a skill
explicitly declares it via required-secrets. ([#5145]) explicitly declares it via required-secrets. ([#5145])
- **artifacts:** Serve XML artifacts as download attachments like HTML and
SVG. `GET /api/threads/{id}/artifacts/{path}` rendered `.xml`, `.xsl`, and
`.rdf` files — and `+xml` types such as `.rss` wherever the host MIME
database maps them — inline in the application origin, so an XML document
with an XHTML-namespaced `<script>`, written by a prompt-injected agent and
opened from a chat link, could call the API with the viewer's session.
Every XML MIME type (`text/xml`, `application/xml`, `text/xsl`, any `+xml`
subtype) is now treated as active content, including `.skill` archive
members; the artifacts panel keeps previewing XML through its ranged fetch.
([#5353])
### Documentation ### Documentation
@ -2724,3 +2734,4 @@ with **180 merged pull requests** since the first 2.0 milestone tag.
[#5287]: https://github.com/bytedance/deer-flow/pull/5287 [#5287]: https://github.com/bytedance/deer-flow/pull/5287
[#5321]: https://github.com/bytedance/deer-flow/pull/5321 [#5321]: https://github.com/bytedance/deer-flow/pull/5321
[#5338]: https://github.com/bytedance/deer-flow/pull/5338 [#5338]: https://github.com/bytedance/deer-flow/pull/5338
[#5353]: https://github.com/bytedance/deer-flow/pull/5353

View File

@ -938,6 +938,13 @@
- **沙箱:** 沙箱子进程环境会清除 `SSH_AUTH_SOCK`——继承宿主机 ssh-agent socket - **沙箱:** 沙箱子进程环境会清除 `SSH_AUTH_SOCK`——继承宿主机 ssh-agent socket
会让沙箱内代码用智能体持有的所有密钥签名与认证——除非技能通过 required-secrets 会让沙箱内代码用智能体持有的所有密钥签名与认证——除非技能通过 required-secrets
显式声明。([#5145]) 显式声明。([#5145])
- **Artifact** XML 产物现在与 HTML、SVG 一样以下载附件形式返回。
`GET /api/threads/{id}/artifacts/{path}` 此前会在应用源内联渲染 `.xml``.xsl`
`.rdf` 文件(以及宿主 MIME 数据库映射为 `+xml``.rss` 等类型),被 prompt
注入的智能体写出带 XHTML 命名空间 `<script>` 的 XML 后,用户从聊天链接打开即可
以其会话调用 API。所有 XML MIME 类型(`text/xml``application/xml``text/xsl`
及任意 `+xml` 子类型)现均视为主动内容,`.skill` 归档成员同样适用Artifact
面板仍通过 Range 请求预览 XML。([#5353])
### 文档 ### 文档
@ -2102,3 +2109,4 @@ DeerFlow 2.0 是围绕"超级智能体"框架的彻底重写,核心包含子
[#5287]: https://github.com/bytedance/deer-flow/pull/5287 [#5287]: https://github.com/bytedance/deer-flow/pull/5287
[#5321]: https://github.com/bytedance/deer-flow/pull/5321 [#5321]: https://github.com/bytedance/deer-flow/pull/5321
[#5338]: https://github.com/bytedance/deer-flow/pull/5338 [#5338]: https://github.com/bytedance/deer-flow/pull/5338
[#5353]: https://github.com/bytedance/deer-flow/pull/5353

View File

@ -1656,7 +1656,7 @@ blocking IO that may run on the backend event loop, prints a concise summary,
and writes complete JSON findings to `.deer-flow/blocking-io-findings.json`. and writes complete JSON findings to `.deer-flow/blocking-io-findings.json`.
The JSON includes compact review records with `priority`, `location`, The JSON includes compact review records with `priority`, `location`,
`blocking_call`, `event_loop_exposure`, `reason`, and `code`. `blocking_call`, `event_loop_exposure`, `reason`, and `code`.
Gateway artifact serving now forces active web content types (`text/html`, `application/xhtml+xml`, `image/svg+xml`) to download as attachments instead of inline rendering, reducing XSS risk for generated artifacts. Gateway artifact serving now forces active web content types (`text/html` and XML documents such as `.xml`, `.xhtml`, and `.svg`) to download as attachments instead of inline rendering, reducing XSS risk for generated artifacts.
Frontend route asset budgets can be checked with `cd frontend && pnpm Frontend route asset budgets can be checked with `cd frontend && pnpm
perf:check`. The command measures `/login` from a normal production build, then perf:check`. The command measures `/login` from a normal production build, then

View File

@ -62,7 +62,7 @@ owner-scoped assistant version selection remains enabled.
| **Memory** (`/api/memory`) | `GET /` - memory data; `POST /reload` - force reload; `GET /config` - config; `GET /status` - config + data | | **Memory** (`/api/memory`) | `GET /` - memory data; `POST /reload` - force reload; `GET /config` - config; `GET /status` - config + data |
| **Uploads** (`/api/threads/{id}/uploads`) | `POST /` - upload files (auto-converts PDF/PPT/Excel/Word); non-mounted sandbox sync uses a non-releasing request lease; `GET /list` - list; `DELETE /{filename}` - delete | | **Uploads** (`/api/threads/{id}/uploads`) | `POST /` - upload files (auto-converts PDF/PPT/Excel/Word); non-mounted sandbox sync uses a non-releasing request lease; `GET /list` - list; `DELETE /{filename}` - delete |
| **Threads** (`/api/threads/{id}`) | `DELETE /` - remove DeerFlow-managed local thread data after LangGraph thread deletion; `POST /branches` - branch a completed assistant turn with a replay checkpoint; inherited titles take next-free displayed sibling suffixes, including explicit/renamed ones, while explicit titles stay unchanged. Durable `branch` admission rejects races. Workspace files are not checkpointed, so the branch only best-effort copies the current workspace when branching from the **latest** turn (`workspace_clone_mode="current_thread_best_effort"`); branching from an older/historical turn skips the copy (`workspace_clone_mode="skipped_historical_turn"`) so the branch never inherits files that only exist in a later timeline. Thread-scoped runtime channels (`sandbox`, `thread_data`) are not copied onto the branch: the parent's `sandbox_id` binds path mappings and the release lifecycle to the parent's workspace, so the branch lazily acquires its own sandbox instead. Branch creation also seeds the new thread's run-event feed from the branch checkpoint's visible messages (`history_seed_mode` in the response): the thread feed reads run_events, not checkpoints, so without the seed the inherited history disappears from the UI after the branch's first run (#4380). Seeded rows are grouped into one synthetic run per inherited turn (`branch-seed-{thread_id}-{n}`, a new turn opening at every persisted human message, including an allowlisted hidden `ask_clarification` reply) because `run_id` is a turn identity to the feed's consumers, not a provenance tag: regenerating an inherited answer supersedes that row's whole `run_id` in `GET /messages/page`, so one shared id for the entire seed deleted the complete inherited history on a branch's first regenerate (#4458); `GET /goal`, `PUT /goal`, `DELETE /goal` - read, set, and clear the active thread goal; `POST /compact` - manually summarize older active context into `summary_text` and retain the recent message window, blocked while a run is in flight; unexpected failures are logged server-side and return a generic 500 detail | | **Threads** (`/api/threads/{id}`) | `DELETE /` - remove DeerFlow-managed local thread data after LangGraph thread deletion; `POST /branches` - branch a completed assistant turn with a replay checkpoint; inherited titles take next-free displayed sibling suffixes, including explicit/renamed ones, while explicit titles stay unchanged. Durable `branch` admission rejects races. Workspace files are not checkpointed, so the branch only best-effort copies the current workspace when branching from the **latest** turn (`workspace_clone_mode="current_thread_best_effort"`); branching from an older/historical turn skips the copy (`workspace_clone_mode="skipped_historical_turn"`) so the branch never inherits files that only exist in a later timeline. Thread-scoped runtime channels (`sandbox`, `thread_data`) are not copied onto the branch: the parent's `sandbox_id` binds path mappings and the release lifecycle to the parent's workspace, so the branch lazily acquires its own sandbox instead. Branch creation also seeds the new thread's run-event feed from the branch checkpoint's visible messages (`history_seed_mode` in the response): the thread feed reads run_events, not checkpoints, so without the seed the inherited history disappears from the UI after the branch's first run (#4380). Seeded rows are grouped into one synthetic run per inherited turn (`branch-seed-{thread_id}-{n}`, a new turn opening at every persisted human message, including an allowlisted hidden `ask_clarification` reply) because `run_id` is a turn identity to the feed's consumers, not a provenance tag: regenerating an inherited answer supersedes that row's whole `run_id` in `GET /messages/page`, so one shared id for the entire seed deleted the complete inherited history on a branch's first regenerate (#4458); `GET /goal`, `PUT /goal`, `DELETE /goal` - read, set, and clear the active thread goal; `POST /compact` - manually summarize older active context into `summary_text` and retain the recent message window, blocked while a run is in flight; unexpected failures are logged server-side and return a generic 500 detail |
| **Artifacts** (`/api/threads/{id}/artifacts`) | `GET /{path}` - stream regular text and binary artifacts with `FileResponse`, including byte-`Range` 206/416 behavior used by bounded text previews and media seeking; active content types (`text/html`, `application/xhtml+xml`, `image/svg+xml`) are always forced as download attachments to reduce XSS risk; `?download=true` still forces download for other file types. `PUT /{path}` atomically replaces an existing UTF-8 text file under `/mnt/user-data/outputs` when its expected SHA-256 still matches; active runs conflict, and non-mounted sandbox providers receive the same update under a request lease. The outputs-only rule is `path_utils.resolve_outputs_confined_path`, shared with IM-channel attachment delivery: it collapses `..` before the prefix check and re-checks the resolved host path against the resolved outputs root, since `resolve_thread_virtual_path` only confines to `user-data/`; a percent-encoded `..` or a symlink planted in `outputs/` must not reach a sibling `uploads/` file. Atomic replacement applies the existing POSIX permission handling when descriptor-based APIs are available and otherwise keeps the platform-native temporary-file permissions (Windows). | | **Artifacts** (`/api/threads/{id}/artifacts`) | `GET /{path}` - stream regular text and binary artifacts with `FileResponse`, including byte-`Range` 206/416 behavior used by bounded text previews and media seeking; active content (`text/html`, `text/xml`, `application/xml`, `text/xsl`, any `+xml` type such as XHTML/SVG; `.skill` members too) is always forced as a download attachment to reduce XSS risk; `?download=true` still forces download for other file types. `PUT /{path}` atomically replaces an existing UTF-8 text file under `/mnt/user-data/outputs` when its expected SHA-256 still matches; active runs conflict, and non-mounted sandbox providers receive the same update under a request lease. The outputs-only rule is `path_utils.resolve_outputs_confined_path`, shared with IM-channel attachment delivery: it collapses `..` before the prefix check and re-checks the resolved host path against the resolved outputs root, since `resolve_thread_virtual_path` only confines to `user-data/`; a percent-encoded `..` or a symlink planted in `outputs/` must not reach a sibling `uploads/` file. Atomic replacement applies the existing POSIX permission handling when descriptor-based APIs are available and otherwise keeps the platform-native temporary-file permissions (Windows). |
| **Suggestions** (`/api/suggestions`) | `GET /config` - returns global suggestions config boolean; `POST /threads/{id}/suggestions` - generate follow-up questions; rich list/block model content is normalized and inline reasoning (`<think>...</think>`, including unclosed/truncated blocks from reasoning models like MiniMax-M3) is stripped before JSON parsing | | **Suggestions** (`/api/suggestions`) | `GET /config` - returns global suggestions config boolean; `POST /threads/{id}/suggestions` - generate follow-up questions; rich list/block model content is normalized and inline reasoning (`<think>...</think>`, including unclosed/truncated blocks from reasoning models like MiniMax-M3) is stripped before JSON parsing |
| **Input Polish** (`/api/input-polish`) | `POST /` - rewrite a composer draft before it is sent. This is a short authenticated `runs:create` LLM request using `input_polish` config; it does not create a LangGraph run, persist a message, or modify thread state. Shares the non-graph one-shot LLM path (`deerflow.utils.oneshot_llm.run_oneshot_llm`) with the suggestions route so model build + Langfuse metadata + invoke stay in one place; validates the same stripped view of the draft it sends to the model, and preserves literal `<think>` substrings in the rewrite (`strip_think_blocks(truncate_unclosed=False)`) | | **Input Polish** (`/api/input-polish`) | `POST /` - rewrite a composer draft before it is sent. This is a short authenticated `runs:create` LLM request using `input_polish` config; it does not create a LangGraph run, persist a message, or modify thread state. Shares the non-graph one-shot LLM path (`deerflow.utils.oneshot_llm.run_oneshot_llm`) with the suggestions route so model build + Langfuse metadata + invoke stay in one place; validates the same stripped view of the draft it sends to the model, and preserves literal `<think>` substrings in the rewrite (`strip_think_blocks(truncate_unclosed=False)`) |
| **Thread Runs** (`/api/threads/{id}/runs`) | `POST /` - create background run; `POST /stream` - create + SSE stream; `POST /wait` - create + block. Before the first journaled run, seed an empty feed from a checkpoint so legacy checkpoint-only history keeps its order and visibility; skip absent checkpoints or populated feeds. `POST /regenerate/prepare` - prepare clean input + checkpoint metadata for regenerating the latest completed or interrupted assistant answer, carrying the latest non-empty thread title in graph input so resuming an older checkpoint cannot roll back a later manual rename (#4457); `POST /edit-regenerate/prepare` - prepare a checkpoint replay from the latest editable human turn with a replacement user message and edit replay metadata; it carries the current thread title the same way, but only when the replay base already has one — an untitled base belongs to a thread the title middleware has not named yet, so pinning the current title there would keep a name generated from the prompt the edit just replaced; `GET /` - newest 100 runs as an array; `GET /page` - keyset history page `{data, has_more, next_before_created_at, next_before_run_id}`; `GET /{rid}` - run details; `POST /{rid}/cancel` - cancel; `GET /{rid}/join` - join SSE; `GET /{rid}/stream` hides action/wait; GET action 405 pre-owner; POST needs `runs:cancel`; `GET /{rid}/messages` - paginated per-run messages `{data, has_more}`; `GET /{rid}/events` - full event stream; `GET /{rid}/workspace-changes` - workspace/output file change summary and optional diffs; `GET/POST /{rid}/artifacts/archive` - receipt manifest / bounded ZIP; `GET /../messages` - legacy thread message array; `GET /../messages/page` - backward thread-global `seq` history page with middleware/subagent-AI/successful-regenerate/edit-replay filtering and page-run-scoped feedback enrichment; subagent AI callbacks remain available through run events while parent `task` ToolMessages stay visible for card restoration; `GET /../token-usage` - aggregate tokens plus an optional `context_usage` percentage. Context usage approximately counts messages from the latest materialized thread state through `build_thread_checkpoint_state_accessor`, so full and delta checkpoint modes expose the same input. The percentage uses the latest run's model and its `context_window`. | | **Thread Runs** (`/api/threads/{id}/runs`) | `POST /` - create background run; `POST /stream` - create + SSE stream; `POST /wait` - create + block. Before the first journaled run, seed an empty feed from a checkpoint so legacy checkpoint-only history keeps its order and visibility; skip absent checkpoints or populated feeds. `POST /regenerate/prepare` - prepare clean input + checkpoint metadata for regenerating the latest completed or interrupted assistant answer, carrying the latest non-empty thread title in graph input so resuming an older checkpoint cannot roll back a later manual rename (#4457); `POST /edit-regenerate/prepare` - prepare a checkpoint replay from the latest editable human turn with a replacement user message and edit replay metadata; it carries the current thread title the same way, but only when the replay base already has one — an untitled base belongs to a thread the title middleware has not named yet, so pinning the current title there would keep a name generated from the prompt the edit just replaced; `GET /` - newest 100 runs as an array; `GET /page` - keyset history page `{data, has_more, next_before_created_at, next_before_run_id}`; `GET /{rid}` - run details; `POST /{rid}/cancel` - cancel; `GET /{rid}/join` - join SSE; `GET /{rid}/stream` hides action/wait; GET action 405 pre-owner; POST needs `runs:cancel`; `GET /{rid}/messages` - paginated per-run messages `{data, has_more}`; `GET /{rid}/events` - full event stream; `GET /{rid}/workspace-changes` - workspace/output file change summary and optional diffs; `GET/POST /{rid}/artifacts/archive` - receipt manifest / bounded ZIP; `GET /../messages` - legacy thread message array; `GET /../messages/page` - backward thread-global `seq` history page with middleware/subagent-AI/successful-regenerate/edit-replay filtering and page-run-scoped feedback enrichment; subagent AI callbacks remain available through run events while parent `task` ToolMessages stay visible for card restoration; `GET /../token-usage` - aggregate tokens plus an optional `context_usage` percentage. Context usage approximately counts messages from the latest materialized thread state through `build_thread_checkpoint_state_accessor`, so full and delta checkpoint modes expose the same input. The percentage uses the latest run's model and its `context_window`. |

View File

@ -31,10 +31,15 @@ logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api", tags=["artifacts"]) router = APIRouter(prefix="/api", tags=["artifacts"])
# Exact matches only; ``_is_active_content_mime_type`` also treats every
# ``+xml`` subtype as active content.
ACTIVE_CONTENT_MIME_TYPES = { ACTIVE_CONTENT_MIME_TYPES = {
"text/html", "text/html",
"application/xhtml+xml", "application/xhtml+xml",
"image/svg+xml", "image/svg+xml",
"text/xml",
"application/xml",
"text/xsl",
} }
MAX_SKILL_ARCHIVE_MEMBER_BYTES = 16 * 1024 * 1024 MAX_SKILL_ARCHIVE_MEMBER_BYTES = 16 * 1024 * 1024
@ -212,6 +217,21 @@ def _slice_byte_range(content: bytes, range_header: str | None) -> tuple[bytes,
return ranged_content, 206, headers return ranged_content, 206, headers
def _is_active_content_mime_type(mime_type: str | None) -> bool:
"""Return whether a browser can run script when rendering *mime_type* inline.
Beyond HTML, this covers every WHATWG XML MIME type (``text/xml``,
``application/xml``, or a ``+xml`` subtype) plus ``text/xsl``, which Blink
also renders as XML: any XML document can carry an XHTML-namespaced
``<script>``, so ``report.xml`` or ``feed.rss`` is as dangerous as
``page.html`` when opened in the application origin.
"""
if mime_type is None:
return False
mime_type = mime_type.lower()
return mime_type in ACTIVE_CONTENT_MIME_TYPES or mime_type.endswith("+xml")
def is_text_file_by_content(path: Path, sample_size: int = 8192) -> bool: def is_text_file_by_content(path: Path, sample_size: int = 8192) -> bool:
"""Check if file is text by examining content for null bytes.""" """Check if file is text by examining content for null bytes."""
try: try:
@ -307,7 +327,7 @@ def _read_artifact_payload(actual_path: Path, path: str, download: bool) -> tupl
raise HTTPException(status_code=400, detail=f"Path is not a file: {path}") raise HTTPException(status_code=400, detail=f"Path is not a file: {path}")
mime_type, _ = mimetypes.guess_type(actual_path) mime_type, _ = mimetypes.guess_type(actual_path)
# Active content / explicit download is streamed by FileResponse — no read here. # Active content / explicit download is streamed by FileResponse — no read here.
if download or mime_type in ACTIVE_CONTENT_MIME_TYPES: if download or _is_active_content_mime_type(mime_type):
return ("file", mime_type) return ("file", mime_type)
if mime_type and mime_type.startswith("text/"): if mime_type and mime_type.startswith("text/"):
return ("inline_file", mime_type) return ("inline_file", mime_type)
@ -361,7 +381,7 @@ async def get_artifact(thread_id: ThreadId, path: str, request: Request, downloa
Returns: Returns:
The file content as a FileResponse with appropriate content type: The file content as a FileResponse with appropriate content type:
- Active content (HTML/XHTML/SVG): Served as download attachment - Active content (HTML and XML documents, including XHTML/SVG): Served as download attachment
- Text files: Plain text with proper MIME type - Text files: Plain text with proper MIME type
- Binary files: Inline display with download option - Binary files: Inline display with download option
@ -373,13 +393,13 @@ async def get_artifact(thread_id: ThreadId, path: str, request: Request, downloa
Query Parameters: Query Parameters:
download (bool): If true, forces attachment download for file types that are download (bool): If true, forces attachment download for file types that are
otherwise returned inline or as plain text. Active HTML/XHTML/SVG content otherwise returned inline or as plain text. Active HTML/XML content
is always downloaded regardless of this flag. (including XHTML and SVG) is always downloaded regardless of this flag.
Example: Example:
- Get text file inline: `/api/threads/abc123/artifacts/mnt/user-data/outputs/notes.txt` - Get text file inline: `/api/threads/abc123/artifacts/mnt/user-data/outputs/notes.txt`
- Download file: `/api/threads/abc123/artifacts/mnt/user-data/outputs/data.csv?download=true` - Download file: `/api/threads/abc123/artifacts/mnt/user-data/outputs/data.csv?download=true`
- Active web content such as `.html`, `.xhtml`, and `.svg` artifacts is always downloaded - Active web content such as `.html`, `.xhtml`, `.svg`, and `.xml` artifacts is always downloaded
""" """
# Trusted internal callers may act on behalf of a thread's owner via the # Trusted internal callers may act on behalf of a thread's owner via the
# owner-user-id header (honored only after the internal token validates). # owner-user-id header (honored only after the internal token validates).
@ -407,7 +427,7 @@ async def get_artifact(thread_id: ThreadId, path: str, request: Request, downloa
# Add cache headers to avoid repeated ZIP extraction (cache for 5 minutes) # Add cache headers to avoid repeated ZIP extraction (cache for 5 minutes)
cache_headers = {"Cache-Control": "private, max-age=300"} cache_headers = {"Cache-Control": "private, max-age=300"}
download_name = Path(internal_path).name or actual_skill_path.stem download_name = Path(internal_path).name or actual_skill_path.stem
if download or mime_type in ACTIVE_CONTENT_MIME_TYPES: if download or _is_active_content_mime_type(mime_type):
return Response(content=content, media_type=mime_type or "application/octet-stream", headers=_build_attachment_headers(download_name, cache_headers)) return Response(content=content, media_type=mime_type or "application/octet-stream", headers=_build_attachment_headers(download_name, cache_headers))
# Archive members are already bounded during extraction. Preserve byte # Archive members are already bounded during extraction. Preserve byte

View File

@ -927,7 +927,7 @@ GET /api/threads/{thread_id}/artifacts/{path}
**Query Parameters:** **Query Parameters:**
- `download` (boolean): If `true`, force download with Content-Disposition header - `download` (boolean): If `true`, force download with Content-Disposition header
**Response:** File content with appropriate Content-Type **Response:** File content with appropriate Content-Type. HTML and XML documents (`.html`, `.xml`, `.xhtml`, `.svg`, and other `+xml` types) are always returned as attachments, regardless of `download`, so generated markup never renders in the application origin.
--- ---

View File

@ -18,10 +18,17 @@ from app.gateway.internal_auth import INTERNAL_OWNER_USER_ID_HEADER_NAME, INTERN
from deerflow.config.paths import Paths, make_safe_user_id from deerflow.config.paths import Paths, make_safe_user_id
from deerflow.sandbox.lease import get_sandbox_lease_manager from deerflow.sandbox.lease import get_sandbox_lease_manager
# Browsers render any XML MIME type as a document, so an XHTML-namespaced
# script in a plain .xml file runs in the application origin as well.
XHTML_SCRIPT_XML = '<?xml version="1.0"?><html xmlns="http://www.w3.org/1999/xhtml"><script>alert("xss")</script></html>'
ACTIVE_ARTIFACT_CASES = [ ACTIVE_ARTIFACT_CASES = [
("poc.html", "<html><body><script>alert('xss')</script></body></html>"), ("poc.html", "<html><body><script>alert('xss')</script></body></html>"),
("page.xhtml", '<?xml version="1.0"?><html xmlns="http://www.w3.org/1999/xhtml"><body>hello</body></html>'), ("page.xhtml", '<?xml version="1.0"?><html xmlns="http://www.w3.org/1999/xhtml"><body>hello</body></html>'),
("image.svg", '<svg xmlns="http://www.w3.org/2000/svg"><script>alert("xss")</script></svg>'), ("image.svg", '<svg xmlns="http://www.w3.org/2000/svg"><script>alert("xss")</script></svg>'),
("report.xml", XHTML_SCRIPT_XML),
("transform.xsl", XHTML_SCRIPT_XML),
("graph.rdf", XHTML_SCRIPT_XML),
] ]
@ -587,6 +594,79 @@ def test_get_artifact_forces_download_for_active_content_in_skill_archive(tmp_pa
assert bytes(response.body) == content.encode("utf-8") assert bytes(response.body) == content.encode("utf-8")
@pytest.mark.parametrize("in_skill_archive", [False, True])
def test_get_artifact_forces_download_for_any_xml_subtype(tmp_path, monkeypatch, in_skill_archive: bool) -> None:
# Whether .rss guesses to application/rss+xml depends on the host's
# mime.types file, so pin the guess to exercise the +xml rule on both paths.
content = '<?xml version="1.0"?><rss><x:script xmlns:x="http://www.w3.org/1999/xhtml">alert("xss")</x:script></rss>'
monkeypatch.setattr(artifacts_router.mimetypes, "guess_type", lambda *_args, **_kwargs: ("application/rss+xml", None))
if in_skill_archive:
artifact_path = tmp_path / "sample.skill"
with zipfile.ZipFile(artifact_path, "w") as zip_ref:
zip_ref.writestr("feed.rss", content)
path = "mnt/user-data/outputs/sample.skill/feed.rss"
else:
artifact_path = tmp_path / "feed.rss"
artifact_path.write_text(content, encoding="utf-8")
path = "mnt/user-data/outputs/feed.rss"
monkeypatch.setattr(artifacts_router, "resolve_thread_virtual_path", lambda _thread_id, _path, user_id=None: artifact_path)
response = asyncio.run(call_unwrapped(artifacts_router.get_artifact, "thread-1", path, _make_request()))
assert response.headers.get("content-disposition", "").startswith("attachment;")
@pytest.mark.parametrize(
"mime_type",
[
"text/html",
"application/xhtml+xml",
"image/svg+xml",
"text/xml",
"application/xml",
"text/xsl",
"application/rss+xml",
"application/atom+xml",
"application/xslt+xml",
"TEXT/XML",
],
)
def test_is_active_content_mime_type_covers_html_and_xml_documents(mime_type: str) -> None:
# Whether .rss or .atom guess to a +xml type depends on the host's
# mime.types file, so the classification is pinned on MIME types directly.
assert artifacts_router._is_active_content_mime_type(mime_type)
@pytest.mark.parametrize(
"mime_type",
[None, "text/plain", "text/markdown", "text/csv", "application/json", "application/pdf", "image/png", "application/xml-dtd"],
)
def test_is_active_content_mime_type_keeps_passive_types_inline(mime_type: str | None) -> None:
assert not artifacts_router._is_active_content_mime_type(mime_type)
def test_get_artifact_xml_download_supports_bounded_range_requests(tmp_path, monkeypatch) -> None:
# The artifacts panel previews .xml as code through a Range fetch, so
# forcing the attachment disposition must keep the bounded preview.
payload = ('<?xml version="1.0"?><items>' + "<item>0123456789</item>" * 50_000 + "</items>").encode()
artifact_path = tmp_path / "large.xml"
artifact_path.write_bytes(payload)
monkeypatch.setattr(artifacts_router, "resolve_thread_virtual_path", lambda _thread_id, _path, user_id=None: artifact_path)
app = make_authed_test_app()
app.include_router(artifacts_router.router)
with TestClient(app) as client:
preview = client.get(
"/api/threads/thread-1/artifacts/mnt/user-data/outputs/large.xml",
headers={"Range": "bytes=0-1048575"},
)
assert preview.status_code == 206
assert preview.content == payload[:1_048_576]
assert preview.headers["content-range"] == f"bytes 0-1048575/{len(payload)}"
assert preview.headers["content-disposition"].startswith("attachment;")
def test_get_artifact_download_false_does_not_force_attachment(tmp_path, monkeypatch) -> None: def test_get_artifact_download_false_does_not_force_attachment(tmp_path, monkeypatch) -> None:
artifact_path = tmp_path / "note.txt" artifact_path = tmp_path / "note.txt"
artifact_path.write_text("hello", encoding="utf-8") artifact_path.write_text("hello", encoding="utf-8")

View File

@ -34,8 +34,9 @@ export function resolveStoredArtifactLanguage(filepath: string) {
* Markdown and tabular files go to the in-app viewer route, which renders it with the same * Markdown and tabular files go to the in-app viewer route, which renders it with the same
* components as the panel instead of handing the browser a `text/markdown` * components as the panel instead of handing the browser a `text/markdown`
* response it can only show as raw source. Everything else keeps the raw * response it can only show as raw source. Everything else keeps the raw
* Gateway URL notably HTML/SVG, which the Gateway deliberately serves as a * Gateway URL notably HTML, SVG, and other XML documents, which the Gateway
* download so active content never executes in the application origin. * deliberately serves as a download so active content never executes in the
* application origin.
*/ */
export function resolveArtifactOpenURL({ export function resolveArtifactOpenURL({
filepath, filepath,