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>
224 lines
8.1 KiB
React
224 lines
8.1 KiB
React
// MarkdownPreview.jsx — Phase 2 of the Contribute rewrite. The rendered
|
|
// preview pane that replaces Tiptap's read-only render for Discuss mode
|
|
// and sits next to the CM6 raw pane in Contribute mode.
|
|
//
|
|
// Two responsibilities:
|
|
// • Markdown → HTML via marked, with ```mermaid fences extracted to
|
|
// placeholder nodes that resolve once mermaid lazy-loads.
|
|
// • Window-selection bridge per §8.12 — onMouseUp inside the preview
|
|
// reports {text, coords} to the parent so the SelectionTooltip can
|
|
// anchor against the rendered DOM (no PM/Tiptap surface here).
|
|
//
|
|
// Mermaid lazy-load: triggered on the first appearance of a ```mermaid
|
|
// fence in any rendered doc, not on mount. The module (~200 KB gzipped)
|
|
// stays out of the main bundle. `securityLevel: 'strict'` neutralizes
|
|
// hostile <script>/onclick payloads embedded in diagram source.
|
|
//
|
|
// The mermaid render path is deliberately decoupled from "blocks are
|
|
// immutable" — placeholder DOM nodes carry the source as a data
|
|
// attribute, and the renderer takes (source) → SVG. A future
|
|
// authoring layer can intercept before mount without restructuring.
|
|
|
|
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'
|
|
|
|
// Module-level mermaid loader. Holds the Promise across consumers so
|
|
// the chunk fetches exactly once across the app lifetime.
|
|
let mermaidPromise = null
|
|
function loadMermaid() {
|
|
if (!mermaidPromise) {
|
|
mermaidPromise = import('mermaid').then(m => {
|
|
const mermaid = m.default
|
|
mermaid.initialize({
|
|
startOnLoad: false,
|
|
securityLevel: 'strict',
|
|
suppressErrorRendering: true,
|
|
})
|
|
return mermaid
|
|
})
|
|
}
|
|
return mermaidPromise
|
|
}
|
|
|
|
function escapeHtml(s) {
|
|
return String(s).replace(/[&<>"']/g, c => (
|
|
{ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]
|
|
))
|
|
}
|
|
|
|
// Scoped Marked instance so the mermaid-fence renderer doesn't leak
|
|
// to any other call site that might construct its own Marked.
|
|
const previewMarked = new Marked({
|
|
renderer: {
|
|
code({ text, lang }) {
|
|
const tag = (lang || '').trim().split(/\s+/)[0]
|
|
if (tag === 'mermaid') {
|
|
const encoded = encodeURIComponent(text || '')
|
|
// Placeholder shows the source as a <pre> until mermaid resolves;
|
|
// keeps the preview useful even if mermaid never loads.
|
|
return (
|
|
`<div class="mermaid-block" data-mermaid-src="${encoded}">`
|
|
+ `<pre class="mermaid-placeholder">${escapeHtml(text || '')}</pre>`
|
|
+ `</div>`
|
|
)
|
|
}
|
|
return false // fall through to the default code renderer
|
|
},
|
|
},
|
|
})
|
|
|
|
export default function MarkdownPreview({
|
|
content,
|
|
onSelectionChange,
|
|
className,
|
|
// Phase 3 — tracked-change overlay (§8.10). When `showTrackedChanges`
|
|
// is true, accepted changes from the branch's changes list are
|
|
// decorated inline as <span class="tracked-insert"> / <span
|
|
// class="tracked-delete">. Hovering a decorated span surfaces a
|
|
// ChangeTooltip with the source message + reason. `messages` is
|
|
// optional; without it the tooltip falls back to badge + reason.
|
|
changes,
|
|
messages,
|
|
showTrackedChanges = false,
|
|
}) {
|
|
const hostRef = useRef(null)
|
|
// Per-block source memo — lets us skip mermaid re-renders for blocks
|
|
// whose source hasn't changed across doc updates.
|
|
const lastMermaidSourcesRef = useRef([])
|
|
// Monotonic counter the async mermaid pass uses to bail if a newer
|
|
// render has already replaced the DOM beneath it.
|
|
const renderTokenRef = useRef(0)
|
|
// Tracked-change hover tooltip state. Shape: { change, position }.
|
|
const [tooltip, setTooltip] = useState(null)
|
|
|
|
// Render markdown → HTML on every content change. The tracked-change
|
|
// overlay runs in the same effect so accepted-change spans appear
|
|
// synchronously with the body itself — no flash of un-decorated text.
|
|
useEffect(() => {
|
|
if (!hostRef.current) return
|
|
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.
|
|
lastMermaidSourcesRef.current = []
|
|
if (showTrackedChanges && changes && changes.length > 0) {
|
|
decorateAcceptedChanges(hostRef.current, changes)
|
|
}
|
|
renderMermaidBlocks(hostRef.current, lastMermaidSourcesRef, token, renderTokenRef)
|
|
}, [content, showTrackedChanges, changes])
|
|
|
|
// Window-selection bridge for §8.12. Listen on document mouseup so
|
|
// releases outside the preview still clear the prior selection.
|
|
useEffect(() => {
|
|
if (!onSelectionChange) return
|
|
const handleMouseUp = () => {
|
|
const sel = window.getSelection?.()
|
|
if (!sel || sel.isCollapsed || sel.rangeCount === 0) {
|
|
onSelectionChange(null)
|
|
return
|
|
}
|
|
const range = sel.getRangeAt(0)
|
|
const host = hostRef.current
|
|
if (!host || !host.contains(range.commonAncestorContainer)) {
|
|
// Selection is outside the preview — leave any active tooltip
|
|
// selection alone (it belongs to another surface).
|
|
return
|
|
}
|
|
const text = sel.toString()
|
|
if (!text || !text.trim()) {
|
|
onSelectionChange(null)
|
|
return
|
|
}
|
|
const rect = range.getBoundingClientRect()
|
|
onSelectionChange({
|
|
text,
|
|
coords: { top: rect.top, left: rect.left },
|
|
})
|
|
}
|
|
document.addEventListener('mouseup', handleMouseUp)
|
|
return () => document.removeEventListener('mouseup', handleMouseUp)
|
|
}, [onSelectionChange])
|
|
|
|
// Hover handler for tracked-change spans (§8.10). The decorated spans
|
|
// carry data-change-id; we look the change row up out of `changes`
|
|
// and surface a ChangeTooltip anchored to the cursor.
|
|
const handleMouseMove = useCallback((e) => {
|
|
if (!showTrackedChanges || !changes) return
|
|
const span = e.target.closest?.('[data-change-id]')
|
|
if (!span) {
|
|
if (tooltip) setTooltip(null)
|
|
return
|
|
}
|
|
const id = span.getAttribute('data-change-id')
|
|
const change = changes.find(c => String(c.id) === String(id))
|
|
if (!change) return
|
|
setTooltip({ change, position: { x: e.clientX, y: e.clientY } })
|
|
}, [showTrackedChanges, changes, tooltip])
|
|
|
|
const handleMouseLeave = useCallback(() => {
|
|
setTooltip(null)
|
|
}, [])
|
|
|
|
return (
|
|
<>
|
|
<div
|
|
ref={hostRef}
|
|
className={`markdown-preview${className ? ' ' + className : ''}`}
|
|
onMouseMove={showTrackedChanges ? handleMouseMove : undefined}
|
|
onMouseLeave={showTrackedChanges ? handleMouseLeave : undefined}
|
|
/>
|
|
{tooltip && (
|
|
<ChangeTooltip
|
|
change={tooltip.change}
|
|
messages={messages || []}
|
|
position={tooltip.position}
|
|
/>
|
|
)}
|
|
</>
|
|
)
|
|
}
|
|
|
|
async function renderMermaidBlocks(host, lastSourcesRef, token, tokenRef) {
|
|
const blocks = host.querySelectorAll('.mermaid-block')
|
|
if (blocks.length === 0) {
|
|
lastSourcesRef.current = []
|
|
return
|
|
}
|
|
let mermaid
|
|
try {
|
|
mermaid = await loadMermaid()
|
|
} catch (err) {
|
|
// Loading failed — leave the <pre> placeholders in place.
|
|
console.error('mermaid load failed', err)
|
|
return
|
|
}
|
|
if (tokenRef.current !== token) return // stale pass
|
|
const prev = lastSourcesRef.current
|
|
const next = []
|
|
for (let i = 0; i < blocks.length; i++) {
|
|
const block = blocks[i]
|
|
const src = decodeURIComponent(block.dataset.mermaidSrc || '')
|
|
next.push(src)
|
|
if (prev[i] === src && block.querySelector('svg')) continue
|
|
if (tokenRef.current !== token) return
|
|
try {
|
|
const id = `mmd-${Math.random().toString(36).slice(2, 10)}`
|
|
const { svg, bindFunctions } = await mermaid.render(id, src)
|
|
if (tokenRef.current !== token) return
|
|
if (!host.contains(block)) return
|
|
block.innerHTML = svg
|
|
bindFunctions?.(block)
|
|
} catch (err) {
|
|
block.innerHTML = (
|
|
`<pre class="mermaid-error">Mermaid parse error: `
|
|
+ escapeHtml(err?.message || String(err))
|
|
+ `</pre>`
|
|
)
|
|
}
|
|
}
|
|
lastSourcesRef.current = next
|
|
}
|