Merge pull request #3723 from Buco7854/feat/customcss-status-page
Feat/customcss status page
This commit is contained in:
22 files changed
+383
-17
No files matched your search
Generated
+20
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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")}
|
||||
|
||||
@@ -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" }}
|
||||
|
||||
@@ -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)
|
||||
);
|
||||
};
|
||||
@@ -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;
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": [
|
||||
{
|
||||
|
||||
Generated
+1
@@ -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",
|
||||
|
||||
@@ -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();
|
||||
};
|
||||
};
|
||||
@@ -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(),
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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"] }),
|
||||
])
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user