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.
This commit is contained in:
Buco7854 committed 2026-06-22 23:49:58 +00:00
1 parent 6165ae60e6
commit 3685e0c476
4 files changed
+101 -1

No files matched your search

@@ -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(),
+23
View File
@@ -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));
};
+58
View File
@@ -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,<svg xmlns='http://www.w3.org/2000/svg'></svg>")}`;
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);
});
});
@@ -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"] }),
])
);
}
});
});