fix(security): keep the theme boot script a literal and drop regex tag stripping

The boot script interpolated its two colours; it is one string literal now,
checked against THEME_COLOR by the script's own tests. The retired-route
guard escapes every regex metacharacter including the backslash, and the
two markup tests read text by walking the string instead of stripping tags
with a regex.
This commit is contained in:
Marc-André Bombeck committed 2026-10-09 00:41:56 +02:00
1 parent 8b5199ee00
commit 312e5de2b2
4 files changed
+59 -18

No files matched your search

@@ -24,6 +24,11 @@ function walk(dir: string): string[] {
});
}
/** Every regex metacharacter escaped, the backslash included. */
function escapeRegExp(text: string): string {
return text.replace(/[\\^$.*+?()[\]{}|/]/g, "\\$&");
}
describe("no link targets a retired route", () => {
const files = walk(join(ROOT, "src"));
@@ -33,7 +38,7 @@ describe("no link targets a retired route", () => {
it.each(Object.entries(RETIRED))("%s is linked as %s", (route) => {
const pattern = new RegExp(
`(?:href|push|replace)[=(]\\s*\\{?\\s*["'\`]${route.replace(/\//g, "\\/")}["'\`?#]`,
`(?:href|push|replace)[=(]\\s*\\{?\\s*["'\`]${escapeRegExp(route)}["'\`?#]`,
);
const offenders = files
.filter((file) => pattern.test(readFileSync(file, "utf8")))
@@ -42,22 +42,38 @@ function Probe({
);
}
/**
* The text of rendered markup: every character outside a `<…>` tag. A walk
* rather than a tag-stripping regex, which can leave a tag behind when one
* is split across another.
*/
function textOf(html: string): string {
let out = "";
let inTag = false;
for (const ch of html) {
if (ch === "<") inTag = true;
else if (ch === ">") inTag = false;
else if (!inTag) out += ch;
}
return out;
}
function read(
type: string,
value: number,
unit: string,
locale: "en" | "de" = "en",
) {
return renderToStaticMarkup(
<I18nProvider
initialLocale={locale}
initialMessages={locale === "de" ? deMessages : enMessages}
>
<Probe type={type} value={value} unit={unit} />
</I18nProvider>,
)
.replace(/<[^>]+>/g, "")
.replace(/<!-- -->/g, "");
return textOf(
renderToStaticMarkup(
<I18nProvider
initialLocale={locale}
initialMessages={locale === "de" ? deMessages : enMessages}
>
<Probe type={type} value={value} unit={unit} />
</I18nProvider>,
),
);
}
describe("a day's values in their charts' spelling", () => {
@@ -48,6 +48,22 @@ const coverage = {
missing: ["ACTIVITY", "SLEEP"],
};
/**
* The text of rendered markup: every character outside a `<…>` tag. A walk
* rather than a tag-stripping regex, which can leave a tag behind when one
* is split across another.
*/
function textOf(html: string): string {
let out = "";
let inTag = false;
for (const ch of html) {
if (ch === "<") inTag = true;
else if (ch === ">") inTag = false;
else if (!inTag) out += ch;
}
return out;
}
function render(node: React.ReactNode, locale: Locale = "en"): string {
return renderToStaticMarkup(
<I18nProvider initialLocale={locale}>{node}</I18nProvider>,
@@ -642,9 +658,7 @@ describe("<HealthScoreCard> composite states", () => {
// The onset is an instant; it reads as the day it falls on in the
// profile timezone, in the user's date format, never as raw ISO.
// v1.42 — the date is a link to its day; the sentence reads the same.
expect(rest.replace(/<[^>]+>/g, "")).toMatch(
/Rest Mode active since \d{2}\/\d{2}\/2026\./,
);
expect(textOf(rest)).toMatch(/Rest Mode active since \d{2}\/\d{2}\/2026\./);
expect(rest).not.toContain("T23:30:00");
expect(rest).toContain('data-slot="health-score-tension"');
expect(rest).toContain('data-slot="health-score-return-to-band"');
@@ -672,7 +686,7 @@ describe("<HealthScoreCard> composite states", () => {
/>,
);
const rest = atRest(html);
expect(rest.replace(/<[^>]+>/g, "")).toMatch(
expect(textOf(rest)).toMatch(
/Rest Mode active since (07\/20\/2026|20\/07\/2026)/,
);
// The date opens the day it names, in the profile timezone.
+9 -3
View File
@@ -35,7 +35,13 @@ export function applyThemeColor(
* tags to that theme, which until now waited for hydration: a phone in light
* mode running the app in dark mode showed a light status bar until React
* caught up. The tags may stream in after the script, so it repeats the
* rewrite once the document is parsed. Built from constants only — the
* layout's one `dangerouslySetInnerHTML` stays file-literal content.
* rewrite once the document is parsed.
*
* One string literal, with the two colours written into it rather than
* interpolated from `THEME_COLOR`: the layout's one `dangerouslySetInnerHTML`
* then carries file-literal content and nothing is built from a value at
* runtime. `theme-color.test.ts` runs the script and compares what it paints
* against `THEME_COLOR`, so the copies cannot drift apart unnoticed.
*/
export const THEME_BOOT_SCRIPT = `(function(){var c="dark";try{var t=localStorage.getItem("healthlog-theme");c=(t==="light"||t==="dark")?t:(t==="system"?(window.matchMedia("(prefers-color-scheme:dark)").matches?"dark":"light"):"dark")}catch(e){}document.documentElement.classList.add(c);var k=c==="light"?${JSON.stringify(THEME_COLOR.light)}:${JSON.stringify(THEME_COLOR.dark)};function m(){var a=document.querySelectorAll('meta[name="theme-color"]');for(var i=0;i<a.length;i++)a[i].setAttribute("content",k)}m();document.addEventListener("DOMContentLoaded",m)})()`;
export const THEME_BOOT_SCRIPT =
'(function(){var c="dark";try{var t=localStorage.getItem("healthlog-theme");c=(t==="light"||t==="dark")?t:(t==="system"?(window.matchMedia("(prefers-color-scheme:dark)").matches?"dark":"light"):"dark")}catch(e){}document.documentElement.classList.add(c);var k=c==="light"?"#f3f2f5":"#282a36";function m(){var a=document.querySelectorAll(\'meta[name="theme-color"]\');for(var i=0;i<a.length;i++)a[i].setAttribute("content",k)}m();document.addEventListener("DOMContentLoaded",m)})()';