Merge pull request #3723 from Buco7854/feat/customcss-status-page

Feat/customcss status page
This commit is contained in:
Alexander Holliday authored and GitHub committed 2026-07-02 09:11:02 -07:00
commit 105d7f0580
22 files changed
+383 -17

No files matched your search

+20
View File
@@ -8,6 +8,7 @@
"name": "client",
"version": "3.9.0",
"dependencies": {
"@csstools/css-tokenizer": "^3.0.4",
"@emotion/react": "^11.13.3",
"@emotion/styled": "^11.13.0",
"@hello-pangea/dnd": "^18.0.0",
@@ -303,6 +304,25 @@
"node": ">=6.9.0"
}
},
"node_modules/@csstools/css-tokenizer": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-3.0.4.tgz",
"integrity": "sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/csstools"
},
{
"type": "opencollective",
"url": "https://opencollective.com/csstools"
}
],
"license": "MIT",
"engines": {
"node": ">=18"
}
},
"node_modules/@ctrl/tinycolor": {
"version": "4.2.0",
"license": "MIT",
+1
View File
@@ -17,6 +17,7 @@
"**/*": "prettier --write --ignore-unknown"
},
"dependencies": {
"@csstools/css-tokenizer": "^3.0.4",
"@emotion/react": "^11.13.3",
"@emotion/styled": "^11.13.0",
"@hello-pangea/dnd": "^18.0.0",
+1 -1
View File
@@ -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",
},
@@ -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);
@@ -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")}
+19 -11
View File
@@ -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";
@@ -16,6 +17,7 @@ import {
} from "@/Types/StatusPage";
import {
buildStatusPageApiPath,
getStatusPagePreviewUrl,
getStatusPagePublicUrl,
isCustomDomainHost,
} from "@/Utils/statusPageUrl";
@@ -127,17 +129,9 @@ 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}
@@ -145,12 +139,17 @@ const StatusPageView = () => {
timezone={statusPage.timezone}
paintBody
>
{themedRenderer}
<BaseStatusPage
statusPage={statusPage}
monitors={monitors}
config={themeConfig}
/>
</StatusPageThemeProvider>
);
}
const publicUrl = getStatusPagePublicUrl(statusPage);
const previewUrl = getStatusPagePreviewUrl(statusPage);
return (
<BasePage
loading={isLoading}
@@ -169,7 +168,16 @@ const StatusPageView = () => {
timezone={statusPage.timezone}
transparent
>
<BrowserFrame url={publicUrl}>{themedRenderer}</BrowserFrame>
<BrowserFrame url={publicUrl}>
<Box
component="iframe"
src={previewUrl}
title={t("pages.statusPages.preview.title")}
flex={1}
width="100%"
border={0}
/>
</BrowserFrame>
</StatusPageThemeProvider>
</BasePage>
);
@@ -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>
);
};
@@ -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" }}
+16
View File
@@ -0,0 +1,16 @@
import { isTokenString, isTokenURL, tokenize } from "@csstools/css-tokenizer";
// Mirrors the server check (server/src/utils/customCss.ts) so the form rejects
// the same CSS the API would.
const EXTERNAL_TARGET = /^\s*(?:https?:|\/\/)/i;
export const cssReferencesExternalResource = (css?: string | null): boolean => {
if (typeof css !== "string" || css === "") {
return false;
}
return tokenize({ css }).some(
(token) =>
(isTokenURL(token) || isTokenString(token)) && EXTERNAL_TARGET.test(token[4].value)
);
};
+10
View File
@@ -70,6 +70,16 @@ export const getStatusPagePublicUrl = (
return `${PUBLIC_STATUS_PAGE_PREFIX}/${statusPage.url}`;
};
// Same-origin URL for the admin preview iframe; a custom domain would be cross
// origin and blocked by the admin document's CSP frame-src.
export const getStatusPagePreviewUrl = (statusPage: Pick<StatusPage, "url">): string => {
if (typeof window !== "undefined") {
return `${window.location.origin}${PUBLIC_STATUS_PAGE_PREFIX}/${statusPage.url}`;
}
return `${PUBLIC_STATUS_PAGE_PREFIX}/${statusPage.url}`;
};
export const buildStatusPageApiPath = (options: {
url?: string;
useCustomDomain?: boolean;
+9 -1
View File
@@ -1,6 +1,7 @@
import { z } from "zod";
import type { FieldPath } from "react-hook-form";
import { STATUS_PAGE_THEMES, STATUS_PAGE_THEME_MODES } from "@/Types/StatusPage";
import { cssReferencesExternalResource } from "@/Utils/customCss";
// Wizard step a field is validated on. Attached inline to each field below so
// the grouping lives next to the field definition; unannotated fields default
@@ -58,7 +59,14 @@ 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")
.refine((css) => !cssReferencesExternalResource(css), {
message: "Custom CSS cannot reference external URLs or use @import",
})
.optional()
.register(statusPageStepRegistry, { step: 1 }),
theme: z
.enum(STATUS_PAGE_THEMES)
.optional()
+13
View File
@@ -1598,6 +1598,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.",
@@ -1620,6 +1630,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": {
+8
View File
@@ -10520,6 +10520,10 @@
"showInfrastructure": {
"type": "boolean"
},
"customCSS": {
"type": "string",
"maxLength": 100000
},
"removeLogo": {
"anyOf": [
{
@@ -10734,6 +10738,10 @@
"showInfrastructure": {
"type": "boolean"
},
"customCSS": {
"type": "string",
"maxLength": 100000
},
"removeLogo": {
"anyOf": [
{
+1
View File
@@ -9,6 +9,7 @@
"version": "1.0.0",
"license": "ISC",
"dependencies": {
"@csstools/css-tokenizer": "^3.0.4",
"@grpc/grpc-js": "^1.14.3",
"@grpc/proto-loader": "^0.8.0",
"axios": "^1.15.2",
+1
View File
@@ -31,6 +31,7 @@
"node": ">=20"
},
"dependencies": {
"@csstools/css-tokenizer": "^3.0.4",
"@grpc/grpc-js": "^1.14.3",
"@grpc/proto-loader": "^0.8.0",
"axios": "^1.15.2",
@@ -0,0 +1,27 @@
import type { NextFunction, Request, Response } from "express";
import { normalizeStatusPageDomain } from "@/utils/statusPageDomain.js";
const PUBLIC_STATUS_PAGE_DOCUMENT_PREFIX = "/status/public";
// Browsers enforce the intersection of all CSP headers, so this only tightens
// the status page on top of the global helmet policy, blocking external images,
// fonts, and stylesheets from custom CSS while keeping the app's Google Fonts.
const STATUS_PAGE_CSP = [
"img-src 'self' data:",
"font-src 'self' data: https://fonts.gstatic.com",
"style-src 'self' 'unsafe-inline' https://fonts.googleapis.com",
].join("; ");
// A status page document is served on the public path or on a custom domain
// (any host other than the app's own). Mirrors the client isCustomDomainHost.
export const createStatusPageDocumentCsp = (clientHost: string) => {
const appHostname = normalizeStatusPageDomain(clientHost);
return (req: Request, res: Response, next: NextFunction) => {
const onCustomDomain = appHostname !== null && normalizeStatusPageDomain(req.hostname) !== appHostname;
if (req.path.startsWith(PUBLIC_STATUS_PAGE_DOCUMENT_PREFIX) || onCustomDomain) {
res.append("Content-Security-Policy", STATUS_PAGE_CSP);
}
return next();
};
};
+4 -3
View File
@@ -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 {
@@ -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() {
@@ -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,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(),
+3
View File
@@ -13,6 +13,7 @@ import { InitializedControllers } from "@/config/controllers.js";
import { EnvConfig } from "@/domain/app-settings/app-settings.service.js";
import { createStatusPageCorsOrigin } from "@/api/middleware/statusPageCorsOrigin.js";
import { isPublicStatusPageApiPath } from "@/api/middleware/statusPagePublicApiPath.js";
import { createStatusPageDocumentCsp } from "@/api/middleware/statusPageDocumentCsp.js";
import { ApiServices } from "@/config/services.api.js";
export const createApp = ({
@@ -48,6 +49,8 @@ export const createApp = ({
return cors(corsOptions)(req, res, next);
});
app.use(createStatusPageDocumentCsp(allowedOrigin));
app.use(express.static(frontendPath));
app.use(express.json());
+14
View File
@@ -0,0 +1,14 @@
import { isTokenString, isTokenURL, tokenize } from "@csstools/css-tokenizer";
// Absolute http(s) or protocol-relative target; data: and relative URLs stay allowed.
const EXTERNAL_TARGET = /^\s*(?:https?:|\/\/)/i;
// Tokenizing resolves comments, escapes, and line continuations so obfuscated
// targets can't hide. url-tokens cover url(); string-tokens cover @import and image-set().
export const cssReferencesExternalResource = (css?: string | null): boolean => {
if (typeof css !== "string" || css === "") {
return false;
}
return tokenize({ css }).some((token) => (isTokenURL(token) || isTokenString(token)) && EXTERNAL_TARGET.test(token[4].value));
};
@@ -0,0 +1,56 @@
import { describe, expect, it, jest } from "@jest/globals";
import type { NextFunction, Request, Response } from "express";
import { createStatusPageDocumentCsp } from "../../../src/api/middleware/statusPageDocumentCsp.ts";
const makeRes = (): Response => {
const res = {} as Response;
res.append = jest.fn().mockReturnValue(res) as unknown as Response["append"];
return res;
};
const cspMiddleware = createStatusPageDocumentCsp("http://localhost:10001");
const run = (req: Partial<Request>) => {
const res = makeRes();
const next = jest.fn() as unknown as NextFunction;
cspMiddleware(req as Request, res, next);
return { res, next };
};
const appendedValue = (res: Response): string => ((res.append as jest.Mock).mock.calls[0] as [string, string])[1];
describe("statusPageDocumentCsp", () => {
it("appends a tightened CSP on the public status page path", () => {
const { res, next } = run({ path: "/status/public/my-status-page", hostname: "localhost" });
expect(res.append).toHaveBeenCalledTimes(1);
const value = appendedValue(res);
expect(value).toContain("img-src 'self' data:");
expect(value).toContain("font-src 'self' data: https://fonts.gstatic.com");
expect(value).toContain("style-src 'self' 'unsafe-inline' https://fonts.googleapis.com");
expect(next).toHaveBeenCalledTimes(1);
});
it("does not restrict default-src or connect-src so the page can still reach the API", () => {
const { res } = run({ path: "/status/public/my-status-page", hostname: "localhost" });
const value = appendedValue(res);
expect(value).not.toContain("default-src");
expect(value).not.toContain("connect-src");
});
it("appends the CSP on a custom domain document at the host root", () => {
const { res, next } = run({ path: "/", hostname: "status.example.com" });
expect(res.append).toHaveBeenCalledTimes(1);
expect(appendedValue(res)).toContain("img-src 'self' data:");
expect(next).toHaveBeenCalledTimes(1);
});
it("leaves the app's own non-public routes untouched", () => {
const { res, next } = run({ path: "/status/create", hostname: "localhost" });
expect(res.append).not.toHaveBeenCalled();
expect(next).toHaveBeenCalledTimes(1);
});
});
+73
View File
@@ -0,0 +1,73 @@
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 references obfuscated with inline comments", () => {
expect(cssReferencesExternalResource('body{background:/*c*/url("http://evil.example/x")}')).toBe(true);
expect(cssReferencesExternalResource('@import/*c*/"http://evil.example/x";')).toBe(true);
expect(cssReferencesExternalResource('body{background:image-set(/*c*/"http://evil.example/x" 1x)}')).toBe(true);
});
it("allows a comment that makes url() a bad token the browser never fetches", () => {
expect(cssReferencesExternalResource('body{background:url(/*c*/ "http://evil.example/x")}')).toBe(false);
});
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);
});
it("flags a scheme split by a line continuation", () => {
expect(cssReferencesExternalResource('body{background:url("htt\\\nps://evil.example/x")}')).toBe(true);
expect(cssReferencesExternalResource('body{background:url("htt\\\r\nps://evil.example/x")}')).toBe(true);
});
it("flags a scheme split by a hex escape", () => {
expect(cssReferencesExternalResource('body{background:url("\\68 ttps://evil.example/x")}')).toBe(true);
});
});
@@ -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"] }),
])
);
}
});
});