Skip to content

Fix session-backed HTML media artifact previews - #5157

Closed
santastabber wants to merge 1 commit into
nesquena:masterfrom
santastabber:fix-media-html-artifact-preview
Closed

santastabber wants to merge 1 commit into
nesquena:masterfrom
santastabber:fix-media-html-artifact-preview

Conversation

@santastabber

Copy link
Copy Markdown
Contributor

Thinking Path

  • Hermes WebUI already supports sandboxed inline previews for local HTML artifacts through /api/media.
  • Session-scoped MEDIA: authorization only covered exact image paths, so assistant-emitted HTML/PDF artifacts outside the active workspace could fail even though they were present in the transcript.
  • The chat preview loaders also rebuilt /api/media URLs without carrying the active session_id, so the backend could not prove the artifact was session-emitted.
  • This PR keeps the existing deny-list and sandboxing model, but extends the session-token authorization path to safe preview MIME types and makes HTML/PDF preview requests session-aware.

What Changed

  • Generalized the session MEDIA-token helper from image-only to a safe MIME whitelist.
  • Kept the existing image-only wrapper for compatibility with existing callers/tests.
  • Added text/html, PDF, audio, and video preview MIME types to the session-emitted artifact allowlist.
  • Updated chat HTML/PDF lazy preview URLs to include the current session_id for fetch, open, and fallback download links.
  • Added focused regressions for:
    • exact assistant-emitted HTML MEDIA: paths outside the workspace;
    • user-authored MEDIA: paths staying unauthorized;
    • MIME allowlist enforcement;
    • frontend HTML/PDF preview fetches carrying session_id.

Why It Matters

HTML artifacts generated by an assistant can now open consistently from chat even when they are not under the active workspace, while still preserving the important security boundary:

  • user-authored MEDIA: paths cannot mint access;
  • unmentioned paths remain blocked;
  • Hermes state/secrets deny-list still runs before serving;
  • HTML continues to use the existing sandboxed preview path.

Verification

  • ./scripts/test.sh tests/test_media_inline.py tests/test_pdf_html_preview.py tests/test_issue1800_file_html_interactions.py -q
    • 115 passed
  • node --check static/ui.js
  • python3 -m py_compile api/routes.py
  • python3 scripts/ruff_lint.py --diff origin/master
    • no new violations on added/modified lines
  • git diff --check origin/master...HEAD

Risks / Follow-ups

  • This intentionally does not allow arbitrary local HTML browsing. The file still needs to be either under an allowed media root or exactly present in an assistant/tool MEDIA: token for the requested session.
  • SVG remains download-only.
  • The PR does not change workspace file browser behavior; it only fixes chat MEDIA: artifact preview authorization.

Model Used

OpenAI GPT-5.5 via OpenRouter, with Hermes Agent tool use for local implementation and verification.

@santastabber
santastabber marked this pull request as ready for review June 28, 2026 23:09
@nesquena-hermes nesquena-hermes added the size:M Medium PR (≤10 files, ≤250 LOC) label Jun 28, 2026
@greptile-apps

greptile-apps Bot commented Jun 28, 2026 •

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR extends the /api/media session-token authorization from image-only to a broader MIME whitelist (HTML, PDF, audio, video) and updates the loadHtmlInline/loadPdfInline frontend loaders to carry session_id in their fetch calls, enabling inline preview of assistant-generated artifacts stored outside the active workspace root.

  • Backend (api/routes.py): refactors _session_media_token_allows_image_path into a general _session_media_token_allows_path wrapper, introduces _AUDIO_VIDEO_PDF_TYPES as a shared local set used in both _SESSION_MEDIA_TOKEN_TYPES (authorization gate) and _INLINE_PREVIEW_TYPES (serving gate), and keeps text/html exclusive to the session-token set with separate CSP-sandbox handling.
  • Frontend (static/ui.js): both PDF and HTML lazy-loaders now read S.session.session_id and append &session_id=… to the fetch call URL (mediaUrl), while download/open-full-page links continue to use publicMediaUrl (no token) — which intentionally prevents session_id from appearing in navigable URLs.
  • Tests: four new unit tests cover exact-path HTML authorization, user-role rejection, MIME-allowlist enforcement, and an end-to-end _handle_media path for a session-authorized HTML file outside any allowed root.

Confidence Score: 5/5

Safe to merge. The session authorization extension is correctly scoped: MIME checks and the assistant-role guard are preserved, the deny-list runs before the allow check, and session_id is kept out of navigable URLs.

