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"] }), + ]) + ); + } + }); });