diff --git a/app/Controllers/SettingsController.php b/app/Controllers/SettingsController.php
index c75a9488..879fc00a 100644
--- a/app/Controllers/SettingsController.php
+++ b/app/Controllers/SettingsController.php
@@ -813,7 +813,7 @@ private function resolveAdvancedSettings(SettingsRepository $repository): array
'custom_js_marketing' => ContentSanitizer::normalizeExternalAssets(
$repository->get('advanced', 'custom_js_marketing', $config['custom_js_marketing'] ?? '')
),
- 'custom_header_css' => ContentSanitizer::normalizeExternalAssets(
+ 'custom_header_css' => ContentSanitizer::sanitizeCustomCss(
$repository->get('advanced', 'custom_header_css', $config['custom_header_css'] ?? '')
),
'days_before_expiry_warning' => (int) $repository->get('advanced', 'days_before_expiry_warning', (string) ($config['days_before_expiry_warning'] ?? 3)),
@@ -852,7 +852,7 @@ public function updateAdvancedSettings(Request $request, Response $response, mys
'custom_js_essential' => ContentSanitizer::normalizeExternalAssets(trim((string) ($data['custom_js_essential'] ?? ''))),
'custom_js_analytics' => ContentSanitizer::normalizeExternalAssets(trim((string) ($data['custom_js_analytics'] ?? ''))),
'custom_js_marketing' => ContentSanitizer::normalizeExternalAssets(trim((string) ($data['custom_js_marketing'] ?? ''))),
- 'custom_header_css' => ContentSanitizer::normalizeExternalAssets(trim((string) ($data['custom_header_css'] ?? ''))),
+ 'custom_header_css' => ContentSanitizer::sanitizeCustomCss(trim((string) ($data['custom_header_css'] ?? ''))),
'days_before_expiry_warning' => (string) $daysBeforeWarning,
'session_lifetime' => (string) $sessionLifetime,
'force_https' => isset($data['force_https']) && $data['force_https'] === '1' ? '1' : '0',
diff --git a/app/Controllers/ThemeController.php b/app/Controllers/ThemeController.php
index c4be8e82..df4e32cc 100644
--- a/app/Controllers/ThemeController.php
+++ b/app/Controllers/ThemeController.php
@@ -143,8 +143,9 @@ public function save(Request $request, Response $response, array $args): Respons
'custom_js' => $parsedBody['advanced']['custom_js'] ?? ''
];
- // Sanitize custom CSS/JS (basic sanitization - remove `;
+
+ await loginAsAdmin(page);
+ await setCustomCss(page, payload);
+
+ // Capture any dialog the payload might raise.
+ let dialogFired = false;
+ page.on('dialog', (d) => { dialogFired = true; d.dismiss().catch(() => {}); });
+
+ // Load a PUBLIC frontend page (fresh navigation parses the stored CSS).
+ await page.goto(`${BASE}/`);
+ await page.waitForLoadState('domcontentloaded');
+
+ // 1. No JavaScript from the payload ran.
+ const xssGlobal = await page.evaluate(() => /** @type {any} */ (window).__xss ?? null);
+ expect(xssGlobal, 'payload JS must not execute').toBeNull();
+ expect(dialogFired, 'no dialog may be raised').toBe(false);
+ expect(await page.title(), 'document.title must be untouched by the payload')
+ .not.toContain(marker);
+
+ // 2. The parsed DOM must contain no
+ * broke out of the style context and executed arbitrary JS for every
+ * visitor — a stored XSS gated behind admin write.
+ *
+ * ContentSanitizer::sanitizeCustomCss() now neutralises every
+ * ` inside values) survives byte-for-byte where it must.
+ *
+ * Run: php tests/custom-css-injection.unit.php (exit 0 iff all pass)
+ */
+
+$root = dirname(__DIR__);
+require $root . '/vendor/autoload.php';
+
+use App\Support\ContentSanitizer;
+
+$pass = 0;
+$fail = 0;
+$check = static function (bool $ok, string $label) use (&$pass, &$fail): void {
+ if ($ok) { $pass++; echo " OK {$label}\n"; }
+ else { $fail++; echo " FAIL {$label}\n"; }
+};
+
+/** True iff the sanitised string can still break out of a opener would start executable content.
+ return (bool) preg_match('#<\s*/?\s*(?:style|script)\b#i', $css);
+};
+
+// ── A. Every breakout payload is neutralised ─────────────────────────────
+echo "A. Breakout payloads neutralised\n";
+$payloads = [
+ 'classic' => '',
+ 'uppercase' => '',
+ 'mixed-case' => '',
+ 'space-before-gt' => '',
+ 'self-closing-style' => 'alert(1)',
+ 'attrs-on-tag' => '',
+ 'style-then-img' => 'a{color:red}
',
+ 'style-then-svg' => 'a{}