The authorization logic is sound — _session_media_token_allows_path gates on both MIME membership and an exact path match in assistant/tool messages, and the hard deny-list for Hermes state files still fires first. The frontend change is minimal and correct: session_id reaches the backend only through in-memory fetch calls, not through any URL the browser navigates to or that could appear in location.search. Existing role-restriction and path-traversal defenses are untouched.

No files require special attention.

Important Files Changed

Filename Overview
api/routes.py Generalizes session MEDIA-token helper; adds shared _AUDIO_VIDEO_PDF_TYPES set; deny-list and role checks are preserved; logic is correct.
static/ui.js loadHtmlInline and loadPdfInline now correctly attach session_id to fetch calls; download/open links use publicMediaUrl without the token; no new token-leakage surface introduced.
tests/test_media_inline.py New unit tests cover HTML authorization, user-role rejection, MIME enforcement, and end-to-end _handle_media path for session-only artifacts; existing tests updated to match renamed symbols.
tests/test_pdf_html_preview.py Adds structural tests verifying that session_id appears in fetch URLs and publicMediaUrl is used for open/download links; window-size bump for loadHtmlInline body scan is justified by new code.

Sequence Diagram

%%{init: {'theme': 'neutral'}}%%
sequenceDiagram
    participant UI as Browser (ui.js)
    participant API as /api/media (routes.py)
    participant FS as Filesystem

    UI->>UI: loadHtmlInline / loadPdfInline triggered
    UI->>UI: read S.session.session_id → mediaSessionId
    UI->>API: fetch(mediaUrl) [path + session_id]
    API->>API: parse session_id from QS
    API->>API: _session_media_token_allows_path(sid, target, _SESSION_MEDIA_TOKEN_TYPES)
    API->>API: get_session(sid) → scan assistant/tool messages for MEDIA: token
    alt token matches target and MIME allowed
        API->>FS: read file
        FS-->>API: bytes
        API-->>UI: 200 OK
    else no match
        API-->>UI: 403 Forbidden
    end
    UI->>UI: embed content via srcdoc (HTML) or pdfjsLib (PDF)
    Note over UI: openUrl/dlUrl use publicMediaUrl (no session_id)
Loading
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
sequenceDiagram
    participant UI as Browser (ui.js)
    participant API as /api/media (routes.py)
    participant FS as Filesystem

    UI->>UI: loadHtmlInline / loadPdfInline triggered
    UI->>UI: read S.session.session_id → mediaSessionId
    UI->>API: fetch(mediaUrl) [path + session_id]
    API->>API: parse session_id from QS
    API->>API: _session_media_token_allows_path(sid, target, _SESSION_MEDIA_TOKEN_TYPES)
    API->>API: get_session(sid) → scan assistant/tool messages for MEDIA: token
    alt token matches target and MIME allowed
        API->>FS: read file
        FS-->>API: bytes
        API-->>UI: 200 OK
    else no match
        API-->>UI: 403 Forbidden
    end
    UI->>UI: embed content via srcdoc (HTML) or pdfjsLib (PDF)
    Note over UI: openUrl/dlUrl use publicMediaUrl (no session_id)
Loading

Reviews (2): Last reviewed commit: "fix: authorize session html media artifa..." | Re-trigger Greptile

Comment thread static/ui.js Outdated
Comment thread api/routes.py Outdated
@nesquena-hermes

Copy link
Copy Markdown
Collaborator

Triage review — focused on whether widening the session-token MIME allowlist keeps the /api/media security boundary intact.

Summary

Reading api/routes.py:15084-15123 (the renamed _session_media_token_allows_path) and the _handle_media flow at :15218-15402 on the PR HEAD vs origin/master, the widening is sound. The change generalizes the image-only token check to a _SESSION_MEDIA_TOKEN_TYPES set (adds audio/video/PDF/text/html) while keeping every existing guard ahead of the serve decision. The three properties that matter — assistant-only token minting, deny-before-serve ordering, and HTML sandboxing — all still hold.

Code reference

The token matcher still refuses user-authored MEDIA tokens, so a user can't mint access to an arbitrary path (api/routes.py:15104-15123):

role = str(message.get("role") or "").strip().lower()
if role == "user":
    continue
...
for ref in _MEDIA_TOKEN_RE.findall(text):
    if "://" in ref:
        continue
    if Path(ref).expanduser().resolve() == target_resolved:
        return True

