diff --git a/client/package-lock.json b/client/package-lock.json index c13d482de7..9d565620a2 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 6e9844b280..6b22084db9 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/Components/inputs/TextInput.tsx b/client/src/Components/inputs/TextInput.tsx index 13277be724..230e7650b3 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 8a7b480108..8dd119b729 100644 --- a/client/src/Pages/StatusPage/Create/index.tsx +++ b/client/src/Pages/StatusPage/Create/index.tsx @@ -162,6 +162,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); @@ -530,6 +531,33 @@ const CreateStatusPage = () => { } /> )} + {showStep(1) && ( + ( + + )} + /> + } + /> + )} {showStep(1) && ( { ); } - 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} + ); } const publicUrl = getStatusPagePublicUrl(statusPage); + const previewUrl = getStatusPagePreviewUrl(statusPage); return ( { 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 b893aa85c6..183e1fdead 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/Pages/StatusPage/Status/themes/shared/BaseStatusPage.tsx b/client/src/Pages/StatusPage/Status/themes/shared/BaseStatusPage.tsx index a6214567d9..1dfc0bd1bb 100644 --- a/client/src/Pages/StatusPage/Status/themes/shared/BaseStatusPage.tsx +++ b/client/src/Pages/StatusPage/Status/themes/shared/BaseStatusPage.tsx @@ -102,6 +102,7 @@ export const BaseStatusPage = ({ statusPage, monitors, config }: Props) => { return ( + {statusPage.customCSS && } { + 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/Utils/statusPageUrl.ts b/client/src/Utils/statusPageUrl.ts index ff19f87c6b..d5628d6bcc 100644 --- a/client/src/Utils/statusPageUrl.ts +++ b/client/src/Utils/statusPageUrl.ts @@ -70,6 +70,16 @@ export const getStatusPagePublicUrl = ( return `${PUBLIC_STATUS_PAGE_PREFIX}/${statusPage.url}`; }; +// Same-origin URL for the admin preview iframe; a custom domain would be cross +// origin and blocked by the admin document's CSP frame-src. +export const getStatusPagePreviewUrl = (statusPage: Pick): 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; diff --git a/client/src/Validation/statusPage.ts b/client/src/Validation/statusPage.ts index 271699765d..eddde8ab7b 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 @@ -58,7 +59,14 @@ 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") + .refine((css) => !cssReferencesExternalResource(css), { + message: "Custom CSS cannot reference external URLs or use @import", + }) + .optional() + .register(statusPageStepRegistry, { step: 1 }), theme: z .enum(STATUS_PAGE_THEMES) .optional() diff --git a/client/src/locales/en.json b/client/src/locales/en.json index e4bbc0f69f..3f32fbc85c 100644 --- a/client/src/locales/en.json +++ b/client/src/locales/en.json @@ -1594,6 +1594,16 @@ "light": "Light only", "dark": "Dark only" }, + "customCSS": { + "title": "Custom CSS", + "description": "Add custom CSS to fine-tune the look of your public status page.", + "option": { + "customCSS": { + "label": "Custom CSS", + "placeholder": "footer { display: none; }" + } + } + }, "features": { "title": "Features", "description": "Configure what information is displayed on your status page.", @@ -1616,6 +1626,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": { diff --git a/server/openapi.json b/server/openapi.json index d0bbe45dce..4b710b639d 100644 --- a/server/openapi.json +++ b/server/openapi.json @@ -10520,6 +10520,10 @@ "showInfrastructure": { "type": "boolean" }, + "customCSS": { + "type": "string", + "maxLength": 100000 + }, "removeLogo": { "anyOf": [ { @@ -10734,6 +10738,10 @@ "showInfrastructure": { "type": "boolean" }, + "customCSS": { + "type": "string", + "maxLength": 100000 + }, "removeLogo": { "anyOf": [ { diff --git a/server/package-lock.json b/server/package-lock.json index 61dd358757..9255f9889a 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", "axios": "^1.15.2", diff --git a/server/package.json b/server/package.json index b893dc29b8..f44dd7031f 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", "axios": "^1.15.2", diff --git a/server/src/api/middleware/statusPageDocumentCsp.ts b/server/src/api/middleware/statusPageDocumentCsp.ts new file mode 100644 index 0000000000..c5175b81d4 --- /dev/null +++ b/server/src/api/middleware/statusPageDocumentCsp.ts @@ -0,0 +1,27 @@ +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 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:", + "font-src 'self' data: https://fonts.gstatic.com", + "style-src 'self' 'unsafe-inline' https://fonts.googleapis.com", +].join("; "); + +// 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/api/routes/statusPageRoute.ts b/server/src/api/routes/statusPageRoute.ts index eab3aecfe9..816c7212af 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 { imageUpload } from "@/api/middleware/upload.js"; class StatusPageRoutes { @@ -15,12 +16,12 @@ class StatusPageRoutes { initRoutes(verifyJWT: RequestHandler, verifyStatusPageAccess: RequestHandler) { this.router.get("/team", verifyJWT, this.statusPageController.getStatusPagesByTeamId); - this.router.post("/", imageUpload.single("logo"), verifyJWT, this.statusPageController.createStatusPage); - this.router.put("/:id", imageUpload.single("logo"), verifyJWT, this.statusPageController.updateStatusPage); + this.router.post("/", imageUpload.single("logo"), verifyJWT, isAllowed(["admin", "superadmin"]), this.statusPageController.createStatusPage); + this.router.put("/:id", imageUpload.single("logo"), verifyJWT, isAllowed(["admin", "superadmin"]), this.statusPageController.updateStatusPage); this.router.get("/resolve", this.statusPageController.resolveStatusPageByDomain); 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() { diff --git a/server/src/api/validation/statusPageValidation.ts b/server/src/api/validation/statusPageValidation.ts index 2fa24be45a..a650129cd8 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,6 +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") + .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/app.ts b/server/src/app.ts index 94a1221371..8ef58d0453 100644 --- a/server/src/app.ts +++ b/server/src/app.ts @@ -13,6 +13,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 { createStatusPageDocumentCsp } from "@/api/middleware/statusPageDocumentCsp.js"; import { ApiServices } from "@/config/services.api.js"; export const createApp = ({ @@ -48,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()); diff --git a/server/src/utils/customCss.ts b/server/src/utils/customCss.ts new file mode 100644 index 0000000000..3f38a0bd5c --- /dev/null +++ b/server/src/utils/customCss.ts @@ -0,0 +1,14 @@ +import { isTokenString, isTokenURL, tokenize } from "@csstools/css-tokenizer"; + +// 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 tokenize({ css }).some((token) => (isTokenURL(token) || isTokenString(token)) && EXTERNAL_TARGET.test(token[4].value)); +}; diff --git a/server/test/unit/middleware/statusPageDocumentCsp.test.ts b/server/test/unit/middleware/statusPageDocumentCsp.test.ts new file mode 100644 index 0000000000..ab0c20abdb --- /dev/null +++ b/server/test/unit/middleware/statusPageDocumentCsp.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, it, jest } from "@jest/globals"; +import type { NextFunction, Request, Response } from "express"; +import { createStatusPageDocumentCsp } 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; +}; + +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 path", () => { + const { res, next } = run({ path: "/status/public/my-status-page", hostname: "localhost" }); + + expect(res.append).toHaveBeenCalledTimes(1); + 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("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); + }); +}); diff --git a/server/test/unit/utils/customCss.test.ts b/server/test/unit/utils/customCss.test.ts new file mode 100644 index 0000000000..bbdac71182 --- /dev/null +++ b/server/test/unit/utils/customCss.test.ts @@ -0,0 +1,73 @@ +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 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); + }); +}); diff --git a/server/test/unit/validation/statusPageValidation.test.ts b/server/test/unit/validation/statusPageValidation.test.ts new file mode 100644 index 0000000000..a2d659c343 --- /dev/null +++ b/server/test/unit/validation/statusPageValidation.test.ts @@ -0,0 +1,63 @@ +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); + }); + + 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"] }), + ]) + ); + } + }); +});