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 + * ', + 'uppercase' => '', + 'mixed-case' => '', + 'space-before-gt' => '', + 'self-closing-style' => '', + 'style-then-img' => 'a{color:red}', + 'style-then-svg' => 'a{}', + 'newline-in-close' => "body{}", + 'tab-in-close' => "body{}", + 'leading-open-style' => '', + 'nested-comment' => '/* */', + 'html-comment-wrap' => '', + 'cdata-wrap' => '</style><script>alert(1)</script>', + 'only-script-open' => 'body{}