bd3ef269d4
Remediates the rfc-app application + deploy-config findings from the Session 0026 security audit. Cut as the "v0.25.0-security-hardening" branch (from v0.24.0); reversioned to 0.27.0 on rebase onto main since v0.26.0 (#28) shipped while this was in flight. - C1 (Critical): single sanitizeHtml.js chokepoint (DOMPurify) for every marked→innerHTML / dangerouslySetInnerHTML sink (MarkdownPreview, ProposalView x2, Editor); rel=noopener hook on target=_blank links. - H1: per-account OTC-verify lockout (migration 023, auto-applied) + per-IP throttle via new ratelimit.py; wired on otc verify/request + passcode check/verify. - M1: device_trust.lookup() single indexed-row read — cookie value is now "<row_id>.<raw_token>"; bcrypt-checks one row, not a global table scan. (Behavior change: existing device-trust cookies re-prompt once.) - M2: HTTP security headers (CSP/HSTS/XFO/XCTO/Referrer-Policy) at nginx. - M4: session cookie Secure-by-default (SESSION_COOKIE_SECURE opt-out). - M5: bounce webhook fails CLOSED (503) when secret unset, instead of open; RFC_APP_INSECURE_BOUNCE_WEBHOOK=1 dev opt-in. - L2/L3: per-IP cooldown + check-endpoint throttle. - L4: systemd sandbox knobs. L8/I1: nginx server_tokens off + TLS1.0/1.1 out. VERSION + frontend/package.json → 0.27.0; CHANGELOG documents the upgrade steps (incl. the out-of-band nginx + systemd apply, which the flotilla deploy gesture does not perform). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
48 lines
2.1 KiB
JavaScript
48 lines
2.1 KiB
JavaScript
// sanitizeHtml.js — the single chokepoint for turning user-authored
|
|
// markdown into DOM-bound HTML.
|
|
//
|
|
// Security audit 0026 (finding C1, Critical): every `marked.parse(...)`
|
|
// result that reaches an `innerHTML` / `dangerouslySetInnerHTML` sink was
|
|
// previously written raw. `marked` passes through embedded HTML and
|
|
// `javascript:`/event-handler attributes verbatim, so any user-authored
|
|
// document (RFC body, proposal body, proposed_use_case, transcript) was a
|
|
// stored-XSS vector — a contributor's payload executed in the session of
|
|
// whoever viewed it, including an admin/owner during review.
|
|
//
|
|
// Fix: route EVERY markdown render through `renderMarkdown` (or, for
|
|
// already-rendered HTML, `sanitizeHtml`). DOMPurify's defaults already
|
|
// strip <script>, on* event handlers, and javascript:/unsafe-data: URIs;
|
|
// we add a hook so any link opening a new tab carries rel="noopener
|
|
// noreferrer". The html profile keeps the standard markdown tag set plus
|
|
// class + data-* attributes (the latter is what MarkdownPreview's mermaid
|
|
// placeholder relies on); mermaid renders its SVG into the DOM *after*
|
|
// sanitization and is itself locked down with securityLevel:'strict'.
|
|
|
|
import DOMPurify from 'dompurify'
|
|
import { marked } from 'marked'
|
|
|
|
let _hookInstalled = false
|
|
function ensureHook() {
|
|
if (_hookInstalled) return
|
|
DOMPurify.addHook('afterSanitizeAttributes', (node) => {
|
|
if (node.tagName === 'A' && node.getAttribute('target') === '_blank') {
|
|
node.setAttribute('rel', 'noopener noreferrer')
|
|
}
|
|
})
|
|
_hookInstalled = true
|
|
}
|
|
|
|
// Sanitize an already-rendered HTML string. Use when the HTML did not come
|
|
// from `marked` (rare) or when a caller parses markdown with a bespoke
|
|
// `Marked` instance and only needs the sanitize step.
|
|
export function sanitizeHtml(html) {
|
|
ensureHook()
|
|
return DOMPurify.sanitize(html || '', { USE_PROFILES: { html: true } })
|
|
}
|
|
|
|
// Parse markdown with the shared `marked` and sanitize the result. This is
|
|
// the drop-in replacement for `marked.parse(src)` at any HTML sink.
|
|
export function renderMarkdown(src) {
|
|
return sanitizeHtml(marked.parse(src || ''))
|
|
}
|