Fix email-thread HTML injection, attachment path traversal, and missing authz (#475)

Hardens issues found in a security review of the current tree (separate from
the cookbook SSH PR):

- Email thread rendering (static/js/emailLibrary.js): the flat read path runs
  inbound HTML through the allowlist sanitizer, but the two threaded paths
  (_renderTurnsAsBubbles / _renderTurnsFromServer — the default view) injected
  server-parsed `body_html` raw into the DOM. A crafted inbound email could
  inject arbitrary markup (phishing/form/credential-capture/tracking; full XSS
  if a deployment relaxes the script CSP). Now sanitized on all paths.

- Attachment extraction (routes/email_routes.py, routes/email_helpers.py): the
  on-disk extraction dir was `ATTACHMENTS_DIR / f"{folder}_{uid}"` with
  user-controlled folder/uid and no containment, so a folder like `../../tmp`
  could escape ATTACHMENTS_DIR. New attachment_extract_dir() flattens both to a
  single safe segment and asserts containment.

- Diagnostics routes (routes/diagnostics_routes.py): /api/db/stats,
  /api/rag/stats, /api/test/youtube, /api/test-research relied only on the
  global session check (any logged-in user). Now require_admin-gated.

- Defense-in-depth HTML escaping: session HTML export escapes the session name
  (routes/session_routes.py); the MCP OAuth page escapes the reflected Host
  header / server_id (routes/mcp_routes.py).

- Internal-tool token now compared with secrets.compare_digest (constant time)
  in core/middleware.py and app.py.

Adds regression tests in tests/test_security_regressions.py.
This commit is contained in:
Jamieson O'Reilly
2026-06-01 23:20:17 +10:00
committed by GitHub
parent 9e8de43f25
commit 171c29dcf3
9 changed files with 113 additions and 16 deletions

View File

@@ -2469,7 +2469,7 @@ function _renderTurnsAsBubbles(turns, data) {
+ (isMine ? '' : avatar)
+ `<div class="email-bubble">`
+ head
+ `<div class="email-bubble-body">${t.body_html || ''}</div>`
+ `<div class="email-bubble-body">${_sanitizeHtml(t.body_html || '')}</div>`
+ `</div>`
+ (isMine ? avatar : '')
+ `</div>`
@@ -2499,7 +2499,7 @@ function _renderTurnsFromServer(turns) {
const w = wrap(top);
if (stack.length) stack[stack.length - 1].html += w; else out += w;
}
out += t.body_html || '';
out += _sanitizeHtml(t.body_html || '');
} else {
while (stack.length && stack[stack.length - 1].level > t.level) {
const top = stack.pop();
@@ -2507,9 +2507,9 @@ function _renderTurnsFromServer(turns) {
if (stack.length) stack[stack.length - 1].html += w; else out += w;
}
if (!stack.length || stack[stack.length - 1].level < t.level) {
stack.push({ level: t.level, meta: t.meta, html: t.body_html || '' });
stack.push({ level: t.level, meta: t.meta, html: _sanitizeHtml(t.body_html || '') });
} else {
stack[stack.length - 1].html += t.body_html || '';
stack[stack.length - 1].html += _sanitizeHtml(t.body_html || '');
if (t.meta && !stack[stack.length - 1].meta) {
stack[stack.length - 1].meta = t.meta;
}