Skip to content
Merged
Show file tree
Hide file tree
Changes from 6 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion client/src/Components/inputs/TextInput.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ export const TextInput = forwardRef<HTMLInputElement, TextInputProps>(function T
width: "100%",
"& .MuiOutlinedInput-root": {
borderRadius: theme.shape.borderRadius,
height: 34,
height: props.multiline ? "auto" : 34,
fontSize: typographyLevels.base,
overflow: "hidden",
},
Expand Down
28 changes: 28 additions & 0 deletions client/src/Pages/StatusPage/Create/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand Down Expand Up @@ -530,6 +531,33 @@ const CreateStatusPage = () => {
}
/>
)}
{showStep(1) && (
<ConfigBox
title={t("pages.statusPages.form.customCSS.title")}
subtitle={t("pages.statusPages.form.customCSS.description")}
rightContent={
<Controller
name="customCSS"
control={control}
render={({ field, fieldState }) => (
<TextField
{...field}
multiline
rows={8}
fieldLabel={t(
"pages.statusPages.form.customCSS.option.customCSS.label"
)}
placeholder={t(
"pages.statusPages.form.customCSS.option.customCSS.placeholder"
)}
error={!!fieldState.error}
helperText={fieldState.error?.message}
/>
)}
/>
}
/>
)}
{showStep(1) && (
<ConfigBox
title={t("pages.statusPages.form.features.title")}
Expand Down
28 changes: 17 additions & 11 deletions client/src/Pages/StatusPage/Status/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { BasePage, BaseFallback } from "@/Components/design-elements";
import Typography from "@mui/material/Typography";
import { Link } from "react-router-dom";
import Stack from "@mui/material/Stack";
import Box from "@mui/material/Box";

import { useTheme } from "@mui/material";
import { useTranslation } from "react-i18next";
Expand Down Expand Up @@ -121,25 +122,21 @@ const StatusPageView = () => {
);
}

const themeConfig = THEME_CONFIGS[resolveStatusPageTheme(statusPage.theme)];
const themedRenderer = (
<BaseStatusPage
statusPage={statusPage}
monitors={monitors}
config={themeConfig}
/>
);

// Public route: render directly on the viewport, themed background covers everything.
if (isPublic) {
const themeConfig = THEME_CONFIGS[resolveStatusPageTheme(statusPage.theme)];
return (
<StatusPageThemeProvider
theme={statusPage.theme}
themeMode={statusPage.themeMode}
timezone={statusPage.timezone}
paintBody
>
{themedRenderer}
<BaseStatusPage
statusPage={statusPage}
monitors={monitors}
config={themeConfig}
/>
</StatusPageThemeProvider>
);
}
Expand All @@ -163,7 +160,16 @@ const StatusPageView = () => {
timezone={statusPage.timezone}
transparent
>
<BrowserFrame url={publicUrl}>{themedRenderer}</BrowserFrame>
<BrowserFrame url={publicUrl}>
<Box
component="iframe"
src={publicUrl}
title={t("pages.statusPages.preview.title")}
flex={1}
width="100%"
border={0}
/>
</BrowserFrame>
</StatusPageThemeProvider>
</BasePage>
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,13 @@ export const BrowserFrame = ({ url, children }: Props) => {
{url}
</Box>
</Box>
<Box sx={{ flex: 1, minHeight: 0 }}>{children}</Box>
<Box
display="flex"
flexDirection="column"
sx={{ flex: 1, minHeight: 0 }}
>
{children}
</Box>
</Box>
);
};
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,7 @@ export const BaseStatusPage = ({ statusPage, monitors, config }: Props) => {

return (
<Box sx={styles.page}>
{statusPage.customCSS && <style>{statusPage.customCSS}</style>}
<Stack
component="header"
direction={{ xs: "column", md: "row" }}
Expand Down
6 changes: 5 additions & 1 deletion client/src/Validation/statusPage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,11 @@ 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")
.optional()
.register(statusPageStepRegistry, { step: 1 }),
Comment thread
Buco7854 marked this conversation as resolved.
theme: z
.enum(STATUS_PAGE_THEMES)
.optional()
Expand Down
13 changes: 13 additions & 0 deletions client/src/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -1579,6 +1579,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.",
Expand All @@ -1601,6 +1611,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": {
Expand Down
8 changes: 8 additions & 0 deletions server/openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -10498,6 +10498,10 @@
"showInfrastructure": {
"type": "boolean"
},
"customCSS": {
"type": "string",
"maxLength": 100000
},
"removeLogo": {
"anyOf": [
{
Expand Down Expand Up @@ -10712,6 +10716,10 @@
"showInfrastructure": {
"type": "boolean"
},
"customCSS": {
"type": "string",
"maxLength": 100000
},
"removeLogo": {
"anyOf": [
{
Expand Down
7 changes: 4 additions & 3 deletions server/src/api/routes/statusPageRoute.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -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() {
Expand Down
8 changes: 8 additions & 0 deletions server/src/api/validation/statusPageValidation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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(),
Expand Down
23 changes: 23 additions & 0 deletions server/src/utils/customCss.ts
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));
};

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The 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:

const decodeForScan = (css) => {
  return css
    .replace(/\/\*[\s\S]*?\*\//g, "")
    .replace(/\\([0-9a-fA-F]{1,6})\s?|\\([\s\S])/g, (_match, hex, char) => {
      if (!hex) {
        return char;
      }
      const codePoint = parseInt(hex, 16);
      return codePoint > 0 && codePoint <= 0x10ffff
        ? String.fromCodePoint(codePoint)
        : "�";
    });
};

const EXTERNAL_REFERENCE =
  /(?:@import\b|(?:url|image-set|-webkit-image-set|image|cross-fade|-webkit-cross-fade)\s*\(\s*['"]?\s*(?:https?:|\/\/))/i;

const cssReferencesExternalResource = (css) => {
  if (typeof css !== "string" || css === "") {
    return false;
  }
  return EXTERNAL_REFERENCE.test(decodeForScan(css));
};

const baseline = 'body{background:url("https://bypass/malicious")}';
const hexEscape =
  'body{background:url("htt\a ps://webhook.site/29d8a9b0-2092-4dfe-8377-51c59f150b3d")}';
const newLine =
  'body{background:url("htt\\\nps://webhook.site/29d8a9b0-2092-4dfe-8377-51c59f150b3d")}';

for (const [name, css] of [
  ["baseline", baseline],
  ["hex escaped \\a", hexEscape],
  ["new line", newLine],
]) {
  console.log(`${name}`);
  console.log(`${JSON.stringify(css)}`);
  console.log(JSON.stringify(decodeForScan(css)));
  console.log(cssReferencesExternalResource(css));
  console.log("--------------------------------------------------");
}

Run this snippet, it doesn't flag either of those cases:

baseline
"body{background:url(\"https://bypass/malicious\")}"
"body{background:url(\"https://bypass/malicious\")}"
true
--------------------------------------------------
hex-escaped \a
"body{background:url(\"htta ps://webhook.site/29d8a9b0-2092-4dfe-8377-51c59f150b3d\")}"
"body{background:url(\"htta ps://webhook.site/29d8a9b0-2092-4dfe-8377-51c59f150b3d\")}"
false
--------------------------------------------------
line continuation
"body{background:url(\"htt\\\nps://webhook.site/29d8a9b0-2092-4dfe-8377-51c59f150b3d\")}"
"body{background:url(\"htt\nps://webhook.site/29d8a9b0-2092-4dfe-8377-51c59f150b3d\")}"
false

The browser will happily make those requests given those values that skip sanitization.

Image

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

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The 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.

58 changes: 58 additions & 0 deletions server/test/unit/utils/customCss.test.ts
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);
});
});
63 changes: 63 additions & 0 deletions server/test/unit/validation/statusPageValidation.test.ts
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"] }),
])
);
}
});
});
Loading