Release v0.27.0: security hardening (audit 0026)

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>
This commit is contained in:
Ben Stull
2026-05-28 18:19:41 -07:00
parent 698821f065
commit bd3ef269d4
18 changed files with 528 additions and 50 deletions
+2 -2
View File
@@ -17,7 +17,7 @@
import { useEditor, EditorContent, Extension } from '@tiptap/react'
import StarterKit from '@tiptap/starter-kit'
import { useEffect, useRef, useCallback } from 'react'
import { marked } from 'marked'
import { renderMarkdown } from '../lib/sanitizeHtml'
import { Plugin, PluginKey } from 'prosemirror-state'
import { Decoration, DecorationSet } from 'prosemirror-view'
@@ -122,7 +122,7 @@ export default function Editor({
useEffect(() => {
if (!editor || content == null) return
const html = marked.parse(content)
const html = renderMarkdown(content)
editor.commands.setContent(html, false)
}, [content, editor])
+2 -1
View File
@@ -21,6 +21,7 @@
import { useEffect, useRef, useState, useCallback } from 'react'
import { Marked } from 'marked'
import { sanitizeHtml } from '../lib/sanitizeHtml'
import { decorateAcceptedChanges } from './trackedOverlay.js'
import ChangeTooltip from './ChangeTooltip.jsx'
@@ -98,7 +99,7 @@ export default function MarkdownPreview({
// synchronously with the body itself — no flash of un-decorated text.
useEffect(() => {
if (!hostRef.current) return
const html = previewMarked.parse(content || '')
const html = sanitizeHtml(previewMarked.parse(content || ''))
hostRef.current.innerHTML = html
const token = ++renderTokenRef.current
// Reset memo so the new block set re-renders from scratch.
+3 -3
View File
@@ -10,7 +10,7 @@
import { useEffect, useState } from 'react'
import { useParams, useNavigate } from 'react-router-dom'
import { marked } from 'marked'
import { renderMarkdown } from '../lib/sanitizeHtml'
import { getProposal, mergeProposal, declineProposal, withdrawProposal } from '../api'
export default function ProposalView({ viewer, onChange }) {
@@ -161,7 +161,7 @@ export default function ProposalView({ viewer, onChange }) {
</h3>
<div
className="entry-body"
dangerouslySetInnerHTML={{ __html: marked.parse(data.entry?.body || '') }}
dangerouslySetInnerHTML={{ __html: renderMarkdown(data.entry?.body || '') }}
/>
{/* #26: the optional ground-truth use case the proposer supplied. */}
@@ -169,7 +169,7 @@ export default function ProposalView({ viewer, onChange }) {
Intended use case
</h3>
{data.proposed_use_case
? <div className="entry-body" dangerouslySetInnerHTML={{ __html: marked.parse(data.proposed_use_case) }} />
? <div className="entry-body" dangerouslySetInnerHTML={{ __html: renderMarkdown(data.proposed_use_case) }} />
: <p style={{ color: '#999', fontStyle: 'italic' }}>Left blank by the proposer.</p>}
</article>
)
+47
View File
@@ -0,0 +1,47 @@
// 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 || ''))
}