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>
64 lines
2.3 KiB
JavaScript
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 }
|