Skip to content

feat: DOMPurify XSS hardening + ES module split for static JS#36

Merged
wpak-ai merged 5 commits into
masterfrom
Fix/claude-chat-xss-appjs-split
May 15, 2026
Merged

feat: DOMPurify XSS hardening + ES module split for static JS#36
wpak-ai merged 5 commits into
masterfrom
Fix/claude-chat-xss-appjs-split

Conversation

@clean6378-max-it
Copy link
Copy Markdown
Collaborator

@clean6378-max-it clean6378-max-it commented May 15, 2026

feat(web): DOMPurify session markdown + split static JS into ES modules

Session HTML was built with marked.parse() and assigned via innerHTML without sanitizing model or user content, which is an XSS risk. This change loads DOMPurify from cdnjs (same SRI + crossorigin="anonymous" pattern as marked and highlight.js) and routes all session markdown through static/js/shared/markdown.js, where renderMarkdown() returns DOMPurify.sanitize(marked.parse(text, { breaks: true, gfm: true })) so injected markup cannot run scripts or handlers.

The former ~950-line static/js/app.js is split into ES modules with no build step: a thin app.js entry (<script type="module">) handles routing and registers window globals for existing inline onclick handlers; projects.js, sessions.js, search.js, and export.js own the primary views; shared/state.js, shared/utils.js, shared/markdown.js, and shared/theme.js hold cross-cutting state, DOM helpers, sanitized markdown, and highlight.js theme swapping (HLJS_THEME_SHEETS moved here so it stays paired with index.html SRI via tests/test_hljs_theme_consistency.py).

New tests/test_xss_sanitization.py asserts DOMPurify is present in index.html with integrity and crossorigin, that only shared/markdown.js calls marked.parse, and that no file assigns a raw marked.parse(...) into innerHTML. A small follow-up fix imports setHamburgerVisible from shared/utils.js in projects.js and search.js (it was incorrectly imported from theme.js).

Test plan: run pytest (full suite). Manually open the app, load projects, open a session with markdown, code blocks, thinking, and tool output, run search and zero-hit search, exercise export and session download, and spot-check that obvious XSS snippets in message text render safely without executing.

Summary by CodeRabbit

  • New Features

    • Added Search page and in-app search.
    • Projects list and Sessions workspace with session view, copy-all, and per-session & bulk download.
    • Persistent theme toggle with selectable code-highlighting themes.
    • Mobile sidebar overlay, scroll-to-top button, and Projects page header layout improvements.
  • Bug Fixes

    • Safer markdown rendering to reduce XSS risk.
  • Tests

    • Added XSS sanitization regression tests and updated highlight.js theme consistency test.

Review Change Stack

… app.js into modules

