From 73f95ac8e1a87d7fc58553814c757bdbad6d7484 Mon Sep 17 00:00:00 2001 From: Buco7854 Date: Wed, 10 Jun 2026 09:47:19 +0000 Subject: [PATCH 01/10] feat: complete custom CSS support for status pages The customCSS field existed on the model, types, and form schema but was never editable, sent to the API, or rendered. Wire it end to end: - Accept customCSS in the create/update status page body validation and regenerate the OpenAPI spec - Add a Custom CSS editor (multiline field) to the status page form and include the value in the submitted form data - Support multiline mode in the shared TextInput component - Inject the saved CSS via a style tag when rendering the status page --- client/src/Components/inputs/TextInput.tsx | 2 +- client/src/Pages/StatusPage/Create/index.tsx | 28 +++++++++++++++++++ .../Status/themes/shared/BaseStatusPage.tsx | 1 + client/src/locales/en.json | 10 +++++++ server/openapi.json | 6 ++++ .../api/validation/statusPageValidation.ts | 1 + 6 files changed, 47 insertions(+), 1 deletion(-) diff --git a/client/src/Components/inputs/TextInput.tsx b/client/src/Components/inputs/TextInput.tsx index 13277be72..230e7650b 100644 --- a/client/src/Components/inputs/TextInput.tsx +++ b/client/src/Components/inputs/TextInput.tsx @@ -22,7 +22,7 @@ export const TextInput = forwardRef(function T width: "100%", "& .MuiOutlinedInput-root": { borderRadius: theme.shape.borderRadius, - height: 34, + height: props.multiline ? "auto" : 34, fontSize: typographyLevels.base, overflow: "hidden", }, diff --git a/client/src/Pages/StatusPage/Create/index.tsx b/client/src/Pages/StatusPage/Create/index.tsx index c9bc04838..921ca8bdb 100644 --- a/client/src/Pages/StatusPage/Create/index.tsx +++ b/client/src/Pages/StatusPage/Create/index.tsx @@ -157,6 +157,7 @@ const CreateStatusPage = () => { fd.append("showUptimePercentage", String(data.showUptimePercentage)); fd.append("showAdminLoginLink", String(data.showAdminLoginLink)); fd.append("showInfrastructure", String(data.showInfrastructure)); + fd.append("customCSS", data.customCSS ?? ""); if (data.theme) fd.append("theme", data.theme); if (data.themeMode) fd.append("themeMode", data.themeMode); @@ -504,6 +505,33 @@ const CreateStatusPage = () => { } /> )} + {showStep(1) && ( + ( + + )} + /> + } + /> + )} {showStep(1) && ( { return ( + {statusPage.customCSS && } Date: Wed, 10 Jun 2026 10:02:39 +0000 Subject: [PATCH 02/10] feat: cap custom CSS length and add validation tests Bound customCSS to 100000 characters on both the client schema and the server body validation (the server is the enforcement boundary; multer's 1MB field cap was the only limit), regenerate the OpenAPI spec, and add a validation test suite covering acceptance, clearing, the length cap, and type rejection. --- client/src/Validation/statusPage.ts | 6 ++- server/openapi.json | 6 ++- .../api/validation/statusPageValidation.ts | 2 +- .../validation/statusPageValidation.test.ts | 51 +++++++++++++++++++ 4 files changed, 61 insertions(+), 4 deletions(-) create mode 100644 server/test/unit/validation/statusPageValidation.test.ts diff --git a/client/src/Validation/statusPage.ts b/client/src/Validation/statusPage.ts index 542f6b2ae..3533fe017 100644 --- a/client/src/Validation/statusPage.ts +++ b/client/src/Validation/statusPage.ts @@ -35,7 +35,11 @@ export const statusPageSchema = z.object({ showUptimePercentage: z.boolean().register(statusPageStepRegistry, { step: 1 }), showAdminLoginLink: z.boolean().register(statusPageStepRegistry, { step: 1 }), showInfrastructure: z.boolean().register(statusPageStepRegistry, { step: 1 }), - customCSS: z.string().optional().register(statusPageStepRegistry, { step: 1 }), + customCSS: z + .string() + .max(100000, "Custom CSS must be at most 100000 characters") + .optional() + .register(statusPageStepRegistry, { step: 1 }), theme: z .enum(STATUS_PAGE_THEMES) .optional() diff --git a/server/openapi.json b/server/openapi.json index 838c94863..ae34fba04 100644 --- a/server/openapi.json +++ b/server/openapi.json @@ -10488,7 +10488,8 @@ "type": "boolean" }, "customCSS": { - "type": "string" + "type": "string", + "maxLength": 100000 }, "removeLogo": { "anyOf": [ @@ -10694,7 +10695,8 @@ "type": "boolean" }, "customCSS": { - "type": "string" + "type": "string", + "maxLength": 100000 }, "removeLogo": { "anyOf": [ diff --git a/server/src/api/validation/statusPageValidation.ts b/server/src/api/validation/statusPageValidation.ts index 3634d831f..c27ca3f15 100644 --- a/server/src/api/validation/statusPageValidation.ts +++ b/server/src/api/validation/statusPageValidation.ts @@ -32,7 +32,7 @@ export const createStatusPageBodyValidation = z showUptimePercentage: booleanCoercion, showAdminLoginLink: booleanCoercion.optional(), showInfrastructure: booleanCoercion.optional(), - customCSS: z.string().optional(), + customCSS: z.string().max(100000, "Custom CSS must be at most 100000 characters").optional(), removeLogo: z.union([z.literal("true"), z.literal("false")]).optional(), theme: z.enum(StatusPageThemes).optional(), themeMode: z.enum(StatusPageThemeModes).optional(), diff --git a/server/test/unit/validation/statusPageValidation.test.ts b/server/test/unit/validation/statusPageValidation.test.ts new file mode 100644 index 000000000..bc67efc42 --- /dev/null +++ b/server/test/unit/validation/statusPageValidation.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, it } from "@jest/globals"; +import { createStatusPageBodyValidation } from "../../../src/api/validation/statusPageValidation.ts"; + +const baseCreateBody = (overrides: Record = {}) => ({ + type: "uptime", + companyName: "Test Co", + url: "my-status-page", + monitors: ["0123456789abcdef01234567"], + isPublished: true, + showUptimePercentage: true, + ...overrides, +}); + +describe("createStatusPageBodyValidation", () => { + it("accepts a valid body without customCSS", () => { + const result = createStatusPageBodyValidation.safeParse(baseCreateBody()); + expect(result.success).toBe(true); + }); + + it("accepts and preserves customCSS", () => { + const customCSS = ".status-page > footer { display: none; }"; + const result = createStatusPageBodyValidation.safeParse(baseCreateBody({ customCSS })); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.customCSS).toBe(customCSS); + } + }); + + it("accepts an empty customCSS so it can be cleared", () => { + const result = createStatusPageBodyValidation.safeParse(baseCreateBody({ customCSS: "" })); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.customCSS).toBe(""); + } + }); + + it("rejects customCSS over the maximum length", () => { + const result = createStatusPageBodyValidation.safeParse(baseCreateBody({ customCSS: "a".repeat(100001) })); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.issues).toEqual( + expect.arrayContaining([expect.objectContaining({ message: "Custom CSS must be at most 100000 characters", path: ["customCSS"] })]) + ); + } + }); + + it("rejects a non-string customCSS", () => { + const result = createStatusPageBodyValidation.safeParse(baseCreateBody({ customCSS: 42 })); + expect(result.success).toBe(false); + }); +}); From 25d883944147a2d145004b208dded18a6a1a4ed7 Mon Sep 17 00:00:00 2001 From: Buco7854 Date: Wed, 10 Jun 2026 13:43:15 +0000 Subject: [PATCH 03/10] feat: render admin status page preview in an iframe The preview previously rendered the themed status page inline in the admin document, so document-global concerns leaked in both directions: custom CSS spilled into the admin UI and the theme body painting never showed. Pointing the BrowserFrame at the real public URL in an iframe isolates the CSS completely and makes the preview pixel-identical to what visitors see. Auth for unpublished pages keeps working because the same-origin iframe shares localStorage and the API client reads the token from the rehydrated store. --- client/src/Pages/StatusPage/Status/index.tsx | 28 +++++++++++-------- .../StatusPage/Status/themes/BrowserFrame.tsx | 8 +++++- client/src/locales/en.json | 3 ++ 3 files changed, 27 insertions(+), 12 deletions(-) diff --git a/client/src/Pages/StatusPage/Status/index.tsx b/client/src/Pages/StatusPage/Status/index.tsx index 9c4001fae..f2ac1a83e 100644 --- a/client/src/Pages/StatusPage/Status/index.tsx +++ b/client/src/Pages/StatusPage/Status/index.tsx @@ -2,6 +2,7 @@ import { BasePage, BaseFallback } from "@/Components/design-elements"; import Typography from "@mui/material/Typography"; import { Link } from "react-router-dom"; import Stack from "@mui/material/Stack"; +import Box from "@mui/material/Box"; import { useTheme } from "@mui/material"; import { useTranslation } from "react-i18next"; @@ -111,17 +112,9 @@ const StatusPageView = () => { ); } - const themeConfig = THEME_CONFIGS[resolveStatusPageTheme(statusPage.theme)]; - const themedRenderer = ( - - ); - // Public route: render directly on the viewport, themed background covers everything. if (isPublic) { + const themeConfig = THEME_CONFIGS[resolveStatusPageTheme(statusPage.theme)]; return ( { timezone={statusPage.timezone} paintBody > - {themedRenderer} + ); } @@ -153,7 +150,16 @@ const StatusPageView = () => { timezone={statusPage.timezone} transparent > - {themedRenderer} + + + ); diff --git a/client/src/Pages/StatusPage/Status/themes/BrowserFrame.tsx b/client/src/Pages/StatusPage/Status/themes/BrowserFrame.tsx index b893aa85c..183e1fdea 100644 --- a/client/src/Pages/StatusPage/Status/themes/BrowserFrame.tsx +++ b/client/src/Pages/StatusPage/Status/themes/BrowserFrame.tsx @@ -64,7 +64,13 @@ export const BrowserFrame = ({ url, children }: Props) => { {url} - {children} + + {children} + ); }; diff --git a/client/src/locales/en.json b/client/src/locales/en.json index a142156b2..b2a643f8b 100644 --- a/client/src/locales/en.json +++ b/client/src/locales/en.json @@ -1606,6 +1606,9 @@ "header": { "title": "Status pages", "description": "Publish public pages that show real-time uptime and incident history to your customers and stakeholders." + }, + "preview": { + "title": "Status page preview" } }, "uptime": { From 69e142f71180fe03627bbbdcf20d992e95368732 Mon Sep 17 00:00:00 2001 From: Buco7854 Date: Fri, 12 Jun 2026 15:43:58 +0000 Subject: [PATCH 04/10] fix: restrict status page mutations to admins and superadmins Creating, updating and deleting status pages was only guarded by verifyJWT, so any authenticated team member could set fields including the new customCSS via a direct API call. The admin gating existed only in the client UI (hidden buttons), and the create/configure routes had no guard, so the form was reachable by URL. Enforce the role at the API with isAllowed, matching the monitor routes. --- server/src/api/routes/statusPageRoute.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/server/src/api/routes/statusPageRoute.ts b/server/src/api/routes/statusPageRoute.ts index 807970e4b..db195b6f9 100644 --- a/server/src/api/routes/statusPageRoute.ts +++ b/server/src/api/routes/statusPageRoute.ts @@ -1,5 +1,6 @@ import { IStatusPageController } from "@/api/controllers/statusPageController.js"; import { RequestHandler, Router } from "express"; +import { isAllowed } from "@/api/middleware/isAllowed.js"; import multer from "multer"; const upload = multer(); @@ -16,11 +17,11 @@ class StatusPageRoutes { initRoutes(verifyJWT: RequestHandler, verifyStatusPageAccess: RequestHandler) { this.router.get("/team", verifyJWT, this.statusPageController.getStatusPagesByTeamId); - this.router.post("/", upload.single("logo"), verifyJWT, this.statusPageController.createStatusPage); - this.router.put("/:id", upload.single("logo"), verifyJWT, this.statusPageController.updateStatusPage); + this.router.post("/", upload.single("logo"), verifyJWT, isAllowed(["admin", "superadmin"]), this.statusPageController.createStatusPage); + this.router.put("/:id", upload.single("logo"), verifyJWT, isAllowed(["admin", "superadmin"]), this.statusPageController.updateStatusPage); this.router.get("/:url", verifyStatusPageAccess, this.statusPageController.getStatusPageByUrl); - this.router.delete("/:id", verifyJWT, this.statusPageController.deleteStatusPage); + this.router.delete("/:id", verifyJWT, isAllowed(["admin", "superadmin"]), this.statusPageController.deleteStatusPage); } getRouter() { From 3685e0c47642eb7388903a59d05153b503893a5a Mon Sep 17 00:00:00 2001 From: Buco7854 Date: Mon, 22 Jun 2026 22:53:47 +0000 Subject: [PATCH 05/10] feat: reject custom CSS that references external resources Reject status page custom CSS containing @import or a url() / image-set() / image() / cross-fade() whose target is http(s) or protocol-relative, while allowing data: and relative URLs. The check normalizes comments and CSS escapes first so obfuscated forms cannot slip past. Enforced in the create/update body validation. --- .../api/validation/statusPageValidation.ts | 9 ++- server/src/utils/customCss.ts | 23 ++++++++ server/test/unit/utils/customCss.test.ts | 58 +++++++++++++++++++ .../validation/statusPageValidation.test.ts | 12 ++++ 4 files changed, 101 insertions(+), 1 deletion(-) create mode 100644 server/src/utils/customCss.ts create mode 100644 server/test/unit/utils/customCss.test.ts diff --git a/server/src/api/validation/statusPageValidation.ts b/server/src/api/validation/statusPageValidation.ts index cb2ab4ed9..a650129cd 100644 --- a/server/src/api/validation/statusPageValidation.ts +++ b/server/src/api/validation/statusPageValidation.ts @@ -2,6 +2,7 @@ import { z } from "zod"; import { booleanCoercion, dnsHostnameRegex } from "./shared.js"; import { StatusPageTypes, StatusPageThemes, StatusPageThemeModes } from "@/domain/status-pages/status-page.type.js"; import { normalizeStatusPageDomain } from "@/utils/statusPageDomain.js"; +import { cssReferencesExternalResource } from "@/utils/customCss.js"; //**************************************** // Status Page Validations @@ -48,7 +49,13 @@ export const createStatusPageBodyValidation = z showUptimePercentage: booleanCoercion, showAdminLoginLink: booleanCoercion.optional(), showInfrastructure: booleanCoercion.optional(), - customCSS: z.string().max(100000, "Custom CSS must be at most 100000 characters").optional(), + customCSS: z + .string() + .max(100000, "Custom CSS must be at most 100000 characters") + .refine((css) => !cssReferencesExternalResource(css), { + message: "Custom CSS cannot reference external URLs or use @import", + }) + .optional(), removeLogo: z.union([z.literal("true"), z.literal("false")]).optional(), theme: z.enum(StatusPageThemes).optional(), themeMode: z.enum(StatusPageThemeModes).optional(), diff --git a/server/src/utils/customCss.ts b/server/src/utils/customCss.ts new file mode 100644 index 000000000..0705efe46 --- /dev/null +++ b/server/src/utils/customCss.ts @@ -0,0 +1,23 @@ +// Normalize the CSS the way a browser does before matching, so obfuscated forms +// (inline comments, CSS escapes like "\75 rl(") can't hide an external reference. +const decodeForScan = (css: string): string => + css.replace(/\/\*[\s\S]*?\*\//g, "").replace(/\\([0-9a-fA-F]{1,6})\s?|\\([\s\S])/g, (_match, hex: string, char: string) => { + if (!hex) { + return char; + } + const codePoint = parseInt(hex, 16); + return codePoint > 0 && codePoint <= 0x10ffff ? String.fromCodePoint(codePoint) : "�"; + }); + +// An external reference is @import, or a resource function (url, image-set, +// image, cross-fade) whose target starts with an http(s) scheme or is +// protocol-relative. Anchoring on the start of the target keeps data: and +// relative URLs allowed, including data: SVG that embeds the SVG namespace URL. +const EXTERNAL_REFERENCE = /(?:@import\b|(?:url|image-set|-webkit-image-set|image|cross-fade|-webkit-cross-fade)\s*\(\s*['"]?\s*(?:https?:|\/\/))/i; + +export const cssReferencesExternalResource = (css?: string | null): boolean => { + if (typeof css !== "string" || css === "") { + return false; + } + return EXTERNAL_REFERENCE.test(decodeForScan(css)); +}; diff --git a/server/test/unit/utils/customCss.test.ts b/server/test/unit/utils/customCss.test.ts new file mode 100644 index 000000000..1efdb8012 --- /dev/null +++ b/server/test/unit/utils/customCss.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, it } from "@jest/globals"; +import { cssReferencesExternalResource } from "../../../src/utils/customCss.ts"; + +describe("cssReferencesExternalResource", () => { + it("returns false for empty or non-string input", () => { + expect(cssReferencesExternalResource("")).toBe(false); + expect(cssReferencesExternalResource(null)).toBe(false); + expect(cssReferencesExternalResource(undefined)).toBe(false); + }); + + it("allows benign CSS", () => { + expect(cssReferencesExternalResource("li{outline:3px dashed red}footer{display:none}")).toBe(false); + }); + + it("allows data: and relative urls", () => { + expect(cssReferencesExternalResource("li{background:url(data:image/png;base64,AAAA)}")).toBe(false); + expect(cssReferencesExternalResource("li{background:url(/assets/logo.png)}")).toBe(false); + expect(cssReferencesExternalResource("li{background:url(logo.png)}")).toBe(false); + }); + + it("allows data: SVG that embeds the SVG namespace URL", () => { + const css = `li{background:url("data:image/svg+xml,")}`; + expect(cssReferencesExternalResource(css)).toBe(false); + }); + + it("flags @import", () => { + expect(cssReferencesExternalResource('@import url("https://evil.example/x.css");')).toBe(true); + expect(cssReferencesExternalResource('@import "https://evil.example/x.css";')).toBe(true); + }); + + it("flags external url()", () => { + expect(cssReferencesExternalResource('body{background:url("https://evil.example/x")}')).toBe(true); + expect(cssReferencesExternalResource("body{background:url(http://evil.example/x)}")).toBe(true); + }); + + it("flags protocol-relative url()", () => { + expect(cssReferencesExternalResource("body{background:url(//evil.example/x)}")).toBe(true); + }); + + it("flags external string-form image functions", () => { + expect(cssReferencesExternalResource('body{background:image-set("https://evil.example/x" 1x)}')).toBe(true); + expect(cssReferencesExternalResource('body{background:-webkit-image-set("//evil.example/x" 1x)}')).toBe(true); + expect(cssReferencesExternalResource('body{background-image:image("https://evil.example/x")}')).toBe(true); + }); + + it("flags url() obfuscated with a trailing parse error", () => { + expect(cssReferencesExternalResource("body{background:url(http://evil.example/x) !!!}")).toBe(true); + }); + + it("flags url() obfuscated with an inline comment", () => { + expect(cssReferencesExternalResource('body{background:url(/*c*/ "http://evil.example/x")}')).toBe(true); + }); + + it("flags url() obfuscated with CSS escapes", () => { + expect(cssReferencesExternalResource("body{background:\\75 rl(http://evil.example/x)}")).toBe(true); + expect(cssReferencesExternalResource('\\40 import "http://evil.example/x";')).toBe(true); + }); +}); diff --git a/server/test/unit/validation/statusPageValidation.test.ts b/server/test/unit/validation/statusPageValidation.test.ts index bc67efc42..a2d659c34 100644 --- a/server/test/unit/validation/statusPageValidation.test.ts +++ b/server/test/unit/validation/statusPageValidation.test.ts @@ -48,4 +48,16 @@ describe("createStatusPageBodyValidation", () => { const result = createStatusPageBodyValidation.safeParse(baseCreateBody({ customCSS: 42 })); expect(result.success).toBe(false); }); + + it("rejects customCSS that references an external resource", () => { + const result = createStatusPageBodyValidation.safeParse(baseCreateBody({ customCSS: 'body{background:url("https://evil.example/beacon")}' })); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.issues).toEqual( + expect.arrayContaining([ + expect.objectContaining({ message: "Custom CSS cannot reference external URLs or use @import", path: ["customCSS"] }), + ]) + ); + } + }); }); From 96021b747903e183440f92624c774c3047ddfd36 Mon Sep 17 00:00:00 2001 From: Buco7854 Date: Tue, 23 Jun 2026 19:25:18 +0000 Subject: [PATCH 06/10] refactor: detect external CSS references with the CSS Syntax tokenizer Replace the regex/escape-normalizer with @csstools/css-tokenizer (already present transitively). Tokenizing resolves comments, hex escapes, and line continuations into the real value before scanning, so obfuscated forms (a scheme split by a line continuation or hex escape) cannot slip past. Scan url-tokens and string-tokens to cover url(), quoted @import, and string-form image functions. Add regression tests for the bypasses. --- server/package-lock.json | 1 + server/package.json | 1 + server/src/utils/customCss.ts | 23 +++++++---------------- server/test/unit/utils/customCss.test.ts | 19 +++++++++++++++++-- 4 files changed, 26 insertions(+), 18 deletions(-) diff --git a/server/package-lock.json b/server/package-lock.json index 0f16e6b5d..5498a5549 100644 --- a/server/package-lock.json +++ b/server/package-lock.json @@ -9,6 +9,7 @@ "version": "1.0.0", "license": "ISC", "dependencies": { + "@csstools/css-tokenizer": "^3.0.4", "@grpc/grpc-js": "^1.14.3", "@grpc/proto-loader": "^0.8.0", "@pulsecron/pulse": "1.6.8", diff --git a/server/package.json b/server/package.json index 2ac0d47f9..8838e2c06 100755 --- a/server/package.json +++ b/server/package.json @@ -31,6 +31,7 @@ "node": ">=20" }, "dependencies": { + "@csstools/css-tokenizer": "^3.0.4", "@grpc/grpc-js": "^1.14.3", "@grpc/proto-loader": "^0.8.0", "@pulsecron/pulse": "1.6.8", diff --git a/server/src/utils/customCss.ts b/server/src/utils/customCss.ts index 0705efe46..3f38a0bd5 100644 --- a/server/src/utils/customCss.ts +++ b/server/src/utils/customCss.ts @@ -1,23 +1,14 @@ -// Normalize the CSS the way a browser does before matching, so obfuscated forms -// (inline comments, CSS escapes like "\75 rl(") can't hide an external reference. -const decodeForScan = (css: string): string => - css.replace(/\/\*[\s\S]*?\*\//g, "").replace(/\\([0-9a-fA-F]{1,6})\s?|\\([\s\S])/g, (_match, hex: string, char: string) => { - if (!hex) { - return char; - } - const codePoint = parseInt(hex, 16); - return codePoint > 0 && codePoint <= 0x10ffff ? String.fromCodePoint(codePoint) : "�"; - }); +import { isTokenString, isTokenURL, tokenize } from "@csstools/css-tokenizer"; -// An external reference is @import, or a resource function (url, image-set, -// image, cross-fade) whose target starts with an http(s) scheme or is -// protocol-relative. Anchoring on the start of the target keeps data: and -// relative URLs allowed, including data: SVG that embeds the SVG namespace URL. -const EXTERNAL_REFERENCE = /(?:@import\b|(?:url|image-set|-webkit-image-set|image|cross-fade|-webkit-cross-fade)\s*\(\s*['"]?\s*(?:https?:|\/\/))/i; +// Absolute http(s) or protocol-relative target; data: and relative URLs stay allowed. +const EXTERNAL_TARGET = /^\s*(?:https?:|\/\/)/i; +// Tokenizing resolves comments, escapes, and line continuations so obfuscated +// targets can't hide. url-tokens cover url(); string-tokens cover @import and image-set(). export const cssReferencesExternalResource = (css?: string | null): boolean => { if (typeof css !== "string" || css === "") { return false; } - return EXTERNAL_REFERENCE.test(decodeForScan(css)); + + return tokenize({ css }).some((token) => (isTokenURL(token) || isTokenString(token)) && EXTERNAL_TARGET.test(token[4].value)); }; diff --git a/server/test/unit/utils/customCss.test.ts b/server/test/unit/utils/customCss.test.ts index 1efdb8012..bbdac7118 100644 --- a/server/test/unit/utils/customCss.test.ts +++ b/server/test/unit/utils/customCss.test.ts @@ -47,12 +47,27 @@ describe("cssReferencesExternalResource", () => { expect(cssReferencesExternalResource("body{background:url(http://evil.example/x) !!!}")).toBe(true); }); - it("flags url() obfuscated with an inline comment", () => { - expect(cssReferencesExternalResource('body{background:url(/*c*/ "http://evil.example/x")}')).toBe(true); + it("flags references obfuscated with inline comments", () => { + expect(cssReferencesExternalResource('body{background:/*c*/url("http://evil.example/x")}')).toBe(true); + expect(cssReferencesExternalResource('@import/*c*/"http://evil.example/x";')).toBe(true); + expect(cssReferencesExternalResource('body{background:image-set(/*c*/"http://evil.example/x" 1x)}')).toBe(true); + }); + + it("allows a comment that makes url() a bad token the browser never fetches", () => { + expect(cssReferencesExternalResource('body{background:url(/*c*/ "http://evil.example/x")}')).toBe(false); }); it("flags url() obfuscated with CSS escapes", () => { expect(cssReferencesExternalResource("body{background:\\75 rl(http://evil.example/x)}")).toBe(true); expect(cssReferencesExternalResource('\\40 import "http://evil.example/x";')).toBe(true); }); + + it("flags a scheme split by a line continuation", () => { + expect(cssReferencesExternalResource('body{background:url("htt\\\nps://evil.example/x")}')).toBe(true); + expect(cssReferencesExternalResource('body{background:url("htt\\\r\nps://evil.example/x")}')).toBe(true); + }); + + it("flags a scheme split by a hex escape", () => { + expect(cssReferencesExternalResource('body{background:url("\\68 ttps://evil.example/x")}')).toBe(true); + }); }); From ffbf2232882d30e53b76d1f6dfff0e84864069b1 Mon Sep 17 00:00:00 2001 From: Buco7854 Date: Tue, 23 Jun 2026 19:25:40 +0000 Subject: [PATCH 07/10] feat: tighten CSP on the public status page document Layer a stricter Content-Security-Policy on the /status/public document so custom CSS cannot load external images, fonts, or stylesheets. Browsers enforce the intersection of all CSP headers, so this only narrows the public status page and never loosens the global policy, while keeping the app's Google Fonts. Browser-enforced backstop behind the validator. --- .../api/middleware/statusPageDocumentCsp.ts | 19 +++++++++++ server/src/app.ts | 2 ++ .../middleware/statusPageDocumentCsp.test.ts | 34 +++++++++++++++++++ 3 files changed, 55 insertions(+) create mode 100644 server/src/api/middleware/statusPageDocumentCsp.ts create mode 100644 server/test/unit/middleware/statusPageDocumentCsp.test.ts diff --git a/server/src/api/middleware/statusPageDocumentCsp.ts b/server/src/api/middleware/statusPageDocumentCsp.ts new file mode 100644 index 000000000..979f78791 --- /dev/null +++ b/server/src/api/middleware/statusPageDocumentCsp.ts @@ -0,0 +1,19 @@ +import type { NextFunction, Request, Response } from "express"; + +const PUBLIC_STATUS_PAGE_DOCUMENT_PREFIX = "/status/public"; + +// Browsers enforce the intersection of all CSP headers, so this only tightens the +// public status page on top of the global helmet policy: it blocks external images, +// fonts, and stylesheets from custom CSS while keeping the app's Google Fonts. +const STATUS_PAGE_CSP = [ + "img-src 'self' data:", + "font-src 'self' data: https://fonts.gstatic.com", + "style-src 'self' 'unsafe-inline' https://fonts.googleapis.com", +].join("; "); + +export const statusPageDocumentCsp = (req: Request, res: Response, next: NextFunction) => { + if (req.path.startsWith(PUBLIC_STATUS_PAGE_DOCUMENT_PREFIX)) { + res.append("Content-Security-Policy", STATUS_PAGE_CSP); + } + return next(); +}; diff --git a/server/src/app.ts b/server/src/app.ts index 907c0f77d..b79f71250 100644 --- a/server/src/app.ts +++ b/server/src/app.ts @@ -14,6 +14,7 @@ import { InitializedControllers } from "@/config/controllers.js"; import { EnvConfig } from "@/domain/app-settings/app-settings.service.js"; import { createStatusPageCorsOrigin } from "@/api/middleware/statusPageCorsOrigin.js"; import { isPublicStatusPageApiPath } from "@/api/middleware/statusPagePublicApiPath.js"; +import { statusPageDocumentCsp } from "@/api/middleware/statusPageDocumentCsp.js"; export const createApp = ({ services, @@ -70,6 +71,7 @@ export const createApp = ({ }, }) ); + app.use(statusPageDocumentCsp); app.use( compression({ level: 6, diff --git a/server/test/unit/middleware/statusPageDocumentCsp.test.ts b/server/test/unit/middleware/statusPageDocumentCsp.test.ts new file mode 100644 index 000000000..450728bfc --- /dev/null +++ b/server/test/unit/middleware/statusPageDocumentCsp.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it, jest } from "@jest/globals"; +import type { NextFunction, Request, Response } from "express"; +import { statusPageDocumentCsp } from "../../../src/api/middleware/statusPageDocumentCsp.ts"; + +const makeRes = (): Response => { + const res = {} as Response; + res.append = jest.fn().mockReturnValue(res) as unknown as Response["append"]; + return res; +}; + +describe("statusPageDocumentCsp", () => { + it("appends a tightened CSP on the public status page document", () => { + const res = makeRes(); + const next = jest.fn() as unknown as NextFunction; + statusPageDocumentCsp({ path: "/status/public/my-status-page" } as Request, res, next); + + expect(res.append).toHaveBeenCalledTimes(1); + const [header, value] = (res.append as jest.Mock).mock.calls[0] as [string, string]; + expect(header).toBe("Content-Security-Policy"); + expect(value).toContain("img-src 'self' data:"); + expect(value).toContain("font-src 'self' data: https://fonts.gstatic.com"); + expect(value).toContain("style-src 'self' 'unsafe-inline' https://fonts.googleapis.com"); + expect(next).toHaveBeenCalledTimes(1); + }); + + it("leaves other routes untouched", () => { + const res = makeRes(); + const next = jest.fn() as unknown as NextFunction; + statusPageDocumentCsp({ path: "/status/create" } as Request, res, next); + + expect(res.append).not.toHaveBeenCalled(); + expect(next).toHaveBeenCalledTimes(1); + }); +}); From a4cc322a55b1b6cc59e0e5b3b81aff636448b758 Mon Sep 17 00:00:00 2001 From: Buco7854 Date: Wed, 24 Jun 2026 18:21:16 +0000 Subject: [PATCH 08/10] feat: apply status page CSP to custom domains Detect custom domains by comparing the request host to the app host and apply the tightened CSP there too, not just on the public path. Run the middleware before express.static so static-served root documents get the header, and keep helmet after static so its default-src does not block custom domain API calls. --- .../api/middleware/statusPageDocumentCsp.ts | 22 +++++++--- server/src/app.ts | 5 ++- .../middleware/statusPageDocumentCsp.test.ts | 44 ++++++++++++++----- 3 files changed, 51 insertions(+), 20 deletions(-) diff --git a/server/src/api/middleware/statusPageDocumentCsp.ts b/server/src/api/middleware/statusPageDocumentCsp.ts index 979f78791..c5175b81d 100644 --- a/server/src/api/middleware/statusPageDocumentCsp.ts +++ b/server/src/api/middleware/statusPageDocumentCsp.ts @@ -1,9 +1,10 @@ import type { NextFunction, Request, Response } from "express"; +import { normalizeStatusPageDomain } from "@/utils/statusPageDomain.js"; const PUBLIC_STATUS_PAGE_DOCUMENT_PREFIX = "/status/public"; -// Browsers enforce the intersection of all CSP headers, so this only tightens the -// public status page on top of the global helmet policy: it blocks external images, +// Browsers enforce the intersection of all CSP headers, so this only tightens +// the status page on top of the global helmet policy, blocking external images, // fonts, and stylesheets from custom CSS while keeping the app's Google Fonts. const STATUS_PAGE_CSP = [ "img-src 'self' data:", @@ -11,9 +12,16 @@ const STATUS_PAGE_CSP = [ "style-src 'self' 'unsafe-inline' https://fonts.googleapis.com", ].join("; "); -export const statusPageDocumentCsp = (req: Request, res: Response, next: NextFunction) => { - if (req.path.startsWith(PUBLIC_STATUS_PAGE_DOCUMENT_PREFIX)) { - res.append("Content-Security-Policy", STATUS_PAGE_CSP); - } - return next(); +// A status page document is served on the public path or on a custom domain +// (any host other than the app's own). Mirrors the client isCustomDomainHost. +export const createStatusPageDocumentCsp = (clientHost: string) => { + const appHostname = normalizeStatusPageDomain(clientHost); + + return (req: Request, res: Response, next: NextFunction) => { + const onCustomDomain = appHostname !== null && normalizeStatusPageDomain(req.hostname) !== appHostname; + if (req.path.startsWith(PUBLIC_STATUS_PAGE_DOCUMENT_PREFIX) || onCustomDomain) { + res.append("Content-Security-Policy", STATUS_PAGE_CSP); + } + return next(); + }; }; diff --git a/server/src/app.ts b/server/src/app.ts index b79f71250..6c9ba03bc 100644 --- a/server/src/app.ts +++ b/server/src/app.ts @@ -14,7 +14,7 @@ import { InitializedControllers } from "@/config/controllers.js"; import { EnvConfig } from "@/domain/app-settings/app-settings.service.js"; import { createStatusPageCorsOrigin } from "@/api/middleware/statusPageCorsOrigin.js"; import { isPublicStatusPageApiPath } from "@/api/middleware/statusPagePublicApiPath.js"; -import { statusPageDocumentCsp } from "@/api/middleware/statusPageDocumentCsp.js"; +import { createStatusPageDocumentCsp } from "@/api/middleware/statusPageDocumentCsp.js"; export const createApp = ({ services, @@ -49,6 +49,8 @@ export const createApp = ({ return cors(corsOptions)(req, res, next); }); + app.use(createStatusPageDocumentCsp(allowedOrigin)); + app.use(express.static(frontendPath)); app.use(express.json()); @@ -71,7 +73,6 @@ export const createApp = ({ }, }) ); - app.use(statusPageDocumentCsp); app.use( compression({ level: 6, diff --git a/server/test/unit/middleware/statusPageDocumentCsp.test.ts b/server/test/unit/middleware/statusPageDocumentCsp.test.ts index 450728bfc..ab0c20abd 100644 --- a/server/test/unit/middleware/statusPageDocumentCsp.test.ts +++ b/server/test/unit/middleware/statusPageDocumentCsp.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it, jest } from "@jest/globals"; import type { NextFunction, Request, Response } from "express"; -import { statusPageDocumentCsp } from "../../../src/api/middleware/statusPageDocumentCsp.ts"; +import { createStatusPageDocumentCsp } from "../../../src/api/middleware/statusPageDocumentCsp.ts"; const makeRes = (): Response => { const res = {} as Response; @@ -8,25 +8,47 @@ const makeRes = (): Response => { return res; }; +const cspMiddleware = createStatusPageDocumentCsp("http://localhost:10001"); + +const run = (req: Partial) => { + const res = makeRes(); + const next = jest.fn() as unknown as NextFunction; + cspMiddleware(req as Request, res, next); + return { res, next }; +}; + +const appendedValue = (res: Response): string => ((res.append as jest.Mock).mock.calls[0] as [string, string])[1]; + describe("statusPageDocumentCsp", () => { - it("appends a tightened CSP on the public status page document", () => { - const res = makeRes(); - const next = jest.fn() as unknown as NextFunction; - statusPageDocumentCsp({ path: "/status/public/my-status-page" } as Request, res, next); + it("appends a tightened CSP on the public status page path", () => { + const { res, next } = run({ path: "/status/public/my-status-page", hostname: "localhost" }); expect(res.append).toHaveBeenCalledTimes(1); - const [header, value] = (res.append as jest.Mock).mock.calls[0] as [string, string]; - expect(header).toBe("Content-Security-Policy"); + const value = appendedValue(res); expect(value).toContain("img-src 'self' data:"); expect(value).toContain("font-src 'self' data: https://fonts.gstatic.com"); expect(value).toContain("style-src 'self' 'unsafe-inline' https://fonts.googleapis.com"); expect(next).toHaveBeenCalledTimes(1); }); - it("leaves other routes untouched", () => { - const res = makeRes(); - const next = jest.fn() as unknown as NextFunction; - statusPageDocumentCsp({ path: "/status/create" } as Request, res, next); + it("does not restrict default-src or connect-src so the page can still reach the API", () => { + const { res } = run({ path: "/status/public/my-status-page", hostname: "localhost" }); + + const value = appendedValue(res); + expect(value).not.toContain("default-src"); + expect(value).not.toContain("connect-src"); + }); + + it("appends the CSP on a custom domain document at the host root", () => { + const { res, next } = run({ path: "/", hostname: "status.example.com" }); + + expect(res.append).toHaveBeenCalledTimes(1); + expect(appendedValue(res)).toContain("img-src 'self' data:"); + expect(next).toHaveBeenCalledTimes(1); + }); + + it("leaves the app's own non-public routes untouched", () => { + const { res, next } = run({ path: "/status/create", hostname: "localhost" }); expect(res.append).not.toHaveBeenCalled(); expect(next).toHaveBeenCalledTimes(1); From ea23610b3a183bf43a7ecbd6bd03e2de48e7052f Mon Sep 17 00:00:00 2001 From: Buco7854 Date: Wed, 24 Jun 2026 18:21:27 +0000 Subject: [PATCH 09/10] fix: mirror server custom CSS validation on the client The form only enforced the length cap, so CSS with an external url() or @import passed client validation and was rejected by the server. Add the same tokenizer based check with the same error message so it is caught inline before submit. --- client/package-lock.json | 20 ++++++++++++++++++++ client/package.json | 1 + client/src/Utils/customCss.ts | 16 ++++++++++++++++ client/src/Validation/statusPage.ts | 4 ++++ 4 files changed, 41 insertions(+) create mode 100644 client/src/Utils/customCss.ts diff --git a/client/package-lock.json b/client/package-lock.json index c13d482de..9d565620a 100644 --- a/client/package-lock.json +++ b/client/package-lock.json @@ -8,6 +8,7 @@ "name": "client", "version": "3.8.1", "dependencies": { + "@csstools/css-tokenizer": "^3.0.4", "@emotion/react": "^11.13.3", "@emotion/styled": "^11.13.0", "@hello-pangea/dnd": "^18.0.0", @@ -303,6 +304,25 @@ "node": ">=6.9.0" } }, + "node_modules/@csstools/css-tokenizer": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-3.0.4.tgz", + "integrity": "sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/@ctrl/tinycolor": { "version": "4.2.0", "license": "MIT", diff --git a/client/package.json b/client/package.json index 6e9844b28..6b22084db 100644 --- a/client/package.json +++ b/client/package.json @@ -17,6 +17,7 @@ "**/*": "prettier --write --ignore-unknown" }, "dependencies": { + "@csstools/css-tokenizer": "^3.0.4", "@emotion/react": "^11.13.3", "@emotion/styled": "^11.13.0", "@hello-pangea/dnd": "^18.0.0", diff --git a/client/src/Utils/customCss.ts b/client/src/Utils/customCss.ts new file mode 100644 index 000000000..02a267daa --- /dev/null +++ b/client/src/Utils/customCss.ts @@ -0,0 +1,16 @@ +import { isTokenString, isTokenURL, tokenize } from "@csstools/css-tokenizer"; + +// Mirrors the server check (server/src/utils/customCss.ts) so the form rejects +// the same CSS the API would. +const EXTERNAL_TARGET = /^\s*(?:https?:|\/\/)/i; + +export const cssReferencesExternalResource = (css?: string | null): boolean => { + if (typeof css !== "string" || css === "") { + return false; + } + + return tokenize({ css }).some( + (token) => + (isTokenURL(token) || isTokenString(token)) && EXTERNAL_TARGET.test(token[4].value) + ); +}; diff --git a/client/src/Validation/statusPage.ts b/client/src/Validation/statusPage.ts index 6d4e2fd6e..eddde8ab7 100644 --- a/client/src/Validation/statusPage.ts +++ b/client/src/Validation/statusPage.ts @@ -1,6 +1,7 @@ import { z } from "zod"; import type { FieldPath } from "react-hook-form"; import { STATUS_PAGE_THEMES, STATUS_PAGE_THEME_MODES } from "@/Types/StatusPage"; +import { cssReferencesExternalResource } from "@/Utils/customCss"; // Wizard step a field is validated on. Attached inline to each field below so // the grouping lives next to the field definition; unannotated fields default @@ -61,6 +62,9 @@ export const statusPageSchema = z.object({ customCSS: z .string() .max(100000, "Custom CSS must be at most 100000 characters") + .refine((css) => !cssReferencesExternalResource(css), { + message: "Custom CSS cannot reference external URLs or use @import", + }) .optional() .register(statusPageStepRegistry, { step: 1 }), theme: z From 52b95ad1210e608755142b9ef3abeb6e149fd044 Mon Sep 17 00:00:00 2001 From: Buco7854 Date: Wed, 24 Jun 2026 18:21:40 +0000 Subject: [PATCH 10/10] fix: keep the admin status page preview iframe same-origin The preview framed the page public URL, which for a custom domain is cross origin and blocked by the admin document CSP frame-src. Point the iframe at the same-origin public path while the frame chrome still shows the public URL. --- client/src/Pages/StatusPage/Status/index.tsx | 4 +++- client/src/Utils/statusPageUrl.ts | 10 ++++++++++ 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/client/src/Pages/StatusPage/Status/index.tsx b/client/src/Pages/StatusPage/Status/index.tsx index 6aed8817b..834228843 100644 --- a/client/src/Pages/StatusPage/Status/index.tsx +++ b/client/src/Pages/StatusPage/Status/index.tsx @@ -17,6 +17,7 @@ import { } from "@/Types/StatusPage"; import { buildStatusPageApiPath, + getStatusPagePreviewUrl, getStatusPagePublicUrl, isCustomDomainHost, } from "@/Utils/statusPageUrl"; @@ -142,6 +143,7 @@ const StatusPageView = () => { } const publicUrl = getStatusPagePublicUrl(statusPage); + const previewUrl = getStatusPagePreviewUrl(statusPage); return ( { ): string => { + if (typeof window !== "undefined") { + return `${window.location.origin}${PUBLIC_STATUS_PAGE_PREFIX}/${statusPage.url}`; + } + + return `${PUBLIC_STATUS_PAGE_PREFIX}/${statusPage.url}`; +}; + export const buildStatusPageApiPath = (options: { url?: string; useCustomDomain?: boolean;