From 5baada08ef05329ca18e61a5326fd136a0dd230d Mon Sep 17 00:00:00 2001 From: wtclaude Date: Tue, 18 Aug 2026 09:54:05 -0500 Subject: [PATCH] fix(teams): four defects the live rig found in the forum MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit None of these could fail a unit test, and three of them break the feature for the operator rather than for the code. **The uploads acknowledgement was a one-way door.** A settings form sends every field it owns, so once `teams_forum_images` was `uploads`, every later save re-sent `uploads` — and the gate fired on the VALUE being present rather than on the mode being SELECTED. The operator could never change a forum setting again, and the thing they would reach for in a hurry, switching the forum off, was exactly what came back 400. The gate now passes when an acknowledgement for the version in force is already on record AND uploads is already the stored mode: there is no new consent to take. A transition INTO uploads still asks, and a reworded notice is still caught by assertSettingsWritable. **An uploaded image could never become a picture.** `uploads` mode hands the composer `/uploads/.png`, the composer puts it in the body as text — the author never writes markup, which is the whole design — and the renderer only rewrites ANCHORS. The linkifier matched absolute http(s) URLs only, so the write path could not produce the anchor the read path looks for, even though `isEmbeddableImageUrl` had accepted those paths since the first commit. The two halves disagreed and only a real upload showed it. **The embed sat beside its link, not beneath it**, because an is inline, and nothing capped a remote image to the column — one post from a host serving a 4000px file would have blown the layout out. Core now emits `class="forum-embed"` and the stylesheet owns both. A class rather than an inline style because the style 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. **The panel's buttons had no button styling.** `btn-ghost` is a MODIFIER — every other call site in this codebase pairs it with the base `btn` — so alone it contributed colours and no geometry, and the controls rendered as bare boxes. Small inline actions use `pill`, which is what the rest of the admin surface uses for exactly these. Same class of mistake as the Material one in the Android M12 phase: the modifier carries no base. Also: the post body now re-sanitises client-side like every other body-HTML surface on this site, with `ADD_ATTR: ['referrerpolicy']`. That argument is load-bearing — DOMPurify's default allowlist carries `loading` but not `referrerpolicy`, so a plain sanitize() call silently strips the one attribute limiting what a remote embed leaks to the host serving it, which is the privacy property the admin help text promises. Co-Authored-By: Claude --- client/src/modules/TeamForumPanel.jsx | 50 +++++++++------- client/src/styles/theme.css | 12 ++++ .../model/teams/teamForumSettings.model.js | 33 +++++++--- .../src/router/v1/admin/admin.controller.js | 2 +- server/src/utils/forumHtml.js | 24 +++++++- server/test/teamForum.test.js | 60 ++++++++++++++++--- 6 files changed, 141 insertions(+), 40 deletions(-) diff --git a/client/src/modules/TeamForumPanel.jsx b/client/src/modules/TeamForumPanel.jsx index d961654..a823bdb 100644 --- a/client/src/modules/TeamForumPanel.jsx +++ b/client/src/modules/TeamForumPanel.jsx @@ -1,5 +1,6 @@ import { useCallback, useEffect, useState } from 'react' import { useSearchParams } from 'react-router-dom' +import DOMPurify from 'dompurify' import { api } from '../api/client.js' import { useAuth } from '../contexts/AuthContext.jsx' import { useSite } from '../contexts/SiteContext.jsx' @@ -118,7 +119,7 @@ export default function TeamForumPanel({ externalId, moduleId }) { Announcements {forum.canPost && !composing && ( - )} @@ -225,7 +226,7 @@ function GuestManager({ slug }) { if (!open) { return ( - ) @@ -235,7 +236,7 @@ function GuestManager({ slug }) {

Forum guests

- +

Guests read and post in this forum without being members of the Team. They do not appear on the @@ -247,7 +248,7 @@ function GuestManager({ slug }) { {(data?.guests || []).map((g) => (

  • {g.username} - +
  • ))} {data && data.guests.length === 0 && ( @@ -257,14 +258,14 @@ function GuestManager({ slug }) {
    setUsername(e.target.value)} placeholder="Account name" maxLength={32} required /> - +
    {error &&

    {error}

    }
    @@ -274,7 +275,7 @@ function GuestManager({ slug }) { function ThreadView({ thread, canModerate, onBack, onModerate }) { return (
    -

    @@ -288,24 +289,33 @@ function ThreadView({ thread, canModerate, onBack, onModerate }) { {thread.posts.map((post) => (
    {/* - Rendered server-side under the operator's image policy, which is why - this is dangerouslySetInnerHTML and not a sanitizer call here. The body - was sanitised on write with the forum's own profile — one in which - `img` is never allowed — and any in it was emitted by core's own - renderer with a fixed attribute set. A client-side sanitiser would have - to strip exactly the tag core just decided to add. + Sanitised on write with the forum's own profile, rendered server-side + under the operator's image policy, and re-sanitised here — the same + defence-in-depth every other body-HTML surface on this site applies + (FiveOnFriday, NewsletterIssue, the rich-text block). + + `ADD_ATTR: ['referrerpolicy']` is load-bearing and not a preference. + DOMPurify's default allowlist carries `loading` but NOT + `referrerpolicy`, so a plain sanitize() call silently strips the one + attribute that limits what a remote embed leaks to the host serving it + — the privacy property the admin help text promises an operator. The + itself is core's own output with a fixed attribute set, so + nothing here is widening what an author can write. */} {/* eslint-disable-next-line react/no-danger */} -
    +
    ))} {canModerate && (
    - -
    @@ -352,7 +362,7 @@ function Composer({ slug, imageMode, onCancel, onPosted }) { return (
    setTitle(e.target.value)} placeholder="Title" @@ -360,7 +370,7 @@ function Composer({ slug, imageMode, onCancel, onPosted }) { required />