Files
TREK/shared/src/sanitize/sanitize.ts
T
Maurice fc7d8b5d12 Migrate TREK 3 to NestJS + React 19 with a shared Zod contract layer
Brownfield strangler migration of the backend onto NestJS modules
(auth, trips, days, places, assignments, packing, todo, budget,
reservations, collab, files, photos, journey, share, settings, backup,
oidc, oauth, admin, atlas, vacay, weather, airports, maps, categories,
tags, notifications, system-notices) served through a per-prefix
dispatcher, keeping the existing SQLite/better-sqlite3 DB and JWT
httpOnly cookie auth, with behavioural parity for every route.

Client: React 19 upgrade, "page = wiring container + data hook"
pattern across all pages, per-domain Zustand stores bound to
@trek/shared contracts, and decomposition of the large components
(DayPlanSidebar, PackingListPanel, CollabNotes, FileManager,
MemoriesPanel, PlacesSidebar, CollabChat, SystemNoticeModal,
BudgetPanel, PlaceFormModal, ...) into focused render units backed by
in-file hooks.

Apply the shared global request pipeline (helmet/CSP, CORS, HSTS,
forced HTTPS, the global MFA policy and request logging) to the NestJS
instance as well, so a migrated route is protected identically to the
legacy fallback rather than bypassing it.
2026-05-30 02:39:26 +02:00

83 lines
2.8 KiB
TypeScript

import DOMPurify from 'isomorphic-dompurify'
/**
* HTML sanitisation for TREK.
*
* TREK currently has no rich-text editor and no user-provided HTML reaches
* the database, so this module exists only to guard the handful of client
* sites that interpolate user-controlled strings into a markup template
* (today: the Journey suggestion banner). It is also the future home for
* sanitisation if TipTap / Markdown ever ships.
*
* Why isomorphic-dompurify: works unchanged in browser (DOMPurify) and Node
* (DOMPurify + jsdom). Tree-shakes correctly so the client bundle does not
* pull jsdom.
*/
// Inline-only tags. Block-level markup (paragraphs, lists, headings) is not
// expected in the surfaces we render today, so we keep the allow-list minimal
// and rely on `sanitizeRichTextHtml` when a richer surface needs full prose.
const INLINE_TAGS = [
'b', 'strong', 'i', 'em', 'u', 's', 'del', 'ins',
'mark', 'code', 'sub', 'sup', 'br', 'span',
] as const
const FULL_TAGS = [
...INLINE_TAGS,
'p', 'div', 'ul', 'ol', 'li',
'h1', 'h2', 'h3', 'h4', 'h5', 'h6',
'blockquote', 'pre', 'hr', 'a',
] as const
const SAFE_ATTRIBUTES = ['href', 'rel', 'target'] as const
/**
* Escapes the five HTML metacharacters so a raw string can be safely
* interpolated into an HTML template. Use this BEFORE substitution when a
* user-controlled value lands inside a markup-shaped translation string.
*
* This is *not* a substitute for `sanitizeInlineHtml`: escape input, then
* sanitise the resulting template — both layers run together in `tHtml`.
*/
export function escapeHtml(value: string): string {
return value
.replace(/&/g, '&')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;')
}
/**
* Strict inline sanitiser. Use for short, mostly-text strings that may include
* basic emphasis (`<strong>`, `<em>`, …) — e.g. the Journey suggestion banner
* where a translated template embeds a user-controlled trip title.
*
* Drops every tag outside the inline allow-list, strips all attributes, and
* blocks every URL scheme except http/https/mailto/tel via DOMPurify's
* built-in URL allow-list.
*/
export function sanitizeInlineHtml(html: string): string {
if (!html) return ''
return DOMPurify.sanitize(html, {
ALLOWED_TAGS: [...INLINE_TAGS],
ALLOWED_ATTR: [],
KEEP_CONTENT: true,
ALLOW_DATA_ATTR: false,
})
}
/**
* Permissive rich-text sanitiser. Use when a surface legitimately renders a
* prose document (lists, paragraphs, links). Keeps the same tag families as
* the inline sanitiser plus block-level markup and anchors with safe attrs.
*/
export function sanitizeRichTextHtml(html: string): string {
if (!html) return ''
return DOMPurify.sanitize(html, {
ALLOWED_TAGS: [...FULL_TAGS],
ALLOWED_ATTR: [...SAFE_ATTRIBUTES],
ALLOW_DATA_ATTR: false,
})
}