-
-
Notifications
You must be signed in to change notification settings - Fork 620
feat: Status Page Custom CSS/JS/HTML overrides (Fixes #2863) #3076
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
base: develop
Are you sure you want to change the base?
Changes from 6 commits
0638624
f2f2469
9e7f44e
93c76bc
ef79ad9
8cba518
f99222c
63b5912
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Large diffs are not rendered by default.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,5 +1,5 @@ | ||
| // Components | ||
| import { Stack, Typography } from "@mui/material"; | ||
| import { Stack, Typography, Checkbox as MuiCheckbox, FormControlLabel } from "@mui/material"; | ||
| import { TabPanel } from "@mui/lab"; | ||
| import ConfigBox from "@/Components/v1/ConfigBox/index.jsx"; | ||
| import Checkbox from "@/Components/v1/Inputs/Checkbox/index.jsx"; | ||
|
|
@@ -8,13 +8,14 @@ import Search from "@/Components/v1/Inputs/Search/index.jsx"; | |
| import ImageUpload from "@/Components/v1/Inputs/ImageUpload/index.jsx"; | ||
| import ColorPicker from "@/Components/v1/Inputs/ColorPicker/index.jsx"; | ||
| import Progress from "../Progress/index.jsx"; | ||
| import TextField from "@mui/material/TextField"; | ||
|
|
||
| // Utils | ||
| import { useTheme } from "@emotion/react"; | ||
| import timezones from "../../../../../../Utils/timezones.json"; | ||
| import PropTypes from "prop-types"; | ||
| import { useTranslation } from "react-i18next"; | ||
| import { useMemo, useState, useCallback } from "react"; | ||
| import { useMemo, useState, useCallback, useEffect } from "react"; | ||
|
|
||
| const TabSettings = ({ | ||
| isCreate, | ||
|
|
@@ -30,6 +31,7 @@ const TabSettings = ({ | |
| const theme = useTheme(); | ||
| const { t } = useTranslation(); | ||
| const [rawInput, setRawInput] = useState(""); | ||
| const [riskAccepted, setRiskAccepted] = useState(false); | ||
|
|
||
| const selectedTimezone = useMemo( | ||
| () => timezones.find((tz) => tz._id === form.timezone) ?? null, | ||
|
|
@@ -49,6 +51,12 @@ const TabSettings = ({ | |
| [handleFormChange] | ||
| ); | ||
|
|
||
| useEffect(() => { | ||
| if (form.customJavaScript && form.customJavaScript.length > 0) { | ||
| setRiskAccepted(true); | ||
| } | ||
| }, [form.customJavaScript]); | ||
|
|
||
| return ( | ||
| <TabPanel value={tabValue}> | ||
| <Stack gap={theme.spacing(10)}> | ||
|
|
@@ -168,6 +176,128 @@ const TabSettings = ({ | |
| /> | ||
| </Stack> | ||
| </ConfigBox> | ||
| {/* --- CUSTOM CSS SECTION --- */} | ||
| <ConfigBox> | ||
| <Stack gap={theme.spacing(6)}> | ||
| <Typography | ||
| component="h2" | ||
| variant="h2" | ||
| > | ||
| {t("customCSS")} | ||
| </Typography> | ||
| <Typography component="p"> | ||
| {t("statusPageCreateCustomCSSDescription")} | ||
| </Typography> | ||
| </Stack> | ||
| <Stack gap={theme.spacing(6)}> | ||
| <TextField | ||
| name="customCSS" // <--- CRITICAL: Needed for saving | ||
| label={t("customCSS")} | ||
| multiline | ||
| rows={4} | ||
| value={form.customCSS || ""} | ||
| onChange={handleFormChange} | ||
| fullWidth | ||
| margin="normal" | ||
| placeholder={t("statusPageCreateCustomCSSPlaceholder")} | ||
| /> | ||
| </Stack> | ||
| </ConfigBox> | ||
|
|
||
| {/* --- CUSTOM HEADER HTML SECTION --- */} | ||
| <ConfigBox> | ||
| <Stack gap={theme.spacing(6)}> | ||
| <Typography | ||
| component="h2" | ||
| variant="h2" | ||
| > | ||
| {t("customHeaderHTML")} | ||
| </Typography> | ||
| <Typography component="p"> | ||
| {t("statusPageCreateCustomHeaderHTMLDescription")} | ||
| </Typography> | ||
| </Stack> | ||
| <Stack gap={theme.spacing(6)}> | ||
| <TextField | ||
| name="headerHTML" // <--- CRITICAL | ||
| label={t("customHeaderHTML")} | ||
| multiline | ||
| rows={3} | ||
| value={form.headerHTML || ""} | ||
| onChange={handleFormChange} | ||
| fullWidth | ||
| margin="normal" | ||
| placeholder={t("statusPageCreateCustomHeaderHTMLPlaceholder")} | ||
| /> | ||
| </Stack> | ||
| </ConfigBox> | ||
|
|
||
| {/* --- CUSTOM FOOTER HTML SECTION --- */} | ||
| <ConfigBox> | ||
| <Stack gap={theme.spacing(6)}> | ||
| <Typography | ||
| component="h2" | ||
| variant="h2" | ||
| > | ||
| {t("customFooterHTML")} | ||
| </Typography> | ||
| <Typography component="p"> | ||
| {t("statusPageCreateCustomFooterHTMLDescription")} | ||
| </Typography> | ||
| </Stack> | ||
| <Stack gap={theme.spacing(6)}> | ||
| <TextField | ||
| name="footerHTML" // <--- CRITICAL | ||
| label={t("customFooterHTML")} | ||
| multiline | ||
| rows={3} | ||
| value={form.footerHTML || ""} | ||
| onChange={handleFormChange} | ||
| fullWidth | ||
| margin="normal" | ||
| /> | ||
| </Stack> | ||
| </ConfigBox> | ||
|
|
||
| {/* --- CUSTOM JAVASCRIPT SECTION --- */} | ||
| <ConfigBox> | ||
| <Stack gap={2}> | ||
| <Typography variant="h6">{t("customJavaScript")}</Typography> | ||
|
|
||
| <FormControlLabel | ||
| control={ | ||
| <MuiCheckbox | ||
| checked={riskAccepted} | ||
| onChange={(e) => setRiskAccepted(e.target.checked)} | ||
| color="error" | ||
| /> | ||
| } | ||
| label={ | ||
| <Typography | ||
| variant="body2" | ||
| color="error" | ||
| sx={{ fontWeight: "bold" }} | ||
| > | ||
| {t("Security Risk Warning") || | ||
| "I understand that adding custom JavaScript poses a security risk and I accept responsibility."} | ||
| </Typography> | ||
| } | ||
| /> | ||
|
|
||
| <TextField | ||
|
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. P2 | Confidence: Medium While there's a warning helper text, the UI doesn't sufficiently communicate the security implications to administrators. The feature enables arbitrary code execution on public pages, which should be accompanied by more prominent warnings and potentially an explicit acknowledgment checkbox. Code Suggestion: |
||
| name="customJavaScript" | ||
| label={t("customJavaScript")} | ||
| multiline | ||
| rows={4} | ||
| value={form.customJavaScript || ""} | ||
| onChange={handleFormChange} | ||
| disabled={!riskAccepted} // <--- LOCKED UNTIL CHECKED | ||
| fullWidth | ||
| helperText={t("statusPageCreateCustomJavaScriptWarning")} | ||
| placeholder="console.log('Loaded');" | ||
| /> | ||
| </Stack> | ||
| </ConfigBox> | ||
| </Stack> | ||
| </TabPanel> | ||
| ); | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -8,6 +8,7 @@ import StatusBar from "./Components/StatusBar/index.jsx"; | |
| import MonitorsList from "./Components/MonitorsList/index.jsx"; | ||
| import Breadcrumbs from "@/Components/v1/Breadcrumbs/index.jsx"; | ||
| import TextLink from "@/Components/v1/TextLink/index.jsx"; | ||
| import DOMPurify from "dompurify"; | ||
|
|
||
| // Utils | ||
| import { useStatusPageFetch } from "./Hooks/useStatusPageFetch.jsx"; | ||
|
|
@@ -16,6 +17,8 @@ import { useIsAdmin } from "../../../../Hooks/v1/useIsAdmin.js"; | |
| import { useLocation } from "react-router-dom"; | ||
| import { useParams } from "react-router-dom"; | ||
| import { useTranslation } from "react-i18next"; | ||
| // --- CHANGE 1: Import useEffect --- | ||
| import { useEffect } from "react"; | ||
coderabbitai[bot] marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
|
||
| const PublicStatus = () => { | ||
| const { url } = useParams(); | ||
|
|
@@ -28,6 +31,67 @@ const PublicStatus = () => { | |
|
|
||
| const [statusPage, monitors, isLoading, networkError] = useStatusPageFetch(false, url); | ||
|
|
||
| // --- CHANGE 2: Inject Custom CSS and JS --- | ||
| useEffect(() => { | ||
| if (!statusPage) return; | ||
|
|
||
| // --- 1. Inject Custom CSS --- | ||
| let styleElement = null; | ||
| if (statusPage.customCSS) { | ||
| styleElement = document.createElement("style"); | ||
| styleElement.id = "custom-status-css"; | ||
|
|
||
| // SECURITY FIX: Remove dangerous CSS patterns (url, import, expression) | ||
| const safeCSS = statusPage.customCSS | ||
| .replace(/url\s*\(/gi, '') // Block external data/images | ||
| .replace(/@import/gi, '') // Block external stylesheets | ||
| .replace(/expression\s*\(/gi, '') // Block IE scripts | ||
| .replace(/behavior:/gi, ''); // Block IE behaviors | ||
|
|
||
| styleElement.textContent = safeCSS; // Use textContent instead of innerHTML for extra safety | ||
| document.head.appendChild(styleElement); | ||
|
Comment on lines
44
to
54
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. CSS sanitization regex is bypassable and incomplete. The regex-based sanitization can be circumvented with whitespace/comment variations (e.g.,
Consider using a dedicated CSS sanitizer library or a stricter allowlist approach. If regex must be used, a more robust pattern: const safeCSS = statusPage.customCSS
- .replace(/url\s*\(/gi, '')
- .replace(/@import/gi, '')
- .replace(/expression\s*\(/gi, '')
- .replace(/behavior:/gi, '');
+ .replace(/url\s*\(/gi, 'blocked(')
+ .replace(/@import\b/gi, '/* blocked */')
+ .replace(/expression\s*\(/gi, 'blocked(')
+ .replace(/behavior\s*:/gi, 'blocked:')
+ .replace(/-moz-binding\s*:/gi, 'blocked:')
+ .replace(/javascript\s*:/gi, 'blocked:');Note: This is still defense-in-depth; a proper CSS parser/sanitizer is more robust. |
||
| } | ||
|
|
||
| // --- 2. Inject Custom JS --- | ||
| let scriptElement = null; | ||
| if (statusPage.customJavaScript) { | ||
|
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. P1 | Confidence: High Direct execution of user-provided JavaScript via Code Suggestion: |
||
| try { | ||
| scriptElement = document.createElement("script"); | ||
| scriptElement.id = "custom-status-js"; | ||
|
|
||
| // Wrap in IIFE (Immediately Invoked Function Expression) with internal try/catch | ||
| // This ensures that if the user writes bad JS, it errors to console but doesn't crash the UI | ||
| scriptElement.textContent = ` | ||
| (function() { | ||
| try { | ||
| ${statusPage.customJavaScript} | ||
| } catch(e) { | ||
| console.error('Custom Status Page Script Error:', e); | ||
| } | ||
| })(); | ||
| `; | ||
|
|
||
| document.body.appendChild(scriptElement); | ||
| } catch (e) { | ||
| console.error("Failed to inject custom JS element:", e); | ||
| } | ||
| } | ||
|
Comment on lines
+57
to
+80
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. Critical: Direct JavaScript injection creates arbitrary code execution vulnerability. Injecting admin-provided JavaScript directly into the DOM enables arbitrary code execution on public status pages. Even with admin-only access, this poses severe risks:
In your PR comment response to @SajanGhuman, you acknowledged these risks and stated you would implement mitigations. However, none of the promised security controls are present:
Issue #2863 explicitly requires "strict Content Security Policy" and security controls. This implementation does not meet those requirements. Recommended mitigations (in order of preference):
Consider whether the use case (analytics/chat widgets) can be served by a safer allowlist-based integration approach instead of arbitrary script execution. 🤖 Prompt for AI Agents |
||
|
|
||
| // --- 3. Safe Cleanup --- | ||
| return () => { | ||
| // Clean up CSS using the direct reference | ||
| if (styleElement && document.head.contains(styleElement)) { | ||
| document.head.removeChild(styleElement); | ||
| } | ||
|
|
||
| // Clean up JS using the direct reference | ||
| if (scriptElement && document.body.contains(scriptElement)) { | ||
| document.body.removeChild(scriptElement); | ||
| } | ||
| }; | ||
| }, [statusPage]); | ||
| // ------------------------------------------ | ||
|
|
||
| // Breadcrumbs | ||
| const crumbs = [ | ||
| { name: t("statusBreadCrumbsStatusPages"), path: "/status" }, | ||
|
|
@@ -38,6 +102,7 @@ const PublicStatus = () => { | |
| let sx = { paddingLeft: theme.spacing(20), paddingRight: theme.spacing(20) }; | ||
| let link = undefined; | ||
| const isPublic = location.pathname.startsWith("/status/uptime/public"); | ||
|
|
||
| // Public status page | ||
| if (isPublic && statusPage && statusPage.showAdminLoginLink === true) { | ||
| sx = { | ||
|
|
@@ -143,23 +208,46 @@ const PublicStatus = () => { | |
|
|
||
| return ( | ||
| <Stack | ||
| gap={theme.spacing(10)} | ||
| sx={{ ...sx, position: "relative" }} | ||
| > | ||
| {!isPublic && <Breadcrumbs list={crumbs} />} | ||
| <ControlsHeader | ||
| statusPage={statusPage} | ||
| url={url} | ||
| isPublic={isPublic} | ||
| /> | ||
| <Typography variant="h2">{t("statusPageStatusServiceStatus")}</Typography> | ||
| <StatusBar monitors={monitors} /> | ||
| <MonitorsList | ||
| monitors={monitors} | ||
| statusPage={statusPage} | ||
| /> | ||
| {link} | ||
| </Stack> | ||
| gap={theme.spacing(10)} | ||
| sx={{ ...sx, position: "relative" }} | ||
| > | ||
| {!isPublic && <Breadcrumbs list={crumbs} />} | ||
|
|
||
| {/* --- CHANGE 3: Custom Header Logic (SANITIZED) --- */} | ||
| {statusPage?.headerHTML ? ( | ||
| <div | ||
| dangerouslySetInnerHTML={{ | ||
| __html: DOMPurify.sanitize(statusPage.headerHTML) | ||
| }} | ||
| /> | ||
| ) : ( | ||
| <ControlsHeader | ||
| statusPage={statusPage} | ||
| url={url} | ||
| isPublic={isPublic} | ||
| /> | ||
| )} | ||
| {/* ------------------------------------- */} | ||
|
|
||
| <Typography variant="h2">{t("statusPageStatusServiceStatus")}</Typography> | ||
| <StatusBar monitors={monitors} /> | ||
| <MonitorsList | ||
| monitors={monitors} | ||
| statusPage={statusPage} | ||
| /> | ||
|
|
||
| {link} | ||
|
|
||
| {/* --- CHANGE 4: Custom Footer Logic (SANITIZED) --- */} | ||
| {statusPage?.footerHTML && ( | ||
| <div | ||
| dangerouslySetInnerHTML={{ | ||
| __html: DOMPurify.sanitize(statusPage.footerHTML) | ||
| }} | ||
| /> | ||
| )} | ||
| {/* ------------------------------------- */} | ||
| </Stack> | ||
| ); | ||
| }; | ||
|
|
||
|
|
||
Uh oh!
There was an error while loading. Please reload this page.