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:
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(),
|
||||
|
||||
@@ -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));
|
||||
};
|
||||
@@ -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"] }),
|
||||
])
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user