Critically, the #3234 state/secret deny-list still runs before session_media_allowed is consulted. The deny returns 403 at :15381 and :15398, and only then does the allow gate apply at :15401:

if any(_within_ci(target, d) for d in _deny_dirs):
    return bad(handler, "Path not in allowed location", 403)
...
if not within_allowed and not session_media_allowed:
    return bad(handler, "Path not in allowed location", 403)

So even though a session token can now authorize text/html, an assistant-emitted MEDIA: pointing at state.db / auth.json / config.yaml / a sibling profile's sessions/ is still 403'd by the deny pass that executes first. Good — the new MIME types don't widen which paths are reachable, only which already-token-authorized paths render inline.

HTML inline path

The inline-disposition logic at :15421-15431 keeps HTML behind both an explicit ?inline=1 and a CSP sandbox:

html_inline_ok = inline_preview and mime == "text/html"
...
csp = "sandbox allow-scripts" if html_inline_ok else None
return _serve_file_bytes(handler, target, mime, disposition, "private, max-age=3600", csp=csp)

sandbox allow-scripts (no allow-same-origin) means the previewed HTML runs scripts in an opaque origin and can't read the WebUI's cookies/localStorage or call same-origin APIs. SVG stays in _DOWNLOAD_TYPES (forced attachment). That's the right posture for assistant-generated HTML.

One nit

_SESSION_MEDIA_TOKEN_TYPES (:15218) is a superset of _INLINE_PREVIEW_TYPES (:15414) — the token-auth set additionally allows text/html, while the inline-serve set deliberately excludes it (HTML gets the separate html_inline_ok path). That divergence is intentional but easy to misread on a later edit; a one-line comment noting "token-auth allows html; inline-serve gates it separately via html_inline_ok" would save a future reviewer a double-take. Functionally correct as-is.

CI is green across 3.11/3.12/3.13 incl. browser-smoke, and the added regressions cover the assistant-vs-user minting distinction and the session_id-carrying preview fetch. No blocking concerns from a security-boundary standpoint.

@nesquena-hermes

Copy link
Copy Markdown
Collaborator

🔬 Gate certification — GREEN ✅ (one low-severity hardening follow-up, non-blocking)

Certified head: 17a93388 · PR: #5157 · santastabber — Fix session-backed HTML media artifact previews
Verdict: Full authoritative gate passes. Auth fails closed, HTML render is sandboxed on both paths, full suite green, live HTTP boundary test passes. Ready to merge. One low-severity, non-blocking hardening item is documented below for a follow-up (do not apply Codex's literal fix — it would break the feature).

What I ran (isolated worktree /tmp/wt-gate-5157 @ 17a93388, gate port 8783 — not prod)

Gate Result
Full pytest suite 10994 passed, 10 skipped, 1 xfailed, 2 xpassed, 2 failed (test_skills_stats_cache, test_git_stage_unstage_discard_and_commit) — both in files this PR does not touch, both pass in isolation = pre-existing order-dependent flakes, not a regression. CI is authoritative: 11/11 shards + browser-smoke + lint all green.
Codex (reproduce) "SHIP ONLY WITH FIXES" — flagged session_id reaching the "Open full page" URL. Reproduced as real but low-severity (see adjudication).
Opus (full review) "✅ APPROVE — correct, safe, ready to merge." session_id is an identifier not a credential; Codex's proposed fix would break the out-of-roots open-full-page path.
Live HTTP boundary test (running server, artifact genuinely outside all roots) PASS 4/4: assistant-emitted HTML + session_id + inline → 200 text/html with Content-Security-Policy: sandbox allow-scripts; no session_id → 403; user-authored MEDIA token → 403; secret path (state.db) → 403 even with a valid session grant.
node -c static/ui.js PARSE OK

Findings

✅ Auth boundary fails closed (verified at code + live HTTP). _session_media_token_allows_path returns False on every branch: empty sid, mime-not-in-allowlist, resolve exception, get_session exception, role=="user" messages skipped (user-authored tokens cannot mint a grant), :// refs skipped, and exact resolved-path equality (Path(ref).resolve() == target_resolved) — no prefix/startswith/commonpath, so no traversal escape. The #3234 secret/state hard-deny runs before the session-grant decision, so a grant can never reach sessions//memories//secrets/state.db (live-confirmed: case D → 403).

✅ XSS-safe on both render paths. (a) Chat inline = <iframe srcdoc=… sandbox="allow-scripts"> (no allow-same-origin → opaque origin, can't touch parent DOM/cookies/localStorage); srcdoc escaping ordered &→"→<→> so no srcdoc="…" breakout. (b) Direct serve = Content-Security-Policy: sandbox allow-scripts + X-Content-Type-Options: nosniff. Invariant holds: text/html serves inline iff the sandbox CSP is set — there is no code path that serves HTML inline without it (live-confirmed: case A carried the CSP header).

✅ No new cross-session/cross-profile exposure. Single-user auth principal; session_id only selects which session's emitted tokens to match. The widening newly reaches only assistant/tool-emitted audio/video/pdf/html artifacts outside the standard roots — the PR's intent.

⚠️ Low-severity hardening follow-up (NON-BLOCKING — do not block merge on this). Codex correctly observed that the "Open full page ↗" link and the too-large fallback build openUrl = mediaUrl + '&inline=1', where mediaUrl carries &session_id=<sid> (static/ui.js:14702, 14706). Clicking it navigates a tab to a sandbox allow-scripts (opaque-origin, scriptable) HTML page whose window.location.search contains the session_id, readable by the served (tool-authored) HTML.

  • Why it is not ship-blocking: session_id is the conversation identifier, not a credential — it already appears in multiple client URLs, and every endpoint is gated by the independent auth layer (is_auth_enabled/cookie/CSRF), so a leaked session_id authenticates nothing. The only capability it confers is requesting that same session's own already-known assistant-emitted artifacts (exact-path grant) — and the user was already viewing that session to click the link. No cross-session/cross-profile escalation. The default inline preview is about:srcdoc and carries no session_id; the leak requires an explicit click on attacker-authored HTML.
  • Do NOT apply Codex's literal fix (strip session_id from the open-full-page URL): that URL needs the session grant to serve an out-of-roots artifact, so stripping it breaks the exact feature this PR adds (Opus flagged this correctly). A safe future hardening, if desired: mint a short-lived opaque one-file token for the open-full-page navigation instead of reusing the session_id query param — separate follow-up, not this PR.

ℹ️ Cosmetic (non-blocking): _SESSION_MEDIA_TOKEN_TYPES duplicates the audio/video/pdf members of _INLINE_PREVIEW_TYPES (could be one constant); the two new JS tests are static source-string assertions (consistent with this repo's convention).

Recommendation to the next agent (release)

Ready to merge. Full gate is green: auth fails closed, render is sandboxed on both paths, full suite passes (the 2 local failures are pre-existing unrelated flakes; CI is 11/11 green), and the live HTTP boundary test passes 4/4. The single security item raised by Codex is low-severity and non-blocking (identifier, not credential; behind an explicit click; no escalation) and its literal fix would break the feature — Opus's APPROVE is the correct call on impact. Optionally file a follow-up for the opaque-token hardening of the open-full-page link; do not hold this PR for it. No code changes needed before merge.


Gate-certifier layer (warm-up → gate → release). I do not merge/tag/deploy/close — that's the release agent's call. This certification is valid only while the head stays at 17a93388.

@nesquena-hermes nesquena-hermes added the gate-pass Full gate passed (Codex+Opus+suite+browser); queued Tier 1 for release agent label Jun 29, 2026
@santastabber
santastabber force-pushed the fix-media-html-artifact-preview branch from 17a9338 to a6fb355 Compare June 29, 2026 03:42
@santastabber
santastabber force-pushed the fix-media-html-artifact-preview branch from a6fb355 to 055e57f Compare June 29, 2026 03:42
@nesquena-hermes

Copy link
Copy Markdown
Collaborator

🔬 Gate certification — GREEN ✅

Certified head: sha:055e57fac173 · PR: #5157 · Fix session-backed HTML media artifact previews
Verdict: Full authoritative gate is green. The session-media authorization boundary is correct, tight, and a net security improvement. Ready to merge — no engineering blockers; two non-blocking follow-up notes below.

This re-certifies #5157 at its current head (055e57fac173), which was re-pushed since the prior cert (17a93388). The re-push generalized the image-only grant to a shared _session_media_token_allows_path helper and addressed the earlier session_id-in-navigable-URL note by scoping the token to the sandboxed fetch only.

What I ran (isolated worktree /tmp/wt-gate-5157, gate port 8783)

Gate Result
Codex (reproduce, on clean rebase) SAFE TO SHIP — see stale-base note
Opus (full review) COMMENT — no blocking issues, 2 follow-up notes
Full pytest suite (-p no:xdist, to completion) 11073 passed, 10 skipped, 1 xfailed, 2 xpassed, 16 subtests passed, 0 failed (418s)
Live HTTP boundary test (4+ cases, out-of-roots /var/tmp artifact) all correct — see below
In-process grant boundary 5/5 — assistant-path+safe-mime → allow; wrong-session / wrong-mime / secret-path / SVG → deny

⚠️ Important gate note — stale-base artifact (resolved)

A first Codex pass against current origin/master returned KICK BACK TO AUTHOR with 5 CORE/SILENT findings (streaming.py partial-failure handling, profile default workspace, profile/model repair, extension_settings.js, gateway restart). All five are stale-base artifacts, not defects in this PR — they describe code from other PRs (incl. #5181) that merged to master after this PR's base. Codex itself diagnosed the cause ("This head is stale against origin/master"). I verified by rebasing the branch onto current master (c35835d8) locally in my throwaway worktree only — the rebase is clean (rc=0) and the post-rebase diff vs master is exactly the 4 media-auth files (api/routes.py, static/ui.js, tests/test_media_inline.py, tests/test_pdf_html_preview.py). Re-running Codex on the clean rebase flipped it to SAFE TO SHIP. (Also confirms the PR is cleanly mergeable on current master.)

Live HTTP boundary results (real server, artifact genuinely outside all roots)

Case Result
assistant-authored HTML + valid session_id + inline=1 200, Content-Type: text/html, Content-Security-Policy: sandbox allow-scripts ✅ correct sandboxed inline grant
same HTML, no session_id 403 ✅ token required
same HTML, wrong session_id 403 ✅ denied
/etc/passwd + valid session_id 403 ✅ secret/out-of-session path denied
SVG + valid session_id + inline=1 403 (never inline) ✅ XSS-risk type blocked from inline
HTML without inline=1 200, Content-Disposition: attachment ✅ forces download, no inline render

Findings

  • ✅ Boundary is exact-path + assistant-authored + safe-mime + session-scoped. _session_media_token_allows_path grants only a path the assistant emitted as a MEDIA: reference in that session, with a mime in the allowed set. User-authored paths, wrong-mime, and cross-session paths are all rejected (unit + live verified). State/secret deny runs before the allow decision.
  • ✅ HTML never gets same-origin. Inline HTML is served only with Content-Security-Policy: sandbox allow-scripts; SVG is always an attachment (XSS). The sandboxed iframe carries the session_id on its fetch; the user-navigable "open full page" + download links deliberately omit it.
  • ✅ Prior advisor finding addressed without breaking the feature. The earlier Codex note (session_id reaching a navigable script-capable page) is resolved by scoping the token to the sandboxed fetch — the supported working surface — rather than the self-defeating "strip it everywhere" fix.
  • ⚠️ Non-blocking follow-up (Opus + Codex agree): the "open full page" / download / large-file fallback links 403 for out-of-roots artifacts because they omit session_id. This is an intentional tradeoff (the sandboxed inline preview is the supported surface; the prior shipped path didn't authorize these previews at all), not a regression. A future hardening could mint a scoped token for those links.
  • ⚠️ Non-blocking: the PR description states open/download links carry session_id; the code (correctly) does not. Worth a one-line PR-body correction.

Recommendation to the next agent

Ready to merge — cert fresh for sha:055e57fac173, no further review needed. The security boundary is intact and verified at the HTTP layer (Codex SAFE on clean rebase, Opus no-blockers, 11073/0 suite, 6/6 live boundary cases, 5/5 in-process grant). The branch rebases cleanly onto current master with a 4-file diff. The two follow-up notes are non-blocking and can be a separate hardening PR. This cert is valid only while the head stays at sha:055e57fac173.


Gate-certifier layer (warm-up → gate → release). I do not merge/tag/deploy/close — that's the release agent's call. Cert valid only at sha:055e57fac173; a new push invalidates it.

@nesquena-hermes

Copy link
Copy Markdown
Collaborator

Shipped in v0.51.739 (via #5191). Thanks @santastabber — session-backed HTML/PDF artifacts now thread the active session_id into the api/media fetch so they authorize and preview inline again (HTML still sandboxed, SVG stays a download, secret/state hard-deny unchanged). Gate-certified green + maintainer-approved after live browser testing of the inline preview.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

gate-pass Full gate passed (Codex+Opus+suite+browser); queued Tier 1 for release agent size:M Medium PR (≤10 files, ≤250 LOC)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants