Skip to content
Open
Show file tree
Hide file tree
Changes from 8 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
1 change: 1 addition & 0 deletions server/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions server/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
"@pulsecron/pulse": "1.6.8",
Expand Down
19 changes: 19 additions & 0 deletions server/src/api/middleware/statusPageDocumentCsp.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import type { NextFunction, Request, Response } from "express";

const PUBLIC_STATUS_PAGE_DOCUMENT_PREFIX = "/status/public";
Comment thread
Buco7854 marked this conversation as resolved.

// Browsers enforce the intersection of all CSP headers, so this only tightens the
// public status page on top of the global helmet policy: it blocks 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("; ");

export const statusPageDocumentCsp = (req: Request, res: Response, next: NextFunction) => {
if (req.path.startsWith(PUBLIC_STATUS_PAGE_DOCUMENT_PREFIX)) {
res.append("Content-Security-Policy", STATUS_PAGE_CSP);
}
return next();
};
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
2 changes: 2 additions & 0 deletions server/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,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 { statusPageDocumentCsp } from "@/api/middleware/statusPageDocumentCsp.js";

export const createApp = ({
services,
Expand Down Expand Up @@ -70,6 +71,7 @@ export const createApp = ({
},
})
);
app.use(statusPageDocumentCsp);
app.use(
compression({
level: 6,
Expand Down
14 changes: 14 additions & 0 deletions server/src/utils/customCss.ts
Original file line number Diff line number Diff line change
@@ -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));
};
34 changes: 34 additions & 0 deletions server/test/unit/middleware/statusPageDocumentCsp.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { describe, expect, it, jest } from "@jest/globals";
import type { NextFunction, Request, Response } from "express";
import { statusPageDocumentCsp } 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;
};

describe("statusPageDocumentCsp", () => {
it("appends a tightened CSP on the public status page document", () => {
const res = makeRes();
const next = jest.fn() as unknown as NextFunction;
statusPageDocumentCsp({ path: "/status/public/my-status-page" } as Request, res, next);

expect(res.append).toHaveBeenCalledTimes(1);
const [header, value] = (res.append as jest.Mock).mock.calls[0] as [string, string];
expect(header).toBe("Content-Security-Policy");
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("leaves other routes untouched", () => {
const res = makeRes();
const next = jest.fn() as unknown as NextFunction;
statusPageDocumentCsp({ path: "/status/create" } as Request, res, next);

expect(res.append).not.toHaveBeenCalled();
expect(next).toHaveBeenCalledTimes(1);
});
});
Loading
Loading