Files
website/server/src/utils/sanitizeHtml.js
Claude a5a8c1930c RTE Posts upgrade: TipTap editor + sanitization for posts
Extend the wiki's RichTextEditor to the Posts editor and close the
stored-XSS gap on public post bodies.

- RichTextEditor: add `variant` prop — `full` (wiki), `post` (no
  internal wiki-page link picker), `minimal` (image-only, for
  Screenshots captions). Toolbar sections rendered conditionally.
- PostEditor: replace the body textarea with a lazy-loaded
  RichTextEditor in Suspense; variant chosen by category
  (minimal for screenshots, post otherwise).
- posts.model: sanitize body via shared cleanBody on create/update,
  treat an empty TipTap `<p></p>` as null, and auto-derive the
  excerpt from the body (max 280 chars) when left blank.
- sanitizeHtml util: add deriveExcerpt() helper.
- FiveOnFriday / NewsletterIssue: wrap dangerouslySetInnerHTML with
  DOMPurify.sanitize() as defense-in-depth on render.

No schema or dependency changes. Verified end-to-end against the
local stack: 24/24 API assertions and a full UI round-trip across
all four post categories.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 13:14:54 -05:00

64 lines
2.3 KiB
JavaScript

const sanitizeHtml = require('sanitize-html')
// Allowlist for wiki/post body HTML. Anything not listed is stripped. This runs
// on every save so the stored value is already safe; the client re-sanitizes on
// render as defense in depth. Tuned for rich-text content from the admin editor.
const OPTIONS = {
allowedTags: [
'h1', 'h2', 'h3', 'h4', 'h5', 'h6',
'p', 'br', 'hr', 'blockquote', 'pre', 'code',
'ul', 'ol', 'li',
'strong', 'b', 'em', 'i', 'u', 's', 'sup', 'sub', 'mark', 'span',
'a', 'img', 'figure', 'figcaption',
'table', 'thead', 'tbody', 'tr', 'th', 'td',
],
allowedAttributes: {
a: ['href', 'name', 'target', 'rel', 'title'],
img: ['src', 'alt', 'title', 'width', 'height'],
span: ['data-wiki-slug'], // marks internal wiki links (used from Phase 3)
th: ['colspan', 'rowspan'],
td: ['colspan', 'rowspan'],
},
// http/https for links and images, mailto for links, plus relative URLs so
// uploaded images (/uploads/...) and internal links (/wiki/...) pass through.
allowedSchemes: ['http', 'https', 'mailto'],
allowedSchemesByTag: { img: ['http', 'https'] },
allowProtocolRelative: false,
// Force safe rel on links that open a new tab; drop empty/odd attributes.
transformTags: {
a: sanitizeHtml.simpleTransform('a', { rel: 'noopener noreferrer nofollow' }, true),
},
disallowedTagsMode: 'discard',
}
/**
* Sanitize a block of body HTML against the allowlist above.
* Null/empty input is returned unchanged.
* @param {string|null|undefined} html
* @returns {string|null|undefined}
*/
function cleanBody(html) {
if (html == null || html === '') return html
return sanitizeHtml(String(html), OPTIONS)
}
/**
* Derive a plain-text excerpt from body HTML. Strips tags, collapses
* whitespace, and truncates to `max` chars (with an ellipsis). Used as the
* excerpt fallback when an author leaves the excerpt field blank.
* @param {string|null|undefined} html
* @param {number} [max=280]
* @returns {string|null}
*/
function deriveExcerpt(html, max = 280) {
if (html == null) return null
const text = String(html)
.replace(/<[^>]+>/g, ' ')
.replace(/\s+/g, ' ')
.trim()
if (!text) return null
return text.length > max ? `${text.slice(0, max - 3)}...` : text
}
module.exports = { cleanBody, deriveExcerpt, OPTIONS }