-
-
Notifications
You must be signed in to change notification settings - Fork 1.1k
Feat/customcss status page #3723
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 6 commits
73f95ac
0f4240d
25d8839
69e142f
6165ae6
3685e0c
96021b7
ffbf223
a4cc322
ea23610
52b95ad
969d24e
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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)); | ||
| }; | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Unless I'm misunderstanding how this works, this hand-rolled solution allows URLs with escape chars to bypass the sanitization: Run this snippet, it doesn't flag either of those cases: The browser will happily make those requests given those values that skip sanitization.
I don't know enough about CSS to feel confident hand rolling a validation here, it's probably best to outsource this to a well tested and maintained library
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I wasn't sure if you would have been fine with an other dependency thats why I avoided it. If you are fine with it I'll use https://github.com/csstools/tokenizer it's actually already pulled by an other dep so it doesn't change anything. I'll add CSP for status page so the client refuses to load those anyway if a different domain somehow manage to slip by. |
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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); | ||
| }); | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,63 @@ | ||
| import { describe, expect, it } from "@jest/globals"; | ||
| import { createStatusPageBodyValidation } from "../../../src/api/validation/statusPageValidation.ts"; | ||
|
|
||
| const baseCreateBody = (overrides: Record<string, unknown> = {}) => ({ | ||
| 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"] }), | ||
| ]) | ||
| ); | ||
| } | ||
| }); | ||
| }); |

Uh oh!
There was an error while loading. Please reload this page.