diff --git a/server/src/utils/forumHtml.js b/server/src/utils/forumHtml.js new file mode 100644 index 0000000..d6e0b4c --- /dev/null +++ b/server/src/utils/forumHtml.js @@ -0,0 +1,193 @@ +// ── The forum's own HTML profile, and core's image renderer ──────────────── +// +// TEAMS.md §5.5.3, which is the load-bearing decision of the whole forum design +// and is deliberately NOT how the rest of the site works. +// +// **The author never writes an `` tag.** Core's shared sanitizer +// (utils/sanitizeHtml.js) allows `` from any http/https host — it is tuned +// for rich text from the ADMIN editor, where the author is already trusted. +// Handing that profile to arbitrary players would make `teams_forum_images` +// unenforceable: every post could hotlink in every mode and the setting would be +// decoration. So the forum derives its own profile in which `img` is never an +// allowed tag, in any mode. +// +// What an author writes is a URL. What decides whether it becomes a picture is +// this file's renderer, at READ time: +// +// author types: https://example.com/banner.png +// stored HTML: https://… +// rendered: that link, and — in `remote`/`uploads` mode only — a +// core-generated beneath it +// +// Five properties fall out, and they are the reason for the design: +// +// 1. The policy is ENFORCEABLE, because the only code that can emit an +// is this file. +// 2. Flipping the setting back to `disabled` retroactively un-renders every +// image on every existing post, with NO data migration — the images were +// never in the stored HTML. +// 3. No attribute smuggling: no author-supplied srcset, onerror, width=99999 +// or style. Core emits a fixed attribute set. +// 4. The link always survives. A blocked, dead or 404ing image degrades to the +// URL the author actually wrote, which is what the reader wanted anyway. +// 5. It matches how forums conventionally behave. +// +// **Never proxy or cache a remote image server-side.** The moment the server +// fetches a user-supplied URL it is an SSRF vector, and an allow-set is useless +// here because the whole point is arbitrary hosts. The browser fetches; the +// server never does. Written down so nobody adds a proxy "for performance". + +const sanitizeHtml = require('sanitize-html') + +// Derived from the shared profile with the image family removed. `figure` and +// `figcaption` go with `img` rather than surviving it: without an image inside, +// a figure is an empty box, and leaving them would let an author build a caption +// for a picture core decided not to render. +const FORUM_OPTIONS = { + allowedTags: [ + 'h3', 'h4', 'h5', 'h6', + 'p', 'br', 'hr', 'blockquote', 'pre', 'code', + 'ul', 'ol', 'li', + 'strong', 'b', 'em', 'i', 'u', 's', 'sup', 'sub', 'mark', 'span', + 'a', + 'table', 'thead', 'tbody', 'tr', 'th', 'td', + ], + allowedAttributes: { + // `rel` is allowed only so the transform below can WRITE it — an author's own + // rel is overwritten, not merged. Without it here, sanitize-html strips the + // very attribute the transform just added and every link ships without + // noopener. + a: ['href', 'title', 'rel'], + th: ['colspan', 'rowspan'], + td: ['colspan', 'rowspan'], + }, + // No `style` at all, and therefore no allowedStyles. The shared profile permits + // text-align for the admin editor's block alignment; a forum post has no such + // editor and every style attribute a player could send is one more thing to + // reason about. + allowedSchemes: ['http', 'https', 'mailto'], + allowProtocolRelative: false, + transformTags: { + a: sanitizeHtml.simpleTransform('a', { rel: 'noopener noreferrer nofollow' }, true), + }, + disallowedTagsMode: 'discard', +} + +// What may become a picture. Conservative on purpose: guessing wrong renders an +// pointed at something that is not an image, which reads as a broken site. +const IMAGE_EXTENSIONS = ['.png', '.jpg', '.jpeg', '.gif', '.webp', '.avif'] + +// Tags whose text is left alone by the linkifier. Inside an anchor because +// nesting one is invalid; inside code/pre because a URL in a code sample is +// being shown, not offered. +const NO_LINKIFY = new Set(['a', 'code', 'pre']) + +const BARE_URL = /\bhttps?:\/\/[^\s<>"']+/g + +/** + * Sanitise a forum post body. Runs on WRITE; the stored value is already safe and + * is served without re-sanitising — the same contract the wiki and the CMS follow. + */ +function cleanForumBody(html) { + if (html == null || html === '') return html + return linkify(sanitizeHtml(String(html), FORUM_OPTIONS)) +} + +/** + * Turn bare URLs in text into anchors. + * + * Runs AFTER sanitising, over the sanitiser's own output, and only on text + * outside tags. That ordering is what makes it safe: every text node has already + * been HTML-escaped, so the matched URL can go into both the href and the link + * text unchanged — `&` is already `&`, which is what an attribute wants. + */ +function linkify(html) { + const tokens = String(html).split(/(<[^>]+>)/) + const openStack = [] + return tokens + .map((token) => { + if (token.startsWith('<')) { + const match = /^<\s*(\/?)\s*([a-zA-Z0-9]+)/.exec(token) + if (match) { + const [, closing, name] = match + const tag = name.toLowerCase() + if (closing) { + const at = openStack.lastIndexOf(tag) + if (at !== -1) openStack.splice(at, 1) + } else if (!token.endsWith('/>')) { + openStack.push(tag) + } + } + return token + } + if (openStack.some((tag) => NO_LINKIFY.has(tag))) return token + return token.replace(BARE_URL, (url) => { + // Trailing punctuation is far more likely to be the sentence's than the + // URL's — "see https://example.com." should not link the full stop. + const trimmed = url.replace(/[.,;:!?)\]]+$/, '') + const tail = url.slice(trimmed.length) + return `${trimmed}${tail}` + }) + }) + .join('') +} + +/** + * May this URL become a picture? + * + * `https:` only, because the CSP is `img-src 'self' data: https:` (config/csp.js) + * — an `http:` image is blocked by the browser and renders as a broken picture, + * so an `http:` URL stays a plain link. This is a real mismatch with the SHARED + * sanitizer, which permits `http` for `img`, and it is exactly the sort of thing + * that presents as "images are broken on my forum" with nothing in any log. + * + * Same-origin `/uploads/…` paths are embeddable too — that is where `uploads` + * mode puts a file, and `'self'` covers them under the same CSP. + */ +function isEmbeddableImageUrl(href) { + if (typeof href !== 'string' || href === '') return false + const decoded = href.replace(/&/g, '&') + let pathname + if (decoded.startsWith('/uploads/')) { + pathname = decoded.split(/[?#]/)[0] + } else { + let url + try { + url = new URL(decoded) + } catch { + return false + } + if (url.protocol !== 'https:') return false + pathname = url.pathname + } + const lower = pathname.toLowerCase() + return IMAGE_EXTENSIONS.some((ext) => lower.endsWith(ext)) +} + +/** + * Render a stored body for one viewer under one image policy. + * + * `disabled` returns the stored HTML byte-for-byte. The other two append a core- + * generated after each anchor whose href looks like an image — which is why + * the stored HTML is identical between the three modes, the property this whole + * design exists to give. + */ +function renderForumBody(storedHtml, mode) { + if (storedHtml == null || storedHtml === '') return storedHtml + if (mode !== 'remote' && mode !== 'uploads') return storedHtml + return String(storedHtml).replace(/]*href="([^"]*)"[^>]*>.*?<\/a>/gi, (anchor, href) => { + if (!isEmbeddableImageUrl(href)) return anchor + // A fixed attribute set, every time. `no-referrer` limits what leaks to the + // third-party host — it cannot prevent the request itself, which is the + // privacy cost stated in the admin help text rather than hidden. + return `${anchor}` + }) +} + +module.exports = { + cleanForumBody, + renderForumBody, + isEmbeddableImageUrl, + IMAGE_EXTENSIONS, + FORUM_OPTIONS, +}