Sanitize all session markdown before innerHTML assignment via a single
renderMarkdown() wrapper in shared/markdown.js that applies
DOMPurify.sanitize(marked.parse(...)). DOMPurify 3.2.7 loaded from CDN
with SRI and crossorigin="anonymous" (issue #295).
Split the 955-line app.js monolith into six route/view modules:
sessions.js, projects.js, search.js, export.js, four shared modules
(state, utils, markdown, theme), and a thin app.js bootstrap with
window registrations for inline handlers. Entry switched to
<script type="module"> (no build step).
Add tests/test_xss_sanitization.py (8 source-level regression tests)
and update test_hljs_theme_consistency.py for the HLJS_THEME_SHEETS
move to shared/theme.js. All 229 tests pass.
@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented May 15, 2026

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: f765eeb5-ca16-4a39-b7dd-0503ddb93b66

📥 Commits

Reviewing files that changed from the base of the PR and between eb17766 and 9a04052.

📒 Files selected for processing (2)
  • static/css/style.css
  • static/js/projects.js
✅ Files skipped from review due to trivial changes (1)
  • static/css/style.css
🚧 Files skipped from review as they are similar to previous changes (1)
  • static/js/projects.js

📝 Walkthrough

Walkthrough

Converts the frontend to ES modules, adds shared state/utils/theme and a safe markdown renderer (DOMPurify), implements Projects/Search/Sessions/Export client routes, updates index.html to load DOMPurify and module app entry, adds Projects header CSS, and introduces tests enforcing theme and XSS sanitization.

Changes

Frontend feature cohort

Layer / File(s) Summary
Shared state, theme, and utilities
static/js/shared/state.js, static/js/shared/theme.js, static/js/shared/utils.js
Adds state object, HLJS_THEME_SHEETS and theme helpers, and utility functions (esc, formatTs/Date/Size, smoothSet, showToast, showConfirm, loadingBar, sidebar helpers).
Markdown sanitization and index.html
static/js/shared/markdown.js, static/index.html, tests/test_xss_sanitization.py
Adds cleanContent() and renderMarkdown() that use marked.parse() output sanitized by DOMPurify (with escaping fallback); injects DOMPurify script tag into index.html with SRI and crossorigin="anonymous"; adds tests asserting DOMPurify presence and that marked.parse is only used via the sanitizer.
App bootstrap and router
static/js/app.js, tests/test_hljs_theme_consistency.py
Converts app.js to an ES module, imports shared modules, rewrites routing/bootstrap (handleRoute, safeDecode, nav gating, window-registered handlers), and re-exports HLJS_THEME_SHEETS; updates test to read theme data from shared/theme.js.
Projects, Search, Sessions, Export features
static/js/projects.js, static/js/search.js, static/js/sessions.js, static/js/export.js
Adds showProjects() page with conditional export actions, showSearchPage()/doSearch() with request-id deduplication, sessions workspace (showWorkspace, loadSession, rendering helpers, tool/result rendering, copyAll()), and export/download flow (bulkExport, downloadSession, File System Access fallback).
CSS tweaks and tests
static/css/style.css, tests/test_xss_sanitization.py
Adds .page-header--projects layout styles and includes the XSS sanitization regression tests module.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related issues

Possibly related PRs

Suggested reviewers

  • jonathanMLDev
  • wpak-ai

Poem

🐰 I nudge the DOM with gentle care,
I chase the bugs and tidy the lair.
I sanitize, bundle, and neatly impart,
module-hops and features close to my heart.
Hop, click, export — the UI sings smart!

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 31.91% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The PR title clearly and specifically describes the two main changes: DOMPurify XSS hardening and ES module refactoring of static JavaScript.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch Fix/claude-chat-xss-appjs-split

Warning

Review ran into problems

🔥 Problems

Git: Failed to clone repository. Please run the @coderabbitai full review command to re-trigger a full review. If the issue persists, set path_filters to include or exclude specific files.


Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 13

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
static/js/app.js (1)

19-35: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Guard route decoding against malformed hash input.

Line 23 and Line 34 call decodeURIComponent directly; malformed hash fragments can throw and break navigation.

Proposed fix
+function safeDecode(value) {
+    try { return decodeURIComponent(value); }
+    catch { return null; }
+}
+
 function handleRoute() {
@@
-            const project = decodeURIComponent(parts.slice(0, slashIdx));
+            const project = safeDecode(parts.slice(0, slashIdx));
+            if (project === null) { showProjects(); return; }
@@
-            showWorkspace(decodeURIComponent(parts));
+            const project = safeDecode(parts);
+            if (project === null) { showProjects(); return; }
+            showWorkspace(project);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@static/js/app.js` around lines 19 - 35, The route parsing calls
decodeURIComponent on untrusted hash fragments (in the project branch where
parts are decoded for project and in the else branch) which can throw on
malformed input; wrap the decode steps in a safe decoder and handle failures by
falling back to a sane value or showing an error/redirect. Specifically, replace
direct decodeURIComponent usages around parts.slice(0, slashIdx) and parts (used
by showWorkspace and loadSession) with a try/catch or a helper like
safeDecode(str) that returns null or a sanitized string on URIError, and then
branch: if decoding fails, call showWorkspace with a fallback or abort
navigation, otherwise proceed to call loadSession(project, sessionId) or
showWorkspace(project).
🧹 Nitpick comments (1)
static/js/search.js (1)

41-43: ⚡ Quick win

Handle non-2xx responses explicitly before parsing JSON.

Without an res.ok check, backend failures often surface as generic parse/type errors instead of actionable messages.

🛠️ Suggested fix
     try {
         const res = await fetch(`/api/search?q=${encodeURIComponent(query)}&limit=50`);
+        if (!res.ok) {
+            throw new Error(`Search failed (${res.status})`);
+        }
         const results = await res.json();
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@static/js/search.js` around lines 41 - 43, The fetch result is parsed without
checking HTTP status, causing opaque JSON parse errors on backend failures;
update the fetch handling around the const res = await fetch(...) / const
results = await res.json() block to first check res.ok and, if false, read the
response text or status (e.g. await res.text() or res.statusText) and throw a
descriptive Error so callers can handle it, otherwise proceed to await
res.json() to obtain results.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@static/js/export.js`:
- Around line 74-76: The current code calls handle.createWritable(), then awaits
writable.write(blob) and writable.close(), but if writable.write() (or close)
throws the writable stays in a bad state; wrap the writable usage in
try/catch/finally: call handle.createWritable() to get writable, perform await
writable.write(blob) and await writable.close() inside try, and in the catch
block call await writable.abort() (or await writable.close() if abort is
unavailable) to discard partial writes, rethrow or handle the error as needed;
reference the functions handle.createWritable(), writable.write(),
writable.close(), and writable.abort() when making the change.
- Around line 52-53: The fetch call building the download URL encodes project
but not sessionId, so update the URL construction in the fetch invocation (the
fetch(...) that references project and sessionId) to wrap sessionId with
encodeURIComponent as well (i.e., encodeURIComponent(sessionId)) so path
parameters are consistently encoded and reserved characters in sessionId won't
break routing.

In `@static/js/projects.js`:
- Around line 23-33: The code parses projRes.json() unconditionally which can
turn a backend error into an empty projects array and show the wrong UI; change
the flow to check projRes.ok first (after the Promise.all) and handle non-2xx
responses by reading the response body (try json then fallback to text), call
loadingBar.done(), and render an error state via smoothSet (including status and
error message), then return; only if projRes.ok proceed to await projRes.json()
and continue with the existing empty-state check. Ensure you reference projRes,
stateRes, projects, smoothSet and loadingBar.done in the fix.
- Around line 47-49: The server-derived value sessionCount (used when building
lastExportHtml) is interpolated directly into HTML and needs to be sanitized:
coerce sessionCount to a safe numeric value (e.g. parseInt/Number and fallback
to 0 if NaN, and clamp to a non-negative integer) before using it, or avoid
string interpolation into innerHTML by building the element and inserting the
count via textContent; update the code around exportState, sessionCount and
lastExportHtml to apply the coercion/escape so only a validated numeric string
is injected.

In `@static/js/search.js`:
- Around line 47-49: The onclick string builds a hash with r.session_id
unescaped which allows injection; update the inline handler in
static/js/search.js where results are iterated (variable r) so that the session
id is encoded before interpolation (use encodeURIComponent on r.session_id,
matching the existing encodeURIComponent(r.project)) and ensure the final string
remains wrapped in the existing quotes to avoid breaking the attribute.

In `@static/js/sessions.js`:
- Line 99: The hash route and fetch URL are using raw sessionId which can
contain characters that break routing; update the places that build the route
and API call (the window.location.hash assignment and the fetch that references
sessionId) to wrap sessionId with encodeURIComponent(sessionId) (i.e., use
encodeURIComponent for the sessionId segment when constructing
`window.location.hash =
\`#project/${encodeURIComponent(projectName)}/${encodeURIComponent(sessionId)}\``
and when composing the fetch path) so all unsafe characters are percent-encoded.
- Line 57: The inline onclick handlers build a JS string using HTML-escaped
values (esc(projectName) and esc(s.id)) which is unsafe because HTML-escaping
does not escape JS-quote characters; update the sidebar markup generation in
static/js/sessions.js to stop passing raw escaped strings into onclick. Instead
attach data attributes (e.g., data-project-name and data-session-id on the
button using proper HTML-escaping) and bind selectSession via addEventListener
(or use JSON.stringify(...) when inlining JS literals) when creating the
element; update the code paths that build the button (the template building code
that includes onclick="selectSession(...)" and the similar occurrence around
lines 154-155) to use the data-attribute + event listener approach (or
JSON.stringify) and call selectSession(element.dataset.projectName,
element.dataset.sessionId) from the listener.
- Around line 395-398: The copyAll() handler is querying the wrong DOM selector
('.messages-container') that this module never renders, so the Copy button does
nothing; update copyAll() to target the actual session DOM used here
('#session-content' or its child '.session-content-inner'), e.g. locate the
container by document.querySelector('#session-content') or
document.querySelector('.session-content-inner'), fall back to the alternative
selector if needed, read its innerText, and then call
navigator.clipboard.writeText(text). Also adjust any variable names (msgs ->
sessionEl or similar) inside copyAll() to reflect the new selector and keep the
existing showToast('Copied to clipboard', 'success') behavior.

In `@static/js/shared/markdown.js`:
- Around line 39-45: Remove the unsanitized early return when DOMPurify is
missing: in the block that checks "if (typeof DOMPurify !== 'undefined')" do not
console.warn then "return parsed"; instead throw an error (e.g. throw new
Error('DOMPurify not available')) so execution falls into the existing catch
handler and the code uses the escaped fallback. Reference DOMPurify and the
local variable parsed (inside the renderMarkdown flow) so you modify that branch
to throw rather than return unsanitized output.

In `@static/js/shared/utils.js`:
- Around line 58-63: The showToast function currently injects unescaped message
into toast.innerHTML which permits XSS; update showToast (and the similar code
block around lines 77-83) to stop using innerHTML for user content: create the
toast container and its child elements (icon span, text span, close button,
progress div) via document.createElement and set the message using textContent
(or otherwise escape the message) instead of interpolating into innerHTML,
preserving existing class names (toast, toast-icon, toast-text, toast-close,
toast-progress) and behavior.
- Around line 39-41: The function formatSize incorrectly treats 0 as missing
because it uses a falsy check; change the guard in formatSize to only treat
null/undefined as unknown (e.g., use bytes == null or bytes === null || bytes
=== undefined) so that 0 is preserved and returns "0 B"; keep the rest of the
logic (bytes < 1024 -> bytes + ' B') unchanged and reference the function name
formatSize when making the change.
- Around line 13-37: formatTs and formatDate silently produce "Invalid Date"/NaN
output because new Date(ts) can be invalid without throwing; update both
functions to validate the Date after construction (e.g., check
isNaN(d.getTime()) or Number.isNaN(d.getTime())) and return the intended
fallback (for formatTs return the original ts, for formatDate return ts ?
ts.slice(0,10) : '') when the date is invalid; keep the existing try/catch but
perform the explicit invalid-date check at the start of formatTs and formatDate
to avoid rendering NaN values.

In `@tests/test_xss_sanitization.py`:
- Line 117: The string expression uses an unnecessary f-string prefix (F541):
change the expression f"shared/markdown.js:\n  " + "\n  ".join(violations) to a
plain string "shared/markdown.js:\n  " + "\n  ".join(violations) (i.e., remove
the leading 'f') so the code is a regular concatenation with the violations
variable.

---

Outside diff comments:
In `@static/js/app.js`:
- Around line 19-35: The route parsing calls decodeURIComponent on untrusted
hash fragments (in the project branch where parts are decoded for project and in
the else branch) which can throw on malformed input; wrap the decode steps in a
safe decoder and handle failures by falling back to a sane value or showing an
error/redirect. Specifically, replace direct decodeURIComponent usages around
parts.slice(0, slashIdx) and parts (used by showWorkspace and loadSession) with
a try/catch or a helper like safeDecode(str) that returns null or a sanitized
string on URIError, and then branch: if decoding fails, call showWorkspace with
a fallback or abort navigation, otherwise proceed to call loadSession(project,
sessionId) or showWorkspace(project).

---

Nitpick comments:
In `@static/js/search.js`:
- Around line 41-43: The fetch result is parsed without checking HTTP status,
causing opaque JSON parse errors on backend failures; update the fetch handling
around the const res = await fetch(...) / const results = await res.json() block
to first check res.ok and, if false, read the response text or status (e.g.
await res.text() or res.statusText) and throw a descriptive Error so callers can
handle it, otherwise proceed to await res.json() to obtain results.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: ce9915e4-7db1-4ef7-807e-2c26feb750a1

📥 Commits

Reviewing files that changed from the base of the PR and between 5b64643 and d497eb6.

📒 Files selected for processing (12)
  • static/index.html
  • static/js/app.js
  • static/js/export.js
  • static/js/projects.js
  • static/js/search.js
  • static/js/sessions.js
  • static/js/shared/markdown.js
  • static/js/shared/state.js
  • static/js/shared/theme.js
  • static/js/shared/utils.js
  • tests/test_hljs_theme_consistency.py
  • tests/test_xss_sanitization.py

Comment thread static/js/export.js Outdated
Comment thread static/js/export.js Outdated
Comment thread static/js/projects.js Outdated
Comment thread static/js/projects.js Outdated
Comment thread static/js/search.js
Comment thread static/js/shared/markdown.js
Comment thread static/js/shared/utils.js
Comment thread static/js/shared/utils.js
Comment thread static/js/shared/utils.js
Comment thread tests/test_xss_sanitization.py Outdated
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@static/js/search.js`:
- Around line 31-64: The doSearch function can be overwritten by out-of-order
async responses; add a request token guard: introduce a module-scoped increasing
counter (e.g., lastSearchRequestId) that you increment at the start of doSearch
and capture as localRequestId, then after each await (both successful fetch/json
and in the catch) check that localRequestId === lastSearchRequestId before
updating the DOM (container.innerHTML or smoothSet). Reference doSearch, the
fetch/res.json handling and the catch block to apply the guard so stale
responses are ignored.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 91670e0c-29fc-4ed5-af85-27f7fcb82590

📥 Commits

Reviewing files that changed from the base of the PR and between d497eb6 and 66312ae.

📒 Files selected for processing (8)
  • static/js/app.js
  • static/js/export.js
  • static/js/projects.js
  • static/js/search.js
  • static/js/sessions.js
  • static/js/shared/markdown.js
  • static/js/shared/utils.js
  • tests/test_xss_sanitization.py
🚧 Files skipped from review as they are similar to previous changes (6)
  • static/js/export.js
  • tests/test_xss_sanitization.py
  • static/js/projects.js
  • static/js/sessions.js
  • static/js/app.js
  • static/js/shared/utils.js

Comment thread static/js/search.js
@clean6378-max-it clean6378-max-it linked an issue May 15, 2026 that may be closed by this pull request
10 tasks
@timon0305
Copy link
Copy Markdown
Collaborator

image is there any way to show in same line?

@clean6378-max-it clean6378-max-it requested a review from wpak-ai May 15, 2026 14:17
@wpak-ai wpak-ai merged commit 4bbb456 into master May 15, 2026
3 checks passed
@wpak-ai wpak-ai deleted the Fix/claude-chat-xss-appjs-split branch May 15, 2026 15:13
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Harden claude-code-chat-browser markdown rendering and split frontend app.js

3 participants