Wiki Phase 1: categories, drafts/publish, HTML sanitization
Foundation & safety phase of the wiki upgrade (see WIKI_UPGRADE.md). Schema (additive, idempotent via ensureSchema): - new wiki_categories table; wiki_pages gains category_id, excerpt, published, published_at, sort_order, and a FULLTEXT index - migration ALTERs guarded with IF NOT EXISTS for existing databases - seed reworked into 4 sections with the 8 starter pages assigned Security: - new utils/sanitizeHtml.js (sanitize-html allowlist); wiki bodies are sanitized on every save, and the article renders through DOMPurify - strips <script>, event handlers (onerror), and javascript: URLs Backend: - public: published-only list with ?category filter + /wiki/categories - admin: extended page CRUD, PATCH publish toggle, category CRUD; drafts visible to admin, hidden from public - all writes logged to activity_log Frontend: - data-driven public wiki index (sections + real descriptions; removed hardcoded blurbs/Roman numerals) with ?category filtering - article: category breadcrumb + sanitized render - admin: Section/Status columns, draft/publish + section + excerpt in the editor, and a Manage sections modal Verified end-to-end against MariaDB 11: migration clean, XSS neutralized, drafts hidden, client builds, server boots. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
45
server/src/utils/sanitizeHtml.js
Normal file
45
server/src/utils/sanitizeHtml.js
Normal file
@@ -0,0 +1,45 @@
|
||||
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)
|
||||
}
|
||||
|
||||
module.exports = { cleanBody, OPTIONS }
|
||||
Reference in New Issue
Block a user