Files
HealthLog/src/lib/notifications/__tests__/strip-html.test.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

51 lines
1.9 KiB
TypeScript

import { describe, it, expect } from "vitest";
import { stripHtml } from "@/lib/notifications/strip-html";
/**
* The five plain-text senders used to strip tags with a local single-pass
* regex replace (CodeQL: incomplete multi-character sanitization). The
* hardened shared helper re-applies the strip until the string stops
* changing, so a tag re-formed by an earlier removal cannot survive. These
* cases pin the invariant that matters — no tag-shaped sequence in any
* output — and identity on plain input.
*/
describe("stripHtml", () => {
it("leaves plain input unchanged", () => {
expect(stripHtml("Blood pressure logged: 120/80")).toBe(
"Blood pressure logged: 120/80",
);
expect(stripHtml("")).toBe("");
});
it("strips simple tags exactly like the old single pass", () => {
expect(stripHtml("<b>urgent</b> reading")).toBe("urgent reading");
expect(stripHtml("line<br/>break")).toBe("linebreak");
expect(stripHtml("a < b and b > a")).toBe("a a");
});
it("removes the classic nested-tag bypass shapes", () => {
expect(stripHtml("<<b>script>alert(1)")).toBe("script>alert(1)");
expect(stripHtml("<<b>script>")).toBe("script>");
expect(stripHtml("<<<i><b>script>>payload")).toBe("script>>payload");
});
it("never lets a tag-shaped sequence survive any adversarial input", () => {
const hostile = [
"<<b>script>alert(1)<</b>/script>",
"<scr<b>ipt>alert(1)</scr<b>ipt>",
"<".repeat(64) + "b>".repeat(64) + "script>alert(1)",
'<img src=">" onerror=alert(1)>',
"<a<b<c<d>e>f>g>",
];
for (const input of hostile) {
expect(stripHtml(input), input).not.toMatch(/<[^>]*>/);
}
});
it("handles unterminated brackets without stripping legitimate text", () => {
// No closing `>` anywhere after the `<` — nothing tag-shaped to remove.
expect(stripHtml("<div")).toBe("<div");
expect(stripHtml("2 < 3 <b")).toBe("2 < 3 <b");
});
});