// ── 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']) // Absolute http(s) URLs, and root-relative `/uploads/…` paths. // // The second alternative is not a nicety. In `uploads` mode the composer hands // the author a path like `/uploads/1787…-ab12.png`, puts it in the body as TEXT // (the author never writes markup — that is the whole design), and the renderer // only ever rewrites ANCHORS. Without this branch the write path cannot produce // the anchor the read path looks for, so an uploaded image could never become a // picture — even though `isEmbeddableImageUrl` was written to accept exactly // these paths. The two halves disagreed, and only a real upload showed it. // // Deliberately narrow: `/uploads/` and nothing else, so ordinary prose that // happens to contain a slash is left alone. const BARE_URL = /\bhttps?:\/\/[^\s<>"']+|(?:^|(?<=[\s(]))\/uploads\/[A-Za-z0-9._~-]+(?:\/[A-Za-z0-9._~-]+)*/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. // // `class` rather than an inline style, for two things the live rig showed: // the embed has to sit BENEATH the link (§5.5.3) and an is inline, so // without it the picture lands beside the URL; and a remote image is any size // its host chooses, so it needs a max-width or one post can blow the column // out. Both live in core's stylesheet (`.forum-embed`) because a style // attribute would then have to survive the client's DOMPurify pass, and its // CSS sanitiser is a larger thing to reason about than one class name. return `${anchor}` }) } module.exports = { cleanForumBody, renderForumBody, isEmbeddableImageUrl, IMAGE_EXTENSIONS, FORUM_OPTIONS, }