Skip to content
650 changes: 629 additions & 21 deletions client/package-lock.json

Large diffs are not rendered by default.

3 changes: 2 additions & 1 deletion client/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
"@reduxjs/toolkit": "2.7.0",
"axios": "^1.7.4",
"dayjs": "1.11.13",
"dompurify": "^3.3.0",
"flag-icons": "7.3.2",
"html2canvas": "^1.4.1",
"human-interval": "2.0.1",
Expand Down Expand Up @@ -58,6 +59,6 @@
"eslint-plugin-react-refresh": "^0.4.6",
"prettier": "^3.3.3",
"typescript": "5.9.3",
"vite": "6.3.6"
"vite": "^6.4.1"
}
}
134 changes: 132 additions & 2 deletions client/src/Pages/v1/StatusPage/Create/Components/Tabs/Settings.jsx
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";
Expand All @@ -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,
Expand All @@ -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,
Expand All @@ -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)}>
Expand Down Expand Up @@ -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
Copy link

Choose a reason for hiding this comment

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

<TextField
    name="customJavaScript"
    label={t("customJavaScript")}
    multiline
    rows={4}
    value={form.customJavaScript || ""}
    onChange={handleFormChange}
    fullWidth
    margin="normal"
    helperText={t("statusPageCreateCustomJavaScriptWarning")}
    error={!!form.customJavaScript}
/>
<FormControlLabel
    control={<Checkbox checked={acknowledgedRisk} onChange={...} />}
    label={t("statusPageAcknowledgeSecurityRisk")}
/>

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>
);
Expand Down
122 changes: 105 additions & 17 deletions client/src/Pages/v1/StatusPage/Status/index.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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";

const PublicStatus = () => {
const { url } = useParams();
Expand All @@ -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
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

CSS sanitization regex is bypassable and incomplete.

The regex-based sanitization can be circumvented with whitespace/comment variations (e.g., url/**/(). Additionally, missing patterns include:

  • data: URIs in url() (already blocked, but only partially)
  • -moz-binding (legacy Firefox XBL)
  • Unicode escapes (\0075rl()

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) {
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 | Confidence: High

Direct execution of user-provided JavaScript via new Function() or script injection creates severe security risks. Malicious code can access sensitive data, modify page behavior, or perform actions on behalf of users. The IIFE wrapper only prevents global pollution but doesn't mitigate core security risks.

Code Suggestion:

// Consider implementing a strict CSP and sandboxed execution environment
// For immediate mitigation, add strong warnings and consider feature flag
{statusPage.customJavaScript && (
    <div className="security-warning">
        Custom JavaScript execution is disabled for security reasons
    </div>
)}

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
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

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:

  1. Compromised admin accounts can inject malicious scripts affecting all visitors
  2. Session hijacking: Scripts can steal session tokens, cookies, or credentials
  3. Phishing: Scripts can modify the page to capture user input
  4. Data exfiltration: Scripts can access and transmit sensitive data
  5. The IIFE wrapper only prevents global variable pollution—it does not prevent malicious code execution
  6. The try-catch only handles script creation errors, not runtime errors or malicious behavior

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:

  • ❌ No server-side feature flag
  • ❌ No explicit admin enablement toggle
  • ❌ No confirmation dialog (the Settings page has a warning but no mandatory checkbox)
  • ❌ No Content Security Policy headers
  • ❌ No sandboxing or restricted execution environment

Issue #2863 explicitly requires "strict Content Security Policy" and security controls. This implementation does not meet those requirements.

Recommended mitigations (in order of preference):

  1. Remove the feature entirely until proper security controls are implemented
  2. Require explicit feature flag: Add a server-side feature flag that must be enabled, with audit logging
  3. Implement CSP: Configure Content-Security-Policy headers to restrict inline scripts and script sources
  4. Sandbox execution: Execute custom JS in an isolated iframe with restrictive sandbox attributes
  5. At absolute minimum: Require the explicit checkbox acknowledgment you described in PR comments before any JS can be saved or executed

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
In client/src/Pages/v1/StatusPage/Status/index.jsx around lines 46-69, the
current approach injects admin-provided JavaScript directly into the DOM which
creates an arbitrary code execution vulnerability; remove this direct injection
and do not append untrusted script text to document.body. Instead, disable
execution until server-side controls exist: require a server-side feature flag
that explicitly enables custom-JS for the deployment, require an explicit
per-save admin acknowledgment checkbox (checked at save time and stored
server-side with audit logging) before any script can be stored or executed, and
only allow execution when the server flag is on and the stored script has the
admin acknowledgment. Replace direct injection with a safe execution model:
either refuse to execute arbitrary JS or render it inside an isolated iframe
with strict sandbox attributes (no allow-scripts unless absolutely necessary)
and a controlled src/srcdoc, and ensure the app sets a strict
Content-Security-Policy header disallowing inline scripts and limiting
script-src to trusted origins when the feature is enabled. If you cannot
implement server-side flagging, acknowledgment, CSP, and sandboxing immediately,
remove the execution path entirely so saved customJavaScript is stored but never
injected or run.


// --- 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" },
Expand All @@ -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 = {
Expand Down Expand Up @@ -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>
);
};

Expand Down
11 changes: 11 additions & 0 deletions client/src/locales/en.json
Original file line number Diff line number Diff line change
@@ -1,4 +1,15 @@
{
"customCSS": "Custom CSS",
"statusPageCreateCustomCSSDescription": "Override the default styling with your own CSS.",
"statusPageCreateCustomCSSPlaceholder": "body { background: #000; }",
"customHeaderHTML": "Custom Header HTML",
"statusPageCreateCustomHeaderHTMLDescription": "Replace the default header with your own HTML code.",
"statusPageCreateCustomHeaderHTMLPlaceholder": "<div>My Custom Header</div>",
"customFooterHTML": "Custom Footer HTML",
"statusPageCreateCustomFooterHTMLDescription": "Replace the default footer with your own HTML code.",
"customJavaScript": "Custom JavaScript",
"statusPageCreateCustomJavaScriptDescription": "Execute custom JavaScript code on the public status page.",
"statusPageCreateCustomJavaScriptWarning": "Warning: This code executes on the public page.",
"submit": "Submit",
"title": "Title",
"distributedStatusHeaderText": "Real-time, real-device coverage",
Expand Down
Loading