Files
HealthLog/src/lib/notifications/strip-html.ts
T
Marc-André Bombeck 347e14d26b Centralise notification HTML stripping behind one hardened helper
Five senders (APNs, Web Push, webhook, ntfy, email) each carried their
own single-pass replace(/<[^>]*>/g, "") — the shape CodeQL flags as
incomplete multi-character sanitization, because a removal can re-form
the pattern it removed. Whether one particular regex happens to be
idempotent is an argument about that regex; five copies of the argument
is how the next edit quietly breaks one of them.

The shared stripHtml re-applies the strip until the string stops
changing, with an iteration cap and a bracket-dropping fallback so no
tag-shaped sequence can survive any input. Plain text passes through
unchanged, same as before. Unit tests pin the nested-tag bypass shapes
and the no-surviving-tag invariant.

Resolves CodeQL alerts 38-42.
2026-08-29 19:20:42 +02:00

38 lines
1.6 KiB
TypeScript

/**
* Strip HTML tags from notification content, hardened against
* multi-character bypasses.
*
* Every plain-text sender (APNs, Web Push, webhook, ntfy, email) used to
* carry its own single-pass `replace(/<[^>]*>/g, "")` — the shape CodeQL
* flags as incomplete multi-character sanitization, because a removal can
* re-form the very pattern it removed (`<<b>script>` loses `<b>` and the
* remaining characters close ranks). Whether a given regex happens to be
* idempotent is an argument about that regex; five copies of the argument
* across five senders is how the next edit quietly breaks one of them.
*
* This helper makes the guarantee structural instead: re-apply the strip
* until the string stops changing, so a tag re-formed by an earlier
* removal is removed on the next pass no matter how deep the nesting. The
* iteration cap bounds CPU on adversarial input; a string still changing
* at the cap is pathological by construction, and for that remnant the
* angle brackets themselves are dropped so no `<...>` sequence can ever
* survive. Plain input without tags returns unchanged, same as the old
* single pass.
*/
const TAG_RE = /<[^>]*>/g;
const MAX_STRIP_PASSES = 10;
/** Remove HTML tags, re-applying until stable so stripped tags cannot re-form. */
export function stripHtml(text: string): string {
let out = text;
for (let i = 0; i < MAX_STRIP_PASSES; i++) {
const next = out.replace(TAG_RE, "");
if (next === out) return next;
out = next;
}
// Still changing after the cap — nesting this deep is adversarial.
// Drop the brackets outright so nothing tag-shaped remains.
return out.replace(/[<>]/g, "");
}