From 3a3edadcb7e1e6d6e63848778a12d016e9378d6f Mon Sep 17 00:00:00 2001 From: EugeniyKiyashko Date: Fri, 27 Feb 2026 16:40:40 +0400 Subject: [PATCH 01/25] Demos: run testing with testcafe --- .github/workflows/visual-tests-demos.yml | 143 ++++++++++++++++++ apps/demos/.gitignore | 2 + apps/demos/package.json | 4 + apps/demos/testing/common.test.ts | 38 ++++- apps/demos/utils/server/csp-report-summary.js | 78 ++++++++++ apps/demos/utils/server/csp-server.js | 134 ++++++++++++++++ .../utils/visual-tests/inject/csp-listener.js | 25 +++ package.json | 3 +- 8 files changed, 424 insertions(+), 3 deletions(-) create mode 100644 apps/demos/utils/server/csp-report-summary.js create mode 100644 apps/demos/utils/server/csp-server.js create mode 100644 apps/demos/utils/visual-tests/inject/csp-listener.js diff --git a/.github/workflows/visual-tests-demos.yml b/.github/workflows/visual-tests-demos.yml index edad31a95aa7..523c00161c2e 100644 --- a/.github/workflows/visual-tests-demos.yml +++ b/.github/workflows/visual-tests-demos.yml @@ -1069,3 +1069,146 @@ jobs: pattern: accessibility-reports-* delete-merged: true + testcafe-csp: + name: CSP violations - ${{ matrix.CONSTEL }} + needs: [check-should-run, build-devextreme] + if: | + always() && + needs.check-should-run.outputs.should-run == 'true' && + needs.build-devextreme.result == 'success' + strategy: + fail-fast: false + matrix: + CONSTEL: [jquery(1/3), jquery(2/3), jquery(3/3)] + THEME: ['fluent.blue.light'] + runs-on: devextreme-shr2 + timeout-minutes: 30 + + steps: + - name: Get sources + uses: actions/checkout@v4 + + - name: Download artifacts + uses: actions/download-artifact@v4 + with: + name: devextreme-artifacts-jquery + path: ./packages/devextreme + + - name: Unpack artifacts + working-directory: ./packages/devextreme + run: 7z x artifacts.zip -aoa + + - name: Setup Chrome + uses: ./.github/actions/setup-chrome + with: + chrome-version: '145.0.7632.67' + runner-type: 'github-hosted' + + - name: Use Node.js + uses: actions/setup-node@v4 + with: + node-version: '20' + + - uses: pnpm/action-setup@v4 + with: + run_install: false + + - name: Get pnpm store directory + shell: bash + run: echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV + + - uses: actions/cache/restore@v4 + name: Restore pnpm cache + with: + path: ${{ env.STORE_PATH }} + key: ${{ runner.os }}-pnpm-cache-${{ hashFiles('**/pnpm-lock.yaml') }} + restore-keys: | + ${{ runner.os }}-pnpm-cache + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Run CSP Server + run: node apps/demos/utils/server/csp-server.js 8080 & + + - name: Run TestCafe tests (CSP) + shell: bash + working-directory: apps/demos + env: + NODE_OPTIONS: --max-old-space-size=8192 + BROWSERS: chrome:headless --window-size=1200,800 --disable-gpu --no-sandbox --disable-dev-shm-usage --disable-partial-raster --disable-skia-runtime-opts --run-all-compositor-stages-before-draw --disable-new-content-rendering-timeout --disable-threaded-animation --disable-threaded-scrolling --disable-checker-imaging --disable-image-animation-resync --use-gl=swiftshader --disable-features=PaintHolding --js-flags=--random-seed=2147483647 --font-render-hinting=none --disable-font-subpixel-positioning + CONCURRENCY: 4 + TCQUARANTINE: true + CONSTEL: ${{ matrix.CONSTEL }} + THEME: ${{ matrix.THEME }} + DISABLE_DEMO_TEST_SETTINGS: all + CI_ENV: true + CSP_REPORT: 'true' + run: pnpx nx test-testcafe + continue-on-error: true + + - name: Prepare CSP artifact name + if: always() + run: echo "CSP_ARTIFACT=$(echo 'csp-${{ matrix.CONSTEL }}-${{ matrix.THEME }}' | tr '/()' '---')" >> $GITHUB_ENV + + - name: Upload CSP violation reports + if: always() + uses: actions/upload-artifact@v4 + with: + name: ${{ env.CSP_ARTIFACT }} + path: apps/demos/csp-reports/ + if-no-files-found: ignore + + csp-report-summary: + name: CSP Violations Summary + runs-on: devextreme-shr2 + needs: [check-should-run, testcafe-csp] + if: always() && needs.check-should-run.outputs.should-run == 'true' + timeout-minutes: 5 + + steps: + - name: Get sources + uses: actions/checkout@v4 + + - name: Use Node.js + uses: actions/setup-node@v4 + with: + node-version: '20' + + - name: Download all CSP reports + uses: actions/download-artifact@v4 + with: + pattern: csp-* + path: csp-reports-all + merge-multiple: true + continue-on-error: true + + - name: Merge and summarize CSP violations + run: | + mkdir -p apps/demos/csp-reports + find csp-reports-all -name '*.jsonl' -exec cat {} + > apps/demos/csp-reports/csp-violations.jsonl 2>/dev/null || true + + if [ -s apps/demos/csp-reports/csp-violations.jsonl ]; then + TOTAL=$(wc -l < apps/demos/csp-reports/csp-violations.jsonl | tr -d ' ') + echo "## ⚠️ CSP Violations Found ($TOTAL total)" >> $GITHUB_STEP_SUMMARY + echo '' >> $GITHUB_STEP_SUMMARY + echo '```' >> $GITHUB_STEP_SUMMARY + node apps/demos/utils/server/csp-report-summary.js >> $GITHUB_STEP_SUMMARY + echo '```' >> $GITHUB_STEP_SUMMARY + echo '' + echo "CSP violations found: $TOTAL" + node apps/demos/utils/server/csp-report-summary.js + else + echo "## ✅ No CSP Violations" >> $GITHUB_STEP_SUMMARY + echo "No CSP violations detected across all demo tests." >> $GITHUB_STEP_SUMMARY + echo "No CSP violations detected." + fi + + - name: Upload merged CSP report + if: always() + uses: actions/upload-artifact@v4 + with: + name: csp-violations-report + path: apps/demos/csp-reports/ + if-no-files-found: ignore + diff --git a/apps/demos/.gitignore b/apps/demos/.gitignore index 045269d9a84b..82d6e6478557 100644 --- a/apps/demos/.gitignore +++ b/apps/demos/.gitignore @@ -33,5 +33,7 @@ Demos/**/tsconfig.json **/.DS_Store publish-demos +csp-reports + .angular angular.json diff --git a/apps/demos/package.json b/apps/demos/package.json index 001dd0183254..fa464d347c79 100644 --- a/apps/demos/package.json +++ b/apps/demos/package.json @@ -177,7 +177,11 @@ "lint-non-demos": "pnpx nx run-many -t lint-js lint-css lint-html -p devextreme-demos", "lint": "pnpm run lint-non-demos && pnpm run lint-demos", "test-testcafe": "ts-node utils/visual-tests/testcafe-runner.ts", + "test-testcafe:csp": "cross-env CSP_REPORT=true ts-node utils/visual-tests/testcafe-runner.ts", "test-testcafe:accessibility": "cross-env STRATEGY=accessibility CONSTEL=jquery node utils/visual-tests/testcafe-runner.ts", + "csp-server": "node utils/server/csp-server.js", + "csp-server:start": "node utils/server/csp-server.js 8080", + "csp-report": "node utils/server/csp-report-summary.js", "fix-lint": "prettier --write . && eslint --fix . && stylelint **/*.{css,vue} --fix", "prettier": "prettier", "build-bundles": "gulp bundles", diff --git a/apps/demos/testing/common.test.ts b/apps/demos/testing/common.test.ts index 22650163014e..240aac87de86 100644 --- a/apps/demos/testing/common.test.ts +++ b/apps/demos/testing/common.test.ts @@ -1,8 +1,9 @@ import { glob } from 'glob'; import { join } from 'path'; -import { existsSync } from 'fs'; +import { existsSync, mkdirSync, appendFileSync } from 'fs'; import { createScreenshotsComparer } from 'devextreme-screenshot-comparer'; import { axeCheck, createReport } from '@testcafe-community/axe'; +import { ClientFunction } from 'testcafe'; import { runTestAtPage, shouldRunFramework, @@ -32,6 +33,26 @@ const execTestCafeCode = (t, code) => { return testCafeFunction(t); }; +const getClientCspViolations = ClientFunction(() => (window as any).__cspViolations || []); + +const isCspEnabled = () => process.env.CSP_REPORT === 'true'; + +const cspReportDir = join(__dirname, '..', 'csp-reports'); + +const writeCspReport = (testName: string, framework: string, violations: any[]) => { + if (!violations.length) return; + mkdirSync(cspReportDir, { recursive: true }); + const reportFile = join(cspReportDir, 'csp-violations.jsonl'); + for (const v of violations) { + const entry = { + test: testName, + framework, + ...v, + }; + appendFileSync(reportFile, `${JSON.stringify(entry)}\n`); + } +}; + const getIgnoredRules = (testName) => { const ignoredRules = []; @@ -103,6 +124,13 @@ const getClientScripts = () => { scripts.push({ module: 'axe-core/axe.min.js' }); } + if (isCspEnabled()) { + scripts.push( + // @ts-expect-error + join(__dirname, '../utils/visual-tests/inject/csp-listener.js'), + ); + } + scripts.push( // @ts-expect-error join(__dirname, '../utils/visual-tests/inject/test-utils.js'), @@ -225,9 +253,15 @@ Object.values(FRAMEWORKS).forEach((approach) => { } else { const consoleMessages = await t.getBrowserConsoleMessages(); const errors = [...consoleMessages.error, ...consoleMessages.warn] - .filter((e) => !knownWarnings.some((kw) => e.startsWith(kw))); + .filter((e) => !knownWarnings.some((kw) => e.startsWith(kw))) + .filter((e) => !e.startsWith('[CSP Violation]')); await t.expect(errors).eql([]); + if (isCspEnabled()) { + const cspViolations = await getClientCspViolations(); + writeCspReport(testName, approach, cspViolations); + } + const { takeScreenshot, compareResults } = createScreenshotsComparer(t); await testScreenshot(t, takeScreenshot, `${testName}.png`, undefined, comparisonOptions); diff --git a/apps/demos/utils/server/csp-report-summary.js b/apps/demos/utils/server/csp-report-summary.js new file mode 100644 index 000000000000..ac92516afc5d --- /dev/null +++ b/apps/demos/utils/server/csp-report-summary.js @@ -0,0 +1,78 @@ +/* eslint-disable no-console */ +const { readFileSync, existsSync } = require('fs'); +const { join } = require('path'); + +const reportFile = join(__dirname, '..', '..', 'csp-reports', 'csp-violations.jsonl'); + +if (!existsSync(reportFile)) { + console.log('No CSP violations report found.'); + console.log(`Expected at: ${reportFile}`); + console.log('Run tests with CSP_REPORT=true to generate a report.'); + process.exit(0); +} + +const lines = readFileSync(reportFile, 'utf-8') + .split('\n') + .filter(Boolean) + .map((line) => JSON.parse(line)); + +if (lines.length === 0) { + console.log('✅ No CSP violations detected!'); + process.exit(0); +} + +console.log(`\n⚠️ CSP Violations Report: ${lines.length} violation(s) found\n`); +console.log('='.repeat(80)); + +const byDirective = {}; +for (const v of lines) { + const key = v.violatedDirective || v.effectiveDirective || 'unknown'; + if (!byDirective[key]) { + byDirective[key] = []; + } + byDirective[key].push(v); +} + +for (const [directive, violations] of Object.entries(byDirective)) { + console.log(`\n📋 ${directive} (${violations.length} violation(s)):`); + console.log('-'.repeat(60)); + + const uniqueViolations = new Map(); + for (const v of violations) { + const key = `${v.blockedURI}|${v.sourceFile}|${v.lineNumber}`; + if (!uniqueViolations.has(key)) { + uniqueViolations.set(key, { ...v, count: 1, tests: [v.test] }); + } else { + const existing = uniqueViolations.get(key); + existing.count += 1; + if (!existing.tests.includes(v.test)) { + existing.tests.push(v.test); + } + } + } + + for (const [, v] of uniqueViolations) { + console.log(` blocked: ${v.blockedURI || 'N/A'}`); + console.log(` source: ${v.sourceFile || 'N/A'}:${v.lineNumber || '?'}`); + console.log(` count: ${v.count} occurrence(s) in ${v.tests.length} test(s)`); + if (v.tests.length <= 5) { + console.log(` tests: ${v.tests.join(', ')}`); + } else { + console.log(` tests: ${v.tests.slice(0, 5).join(', ')} ... and ${v.tests.length - 5} more`); + } + console.log(); + } +} + +const byFramework = {}; +for (const v of lines) { + const fw = v.framework || 'unknown'; + byFramework[fw] = (byFramework[fw] || 0) + 1; +} + +console.log('='.repeat(80)); +console.log('\n📊 Summary by framework:'); +for (const [fw, count] of Object.entries(byFramework)) { + console.log(` ${fw}: ${count} violation(s)`); +} +console.log(`\n Total: ${lines.length} violation(s)\n`); diff --git a/apps/demos/utils/server/csp-server.js b/apps/demos/utils/server/csp-server.js new file mode 100644 index 000000000000..e42997f41e5d --- /dev/null +++ b/apps/demos/utils/server/csp-server.js @@ -0,0 +1,134 @@ +/* eslint-disable import/no-extraneous-dependencies */ + +const express = require('express'); +const serveStatic = require('serve-static'); +const cookieParser = require('cookie-parser'); +const { join, normalize } = require('path'); +const { readFileSync, readdirSync } = require('fs'); + +const root = join(__dirname, '..', '..', '..', '..'); +const indexFileName = 'index.html'; +const port = process.argv[2] ?? 8080; + +const cspViolations = []; +let cspViolationIdCounter = 0; + +const CSP_DIRECTIVES = [ + "default-src 'self'", + "script-src 'self' 'unsafe-eval' 'unsafe-inline'", + "style-src 'self' 'unsafe-inline'", + "img-src 'self' data: blob:", + "font-src 'self' data:", + "connect-src 'self' https://js.devexpress.com", + "worker-src 'self' blob:", + "frame-src 'self'", + `report-uri /csp-report`, +].join('; '); + +function cspMiddleware(_req, res, next) { + res.setHeader('Content-Security-Policy-Report-Only', CSP_DIRECTIVES); + next(); +} + +function cspReportHandler(req, res) { + let body = ''; + req.on('data', (chunk) => { body += chunk.toString(); }); + req.on('end', () => { + try { + const report = JSON.parse(body); + const violation = report['csp-report'] || report; + cspViolationIdCounter += 1; + cspViolations.push({ + id: cspViolationIdCounter, + timestamp: new Date().toISOString(), + documentUri: violation['document-uri'], + blockedUri: violation['blocked-uri'], + violatedDirective: violation['violated-directive'], + effectiveDirective: violation['effective-directive'], + originalPolicy: violation['original-policy'], + sourceFile: violation['source-file'], + lineNumber: violation['line-number'], + columnNumber: violation['column-number'], + statusCode: violation['status-code'], + }); + } catch (e) { + console.error('Failed to parse CSP report:', e.message); + } + res.status(204).end(); + }); +} + +function cspViolationsHandler(req, res) { + const since = parseInt(req.query.since, 10) || 0; + const filtered = cspViolations.filter((v) => v.id > since); + res.json({ + violations: filtered, + lastId: cspViolationIdCounter, + total: cspViolations.length, + }); +} + +function cspViolationsClearHandler(_req, res) { + cspViolations.length = 0; + cspViolationIdCounter = 0; + res.status(204).end(); +} + +const demoIndexHandler = (request, response) => { + const parts = request.path.split('/'); + parts.unshift(root); + + if (parts[parts.length - 1] !== indexFileName) { + parts.push(indexFileName); + } + + const fileSystemPath = normalize(join.apply(null, parts)); + let fileContent; + try { + fileContent = readFileSync(fileSystemPath).toString(); + } catch (e) { + response.status(404).send('Not Found'); + return; + } + + const cookieTheme = request.cookies['dx-demo-theme']; + const cssDirectory = join(root, 'node_modules', 'devextreme', 'dist', 'css'); + let availableThemes = []; + try { + availableThemes = readdirSync(cssDirectory).filter((f) => /^dx\.(?!common).*\.css$/i.test(f)); + } catch (e) { /* css directory may not exist */ } + + if (cookieTheme && availableThemes.includes(cookieTheme)) { + fileContent = fileContent.replace('dx.light.css', cookieTheme); + } + + response.set('Content-Type', 'text/html'); + response.send(fileContent); +}; + +const app = express(); +app.use(cookieParser()); +app.use(cspMiddleware); + +app.post('/csp-report', cspReportHandler); +app.get('/csp-violations', cspViolationsHandler); +app.delete('/csp-violations', cspViolationsClearHandler); + +app.get('/apps/demos/Demos/:widget/:name/:approach', demoIndexHandler); +app.get(`/apps/demos/Demos/:widget/:name/:approach/${indexFileName}`, demoIndexHandler); +app.get('/Demos/:widget/:name/:approach', demoIndexHandler); +app.get(`/Demos/:widget/:name/:approach/${indexFileName}`, demoIndexHandler); + +app.use( + serveStatic(root, { index: [indexFileName] }), +); + +const server = app.listen(port, () => { + console.log(`CSP Demo server listening on http://127.0.0.1:${port}`); + console.log('CSP Report-Only mode enabled'); + console.log(` Report endpoint: POST /csp-report`); + console.log(` View violations: GET /csp-violations`); + console.log(` Clear violations: DELETE /csp-violations`); +}); + +module.exports = { app, server }; diff --git a/apps/demos/utils/visual-tests/inject/csp-listener.js b/apps/demos/utils/visual-tests/inject/csp-listener.js new file mode 100644 index 000000000000..a0d763264a0c --- /dev/null +++ b/apps/demos/utils/visual-tests/inject/csp-listener.js @@ -0,0 +1,25 @@ +window.__cspViolations = []; + +document.addEventListener('securitypolicyviolation', function(e) { + var violation = { + blockedURI: e.blockedURI, + violatedDirective: e.violatedDirective, + effectiveDirective: e.effectiveDirective, + originalPolicy: e.originalPolicy, + sourceFile: e.sourceFile, + lineNumber: e.lineNumber, + columnNumber: e.columnNumber, + documentURI: e.documentURI, + disposition: e.disposition, + timestamp: new Date().toISOString(), + }; + + window.__cspViolations.push(violation); + + console.warn( + '[CSP Violation] ' + e.violatedDirective + + ' | blocked: ' + e.blockedURI + + ' | source: ' + (e.sourceFile || 'N/A') + + ':' + (e.lineNumber || '?') + ); +}); diff --git a/package.json b/package.json index 5b7e91793b9a..e2fa07a340bb 100644 --- a/package.json +++ b/package.json @@ -21,7 +21,8 @@ "all:build-dev": "nx all:build-dev workflows", "all:pack-and-copy": "nx run-many -t pack-and-copy", "demos:prepare": "nx run devextreme-demos:prepare-js", - "demos:start": "http-server ./apps/demos --port 8080 -c-1" + "demos:start": "http-server ./apps/demos --port 8080 -c-1", + "demos:start-csp": "node apps/demos/utils/server/csp-server.js 8080" }, "nx": {}, "private": true, From bd4d8191e289028f988228cfe9f6560dc5721966 Mon Sep 17 00:00:00 2001 From: EugeniyKiyashko Date: Fri, 27 Feb 2026 17:28:17 +0400 Subject: [PATCH 02/25] update --- .github/workflows/visual-tests-demos.yml | 37 ++--- apps/demos/package.json | 1 + apps/demos/utils/server/csp-check.js | 167 +++++++++++++++++++++++ apps/demos/utils/server/csp-server.js | 12 +- 4 files changed, 182 insertions(+), 35 deletions(-) create mode 100644 apps/demos/utils/server/csp-check.js diff --git a/.github/workflows/visual-tests-demos.yml b/.github/workflows/visual-tests-demos.yml index 523c00161c2e..4be779e65748 100644 --- a/.github/workflows/visual-tests-demos.yml +++ b/.github/workflows/visual-tests-demos.yml @@ -1069,20 +1069,15 @@ jobs: pattern: accessibility-reports-* delete-merged: true - testcafe-csp: - name: CSP violations - ${{ matrix.CONSTEL }} + csp-check: + name: CSP violations check needs: [check-should-run, build-devextreme] if: | always() && needs.check-should-run.outputs.should-run == 'true' && needs.build-devextreme.result == 'success' - strategy: - fail-fast: false - matrix: - CONSTEL: [jquery(1/3), jquery(2/3), jquery(3/3)] - THEME: ['fluent.blue.light'] runs-on: devextreme-shr2 - timeout-minutes: 30 + timeout-minutes: 60 steps: - name: Get sources @@ -1128,41 +1123,25 @@ jobs: - name: Install dependencies run: pnpm install --frozen-lockfile - - name: Run CSP Server + - name: Start CSP Server run: node apps/demos/utils/server/csp-server.js 8080 & - - name: Run TestCafe tests (CSP) - shell: bash + - name: Run CSP Check (Chrome headless) working-directory: apps/demos - env: - NODE_OPTIONS: --max-old-space-size=8192 - BROWSERS: chrome:headless --window-size=1200,800 --disable-gpu --no-sandbox --disable-dev-shm-usage --disable-partial-raster --disable-skia-runtime-opts --run-all-compositor-stages-before-draw --disable-new-content-rendering-timeout --disable-threaded-animation --disable-threaded-scrolling --disable-checker-imaging --disable-image-animation-resync --use-gl=swiftshader --disable-features=PaintHolding --js-flags=--random-seed=2147483647 --font-render-hinting=none --disable-font-subpixel-positioning - CONCURRENCY: 4 - TCQUARANTINE: true - CONSTEL: ${{ matrix.CONSTEL }} - THEME: ${{ matrix.THEME }} - DISABLE_DEMO_TEST_SETTINGS: all - CI_ENV: true - CSP_REPORT: 'true' - run: pnpx nx test-testcafe - continue-on-error: true - - - name: Prepare CSP artifact name - if: always() - run: echo "CSP_ARTIFACT=$(echo 'csp-${{ matrix.CONSTEL }}-${{ matrix.THEME }}' | tr '/()' '---')" >> $GITHUB_ENV + run: node utils/server/csp-check.js - name: Upload CSP violation reports if: always() uses: actions/upload-artifact@v4 with: - name: ${{ env.CSP_ARTIFACT }} + name: csp-violations path: apps/demos/csp-reports/ if-no-files-found: ignore csp-report-summary: name: CSP Violations Summary runs-on: devextreme-shr2 - needs: [check-should-run, testcafe-csp] + needs: [check-should-run, csp-check] if: always() && needs.check-should-run.outputs.should-run == 'true' timeout-minutes: 5 diff --git a/apps/demos/package.json b/apps/demos/package.json index fa464d347c79..0b1f3ae348c9 100644 --- a/apps/demos/package.json +++ b/apps/demos/package.json @@ -181,6 +181,7 @@ "test-testcafe:accessibility": "cross-env STRATEGY=accessibility CONSTEL=jquery node utils/visual-tests/testcafe-runner.ts", "csp-server": "node utils/server/csp-server.js", "csp-server:start": "node utils/server/csp-server.js 8080", + "csp-check": "node utils/server/csp-check.js", "csp-report": "node utils/server/csp-report-summary.js", "fix-lint": "prettier --write . && eslint --fix . && stylelint **/*.{css,vue} --fix", "prettier": "prettier", diff --git a/apps/demos/utils/server/csp-check.js b/apps/demos/utils/server/csp-check.js new file mode 100644 index 000000000000..68fba7ef17ec --- /dev/null +++ b/apps/demos/utils/server/csp-check.js @@ -0,0 +1,167 @@ +/* eslint-disable no-console */ +const { execFileSync } = require('child_process'); +const { join } = require('path'); +const { + readdirSync, existsSync, writeFileSync, mkdirSync, +} = require('fs'); +const http = require('http'); + +const DEMO_ROOT = join(__dirname, '..', '..'); +const REPORT_DIR = join(DEMO_ROOT, 'csp-reports'); +const SERVER_URL = process.env.CSP_SERVER_URL || 'http://localhost:8080'; +const FRAMEWORKS = (process.env.CSP_FRAMEWORKS || 'jQuery').split(',').map((f) => f.trim()); + +function findChrome() { + const candidates = [ + process.env.CHROME_PATH, + 'google-chrome-stable', + 'google-chrome', + 'chromium-browser', + 'chromium', + '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome', + '/usr/bin/google-chrome-stable', + '/usr/bin/google-chrome', + ].filter(Boolean); + + for (const candidate of candidates) { + try { + if (candidate.startsWith('/')) { + execFileSync('test', ['-x', candidate], { stdio: 'ignore' }); + } else { + execFileSync('which', [candidate], { stdio: 'ignore' }); + } + return candidate; + } catch { + // try next candidate + } + } + throw new Error('Chrome not found. Set CHROME_PATH environment variable.'); +} + +function findDemos() { + const demosDir = join(DEMO_ROOT, 'Demos'); + const result = []; + + if (!existsSync(demosDir)) { + console.error(`Demos directory not found: ${demosDir}`); + return result; + } + + const widgets = readdirSync(demosDir, { withFileTypes: true }) + .filter((d) => d.isDirectory()); + + for (const widget of widgets) { + const widgetDir = join(demosDir, widget.name); + let demos; + try { + demos = readdirSync(widgetDir, { withFileTypes: true }) + .filter((d) => d.isDirectory()); + } catch { + continue; + } + + for (const demo of demos) { + for (const fw of FRAMEWORKS) { + const fwDir = join(widgetDir, demo.name, fw); + if (existsSync(join(fwDir, 'index.html'))) { + result.push({ + url: `${SERVER_URL}/apps/demos/Demos/${widget.name}/${demo.name}/${fw}/`, + widget: widget.name, + demo: demo.name, + framework: fw, + }); + } + } + } + } + + return result; +} + +function visitPage(chromePath, url) { + try { + execFileSync(chromePath, [ + '--headless=new', + '--no-sandbox', + '--disable-gpu', + '--disable-software-rasterizer', + '--disable-dev-shm-usage', + '--screenshot=/tmp/csp_check.png', + '--window-size=100,100', + url, + ], { timeout: 30000, stdio: 'ignore' }); + } catch { + // timeout or crash — continue to next page + } +} + +function httpRequest(url, method) { + return new Promise((resolve, reject) => { + const req = http.request(url, { method: method || 'GET' }, (res) => { + let data = ''; + res.on('data', (chunk) => { data += chunk; }); + res.on('end', () => { + try { + resolve(JSON.parse(data)); + } catch { + resolve(data); + } + }); + }); + req.on('error', reject); + req.end(); + }); +} + +async function main() { + const chromePath = findChrome(); + console.log(`Chrome: ${chromePath}`); + console.log(`Server: ${SERVER_URL}`); + console.log(`Frameworks: ${FRAMEWORKS.join(', ')}\n`); + + // Clear previous violations on server + await httpRequest(`${SERVER_URL}/csp-violations`, 'DELETE'); + + const demos = findDemos(); + console.log(`Found ${demos.length} demo page(s) to check\n`); + + if (demos.length === 0) { + console.log('No demos found. Exiting.'); + return; + } + + let checked = 0; + for (const demo of demos) { + checked += 1; + if (checked % 50 === 0 || checked === demos.length || checked === 1) { + console.log(` [${checked}/${demos.length}] ${demo.widget}/${demo.demo}/${demo.framework}`); + } + visitPage(chromePath, demo.url); + } + + // Wait for in-flight CSP reports to arrive at the server + console.log('\nWaiting for remaining CSP reports...'); + await new Promise((resolve) => { setTimeout(resolve, 3000); }); + + // Fetch violations from CSP server + const result = await httpRequest(`${SERVER_URL}/csp-violations`); + const violations = result.violations || []; + + // Write JSONL report + mkdirSync(REPORT_DIR, { recursive: true }); + const reportFile = join(REPORT_DIR, 'csp-violations.jsonl'); + + if (violations.length > 0) { + const lines = violations.map((v) => JSON.stringify(v)).join('\n'); + writeFileSync(reportFile, `${lines}\n`); + console.log(`\n⚠️ ${violations.length} CSP violation(s) detected`); + } else { + writeFileSync(reportFile, ''); + console.log('\n✅ No CSP violations detected'); + } +} + +main().catch((err) => { + console.error('CSP check failed:', err.message); + process.exit(1); +}); diff --git a/apps/demos/utils/server/csp-server.js b/apps/demos/utils/server/csp-server.js index e42997f41e5d..9949f7433953 100644 --- a/apps/demos/utils/server/csp-server.js +++ b/apps/demos/utils/server/csp-server.js @@ -15,14 +15,14 @@ let cspViolationIdCounter = 0; const CSP_DIRECTIVES = [ "default-src 'self'", - "script-src 'self' 'unsafe-eval' 'unsafe-inline'", + "script-src 'self'", "style-src 'self' 'unsafe-inline'", - "img-src 'self' data: blob:", - "font-src 'self' data:", - "connect-src 'self' https://js.devexpress.com", + "img-src 'self' data: blob: https:", + "font-src 'self' data: https:", + "connect-src 'self' https:", "worker-src 'self' blob:", - "frame-src 'self'", - `report-uri /csp-report`, + "frame-src 'self' blob:", + 'report-uri /csp-report', ].join('; '); function cspMiddleware(_req, res, next) { From c82de20f422b1119f46a19891fb32e8c136dbc80 Mon Sep 17 00:00:00 2001 From: EugeniyKiyashko Date: Fri, 27 Feb 2026 19:12:27 +0400 Subject: [PATCH 03/25] update rule --- .github/workflows/visual-tests-demos.yml | 2 ++ apps/demos/utils/server/csp-server.js | 10 +++++----- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/.github/workflows/visual-tests-demos.yml b/.github/workflows/visual-tests-demos.yml index 4be779e65748..3b635096dbcd 100644 --- a/.github/workflows/visual-tests-demos.yml +++ b/.github/workflows/visual-tests-demos.yml @@ -1128,6 +1128,8 @@ jobs: - name: Run CSP Check (Chrome headless) working-directory: apps/demos + env: + CSP_FRAMEWORKS: jQuery,React run: node utils/server/csp-check.js - name: Upload CSP violation reports diff --git a/apps/demos/utils/server/csp-server.js b/apps/demos/utils/server/csp-server.js index 9949f7433953..9388a0cf5c08 100644 --- a/apps/demos/utils/server/csp-server.js +++ b/apps/demos/utils/server/csp-server.js @@ -15,8 +15,8 @@ let cspViolationIdCounter = 0; const CSP_DIRECTIVES = [ "default-src 'self'", - "script-src 'self'", - "style-src 'self' 'unsafe-inline'", + "script-src 'self' https://esm.sh https://cdnjs.cloudflare.com https://cdn.jsdelivr.net", + "style-src 'self' https://maxcdn.bootstrapcdn.com", "img-src 'self' data: blob: https:", "font-src 'self' data: https:", "connect-src 'self' https:", @@ -126,9 +126,9 @@ app.use( const server = app.listen(port, () => { console.log(`CSP Demo server listening on http://127.0.0.1:${port}`); console.log('CSP Report-Only mode enabled'); - console.log(` Report endpoint: POST /csp-report`); - console.log(` View violations: GET /csp-violations`); - console.log(` Clear violations: DELETE /csp-violations`); + console.log(' Report endpoint: POST /csp-report'); + console.log(' View violations: GET /csp-violations'); + console.log(' Clear violations: DELETE /csp-violations'); }); module.exports = { app, server }; From 29b9cf673c79dddcaf393565b4de220976a461d5 Mon Sep 17 00:00:00 2001 From: EugeniyKiyashko Date: Fri, 27 Feb 2026 20:05:53 +0400 Subject: [PATCH 04/25] parallel frameworks --- .github/workflows/visual-tests-demos.yml | 139 ++++++++++++++---- apps/demos/utils/server/csp-check.js | 70 ++++++--- apps/demos/utils/server/csp-report-summary.js | 2 +- apps/demos/utils/server/csp-server.js | 20 ++- 4 files changed, 175 insertions(+), 56 deletions(-) diff --git a/.github/workflows/visual-tests-demos.yml b/.github/workflows/visual-tests-demos.yml index 3b635096dbcd..9a3e612167ff 100644 --- a/.github/workflows/visual-tests-demos.yml +++ b/.github/workflows/visual-tests-demos.yml @@ -1069,8 +1069,8 @@ jobs: pattern: accessibility-reports-* delete-merged: true - csp-check: - name: CSP violations check + csp-check-jquery: + name: CSP check (jQuery) needs: [check-should-run, build-devextreme] if: | always() && @@ -1126,24 +1126,100 @@ jobs: - name: Start CSP Server run: node apps/demos/utils/server/csp-server.js 8080 & - - name: Run CSP Check (Chrome headless) + - name: Run CSP Check working-directory: apps/demos env: - CSP_FRAMEWORKS: jQuery,React + CSP_FRAMEWORKS: jQuery run: node utils/server/csp-check.js - - name: Upload CSP violation reports + - name: Upload CSP report if: always() uses: actions/upload-artifact@v4 with: - name: csp-violations + name: csp-violations-jquery + path: apps/demos/csp-reports/ + if-no-files-found: ignore + + csp-check-frameworks: + name: CSP check (${{ matrix.FRAMEWORK }}) + needs: [check-should-run, determine-framework-tests-scope, build-devextreme] + if: | + always() && + needs.check-should-run.outputs.should-run == 'true' && + needs.determine-framework-tests-scope.result == 'success' && + needs.determine-framework-tests-scope.outputs.framework-tests-scope != 'none' && + needs.build-devextreme.result == 'success' + strategy: + fail-fast: false + matrix: + FRAMEWORK: [React, Vue, Angular] + runs-on: devextreme-shr2 + timeout-minutes: 60 + + steps: + - name: Get sources + uses: actions/checkout@v4 + + - name: Download devextreme sources + uses: actions/download-artifact@v4 + with: + name: devextreme-sources + + - name: Setup Chrome + uses: ./.github/actions/setup-chrome + with: + chrome-version: '145.0.7632.67' + runner-type: 'github-hosted' + + - name: Use Node.js + uses: actions/setup-node@v4 + with: + node-version: '20' + + - uses: pnpm/action-setup@v4 + with: + run_install: false + + - name: Get pnpm store directory + shell: bash + run: echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV + + - uses: actions/cache/restore@v4 + name: Restore pnpm cache + with: + path: ${{ env.STORE_PATH }} + key: ${{ runner.os }}-pnpm-cache-${{ hashFiles('**/pnpm-lock.yaml') }} + restore-keys: | + ${{ runner.os }}-pnpm-cache + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Install tgz + working-directory: apps/demos + run: pnpm add ../../devextreme-installer.tgz ../../devextreme-dist-installer.tgz ../../devextreme-react-installer.tgz ../../devextreme-vue-installer.tgz ../../devextreme-angular-installer.tgz + + - name: Start CSP Server + run: node apps/demos/utils/server/csp-server.js 8080 & + + - name: Run CSP Check + working-directory: apps/demos + env: + CSP_FRAMEWORKS: ${{ matrix.FRAMEWORK }} + run: node utils/server/csp-check.js + + - name: Upload CSP report + if: always() + uses: actions/upload-artifact@v4 + with: + name: csp-violations-${{ matrix.FRAMEWORK }} path: apps/demos/csp-reports/ if-no-files-found: ignore csp-report-summary: name: CSP Violations Summary runs-on: devextreme-shr2 - needs: [check-should-run, csp-check] + needs: [check-should-run, csp-check-jquery, csp-check-frameworks] if: always() && needs.check-should-run.outputs.should-run == 'true' timeout-minutes: 5 @@ -1159,33 +1235,46 @@ jobs: - name: Download all CSP reports uses: actions/download-artifact@v4 with: - pattern: csp-* + pattern: csp-violations-* path: csp-reports-all merge-multiple: true continue-on-error: true - - name: Merge and summarize CSP violations + - name: Summarize CSP violations run: | mkdir -p apps/demos/csp-reports - find csp-reports-all -name '*.jsonl' -exec cat {} + > apps/demos/csp-reports/csp-violations.jsonl 2>/dev/null || true - - if [ -s apps/demos/csp-reports/csp-violations.jsonl ]; then - TOTAL=$(wc -l < apps/demos/csp-reports/csp-violations.jsonl | tr -d ' ') - echo "## ⚠️ CSP Violations Found ($TOTAL total)" >> $GITHUB_STEP_SUMMARY - echo '' >> $GITHUB_STEP_SUMMARY - echo '```' >> $GITHUB_STEP_SUMMARY - node apps/demos/utils/server/csp-report-summary.js >> $GITHUB_STEP_SUMMARY - echo '```' >> $GITHUB_STEP_SUMMARY - echo '' - echo "CSP violations found: $TOTAL" - node apps/demos/utils/server/csp-report-summary.js + + echo "## CSP Violations Report" >> $GITHUB_STEP_SUMMARY + echo '' >> $GITHUB_STEP_SUMMARY + + GRAND_TOTAL=0 + for report in csp-reports-all/csp-violations-*.jsonl; do + [ -f "$report" ] || continue + FRAMEWORK=$(basename "$report" | sed 's/csp-violations-//;s/\.jsonl//') + cp "$report" "apps/demos/csp-reports/" + + if [ -s "$report" ]; then + COUNT=$(wc -l < "$report" | tr -d ' ') + GRAND_TOTAL=$((GRAND_TOTAL + COUNT)) + echo "### ⚠️ ${FRAMEWORK}: ${COUNT} violation(s)" >> $GITHUB_STEP_SUMMARY + echo '' >> $GITHUB_STEP_SUMMARY + echo '```' >> $GITHUB_STEP_SUMMARY + CSP_REPORT_FILE="$report" node apps/demos/utils/server/csp-report-summary.js >> $GITHUB_STEP_SUMMARY + echo '```' >> $GITHUB_STEP_SUMMARY + echo '' >> $GITHUB_STEP_SUMMARY + else + echo "### ✅ ${FRAMEWORK}: No violations" >> $GITHUB_STEP_SUMMARY + echo '' >> $GITHUB_STEP_SUMMARY + fi + done + + if [ "$GRAND_TOTAL" -eq 0 ]; then + echo "✅ No CSP violations detected across all frameworks." else - echo "## ✅ No CSP Violations" >> $GITHUB_STEP_SUMMARY - echo "No CSP violations detected across all demo tests." >> $GITHUB_STEP_SUMMARY - echo "No CSP violations detected." + echo "⚠️ Total: $GRAND_TOTAL CSP violation(s)" fi - - name: Upload merged CSP report + - name: Upload merged CSP reports if: always() uses: actions/upload-artifact@v4 with: diff --git a/apps/demos/utils/server/csp-check.js b/apps/demos/utils/server/csp-check.js index 68fba7ef17ec..2c635059c6cc 100644 --- a/apps/demos/utils/server/csp-check.js +++ b/apps/demos/utils/server/csp-check.js @@ -1,4 +1,3 @@ -/* eslint-disable no-console */ const { execFileSync } = require('child_process'); const { join } = require('path'); const { @@ -115,12 +114,10 @@ function httpRequest(url, method) { async function main() { const chromePath = findChrome(); + const framework = FRAMEWORKS[0]; console.log(`Chrome: ${chromePath}`); console.log(`Server: ${SERVER_URL}`); - console.log(`Frameworks: ${FRAMEWORKS.join(', ')}\n`); - - // Clear previous violations on server - await httpRequest(`${SERVER_URL}/csp-violations`, 'DELETE'); + console.log(`Framework: ${framework}\n`); const demos = findDemos(); console.log(`Found ${demos.length} demo page(s) to check\n`); @@ -130,33 +127,58 @@ async function main() { return; } - let checked = 0; - for (const demo of demos) { - checked += 1; - if (checked % 50 === 0 || checked === demos.length || checked === 1) { - console.log(` [${checked}/${demos.length}] ${demo.widget}/${demo.demo}/${demo.framework}`); - } + mkdirSync(REPORT_DIR, { recursive: true }); + const reportFile = join(REPORT_DIR, `csp-violations-${framework.toLowerCase()}.jsonl`); + + let totalViolations = 0; + let demosWithViolations = 0; + const allViolations = []; + + for (let i = 0; i < demos.length; i += 1) { + const demo = demos[i]; + + await httpRequest(`${SERVER_URL}/csp-violations`, 'DELETE'); + visitPage(chromePath, demo.url); - } - // Wait for in-flight CSP reports to arrive at the server - console.log('\nWaiting for remaining CSP reports...'); - await new Promise((resolve) => { setTimeout(resolve, 3000); }); + await new Promise((resolve) => { setTimeout(resolve, 500); }); - // Fetch violations from CSP server - const result = await httpRequest(`${SERVER_URL}/csp-violations`); - const violations = result.violations || []; + const result = await httpRequest(`${SERVER_URL}/csp-violations`); + const violations = result.violations || []; - // Write JSONL report - mkdirSync(REPORT_DIR, { recursive: true }); - const reportFile = join(REPORT_DIR, 'csp-violations.jsonl'); + if (violations.length > 0) { + demosWithViolations += 1; + totalViolations += violations.length; - if (violations.length > 0) { - const lines = violations.map((v) => JSON.stringify(v)).join('\n'); + console.log(` ❌ [${i + 1}/${demos.length}] ${demo.widget}/${demo.demo}/${demo.framework} — ${violations.length} violation(s)`); + for (const v of violations) { + const blocked = v.blockedUri || 'N/A'; + const directive = v.effectiveDirective || v.violatedDirective || '?'; + console.log(` ${directive}: ${blocked}`); + allViolations.push({ ...v, framework }); + } + } else { + console.log(` ✅ [${i + 1}/${demos.length}] ${demo.widget}/${demo.demo}/${demo.framework}`); + } + } + + if (allViolations.length > 0) { + const lines = allViolations.map((v) => JSON.stringify(v)).join('\n'); writeFileSync(reportFile, `${lines}\n`); - console.log(`\n⚠️ ${violations.length} CSP violation(s) detected`); } else { writeFileSync(reportFile, ''); + } + + console.log(`\n${'='.repeat(60)}`); + console.log(`Framework: ${framework}`); + console.log(`Demos checked: ${demos.length}`); + console.log(`Demos with violations: ${demosWithViolations}`); + console.log(`Total violations: ${totalViolations}`); + + if (totalViolations > 0) { + console.log(`\n⚠️ ${totalViolations} CSP violation(s) detected in ${demosWithViolations} demo(s)`); + console.log(`Report: ${reportFile}`); + } else { console.log('\n✅ No CSP violations detected'); } } diff --git a/apps/demos/utils/server/csp-report-summary.js b/apps/demos/utils/server/csp-report-summary.js index ac92516afc5d..b9fff1711127 100644 --- a/apps/demos/utils/server/csp-report-summary.js +++ b/apps/demos/utils/server/csp-report-summary.js @@ -2,7 +2,7 @@ const { readFileSync, existsSync } = require('fs'); const { join } = require('path'); -const reportFile = join(__dirname, '..', '..', 'csp-reports', 'csp-violations.jsonl'); +const reportFile = process.env.CSP_REPORT_FILE || join(__dirname, '..', '..', 'csp-reports', 'csp-violations.jsonl'); if (!existsSync(reportFile)) { console.log('No CSP violations report found.'); diff --git a/apps/demos/utils/server/csp-server.js b/apps/demos/utils/server/csp-server.js index 9388a0cf5c08..5746283c12cd 100644 --- a/apps/demos/utils/server/csp-server.js +++ b/apps/demos/utils/server/csp-server.js @@ -14,14 +14,22 @@ const cspViolations = []; let cspViolationIdCounter = 0; const CSP_DIRECTIVES = [ - "default-src 'self'", + "default-src 'none'", + "script-src 'self' https://esm.sh https://cdnjs.cloudflare.com https://cdn.jsdelivr.net", "style-src 'self' https://maxcdn.bootstrapcdn.com", - "img-src 'self' data: blob: https:", - "font-src 'self' data: https:", - "connect-src 'self' https:", - "worker-src 'self' blob:", - "frame-src 'self' blob:", + + "img-src 'self'", + "font-src 'self'", + "connect-src 'self'", + "worker-src 'self'", + "frame-src 'self'", + + "object-src 'none'", + "base-uri 'none'", + "form-action 'self'", + "frame-ancestors 'none'", + 'report-uri /csp-report', ].join('; '); From 39145dc2256cc57012a77daea1b02d8e245dd936 Mon Sep 17 00:00:00 2001 From: EugeniyKiyashko Date: Tue, 3 Mar 2026 16:33:16 +0400 Subject: [PATCH 05/25] add allow list --- .github/workflows/visual-tests-demos.yml | 5 ++ apps/demos/utils/server/csp-server.js | 73 ++++++++++++++++++------ 2 files changed, 61 insertions(+), 17 deletions(-) diff --git a/.github/workflows/visual-tests-demos.yml b/.github/workflows/visual-tests-demos.yml index 9a3e612167ff..e1c23db7936d 100644 --- a/.github/workflows/visual-tests-demos.yml +++ b/.github/workflows/visual-tests-demos.yml @@ -1258,10 +1258,15 @@ jobs: GRAND_TOTAL=$((GRAND_TOTAL + COUNT)) echo "### ⚠️ ${FRAMEWORK}: ${COUNT} violation(s)" >> $GITHUB_STEP_SUMMARY echo '' >> $GITHUB_STEP_SUMMARY + echo '
' >> $GITHUB_STEP_SUMMARY + echo 'Show detailed report' >> $GITHUB_STEP_SUMMARY + echo '' >> $GITHUB_STEP_SUMMARY echo '```' >> $GITHUB_STEP_SUMMARY CSP_REPORT_FILE="$report" node apps/demos/utils/server/csp-report-summary.js >> $GITHUB_STEP_SUMMARY echo '```' >> $GITHUB_STEP_SUMMARY echo '' >> $GITHUB_STEP_SUMMARY + echo '
' >> $GITHUB_STEP_SUMMARY + echo '' >> $GITHUB_STEP_SUMMARY else echo "### ✅ ${FRAMEWORK}: No violations" >> $GITHUB_STEP_SUMMARY echo '' >> $GITHUB_STEP_SUMMARY diff --git a/apps/demos/utils/server/csp-server.js b/apps/demos/utils/server/csp-server.js index 5746283c12cd..fe42df1cd1ed 100644 --- a/apps/demos/utils/server/csp-server.js +++ b/apps/demos/utils/server/csp-server.js @@ -13,28 +13,67 @@ const port = process.argv[2] ?? 8080; const cspViolations = []; let cspViolationIdCounter = 0; -const CSP_DIRECTIVES = [ - "default-src 'none'", +const CSP_BASE_DIRECTIVES = { + 'default-src': ["'none'"], + 'script-src': ["'self'", 'https://esm.sh', 'https://cdnjs.cloudflare.com', 'https://cdn.jsdelivr.net'], + 'style-src': ["'self'", 'https://maxcdn.bootstrapcdn.com'], + 'img-src': ["'self'"], + 'font-src': ["'self'"], + 'connect-src': ["'self'", 'https://js.devexpress.com', ' https://demos.devexpress.com'], + 'worker-src': ["'self'"], + 'frame-src': ["'self'"], + 'object-src': ["'none'"], + 'base-uri': ["'none'"], + 'form-action': ["'self'"], + 'frame-ancestors': ["'none'"], +}; + +const CSP_DEMO_ALLOWLIST = { + 'Button/Icons': { + 'font-src': ['https://maxcdn.bootstrapcdn.com'], + }, + Map: { + 'script-src': ['https://atlas.microsoft.com'], + 'connect-src': ['https://atlas.microsoft.com'], + }, +}; - "script-src 'self' https://esm.sh https://cdnjs.cloudflare.com https://cdn.jsdelivr.net", - "style-src 'self' https://maxcdn.bootstrapcdn.com", +function buildCspHeader(demoKey) { + const directives = {}; + for (const [key, values] of Object.entries(CSP_BASE_DIRECTIVES)) { + directives[key] = [...values]; + } - "img-src 'self'", - "font-src 'self'", - "connect-src 'self'", - "worker-src 'self'", - "frame-src 'self'", + const widgetKey = demoKey && demoKey.split('/')[0]; + const allowlists = [ + demoKey && CSP_DEMO_ALLOWLIST[demoKey], + widgetKey && CSP_DEMO_ALLOWLIST[widgetKey], + ].filter(Boolean); + + for (const allowlist of allowlists) { + for (const [key, values] of Object.entries(allowlist)) { + if (directives[key]) { + directives[key].push(...values); + } else { + directives[key] = [...values]; + } + } + } - "object-src 'none'", - "base-uri 'none'", - "form-action 'self'", - "frame-ancestors 'none'", + const parts = Object.entries(directives) + .map(([key, values]) => `${key} ${values.join(' ')}`); + parts.push('report-uri /csp-report'); + return parts.join('; '); +} - 'report-uri /csp-report', -].join('; '); +function getDemoKey(url) { + const match = url.match(/\/Demos\/([^/]+)\/([^/]+)\//); + return match ? `${match[1]}/${match[2]}` : null; +} -function cspMiddleware(_req, res, next) { - res.setHeader('Content-Security-Policy-Report-Only', CSP_DIRECTIVES); +function cspMiddleware(req, res, next) { + const demoKey = getDemoKey(req.path); + res.setHeader('Content-Security-Policy-Report-Only', buildCspHeader(demoKey)); next(); } From 6492b92d71c0b2d3ecb977dee0b0be6806741384 Mon Sep 17 00:00:00 2001 From: EugeniyKiyashko Date: Tue, 3 Mar 2026 17:05:42 +0400 Subject: [PATCH 06/25] Update csp-server.js --- apps/demos/utils/server/csp-server.js | 66 +++++++++++++++++++++++++++ 1 file changed, 66 insertions(+) diff --git a/apps/demos/utils/server/csp-server.js b/apps/demos/utils/server/csp-server.js index fe42df1cd1ed..6ccf514dc298 100644 --- a/apps/demos/utils/server/csp-server.js +++ b/apps/demos/utils/server/csp-server.js @@ -36,6 +36,72 @@ const CSP_DEMO_ALLOWLIST = { 'script-src': ['https://atlas.microsoft.com'], 'connect-src': ['https://atlas.microsoft.com'], }, + 'DataGrid/CollaborativeEditing': { + 'connect-src': ['wss://js.devexpress.com'], + }, + 'Charts/SignalRService': { + 'connect-src': ['wss://js.devexpress.com'], + }, + 'DataGrid/SignalRService': { + 'connect-src': ['wss://js.devexpress.com'], + }, + 'Scheduler/SignalRService': { + 'connect-src': ['wss://js.devexpress.com'], + }, + 'DataGrid/Cell': { + 'img-src': ['data:'], + }, + 'DataGrid/ExcelJSExportImages': { + 'img-src': ['data:'], + }, + 'DataGrid/InfiniteScrolling': { + 'img-src': ['data:'], + }, + 'DataGrid/LocalReordering': { + 'img-src': ['data:'], + }, + 'DataGrid/PDFExportImages': { + 'img-src': ['data:'], + }, + 'DataGrid/VirtualScrolling': { + 'img-src': ['data:'], + }, + Gantt: { + 'img-src': ['data:'], + }, + FilterBuilder: { + 'font-src': ['https://maxcdn.bootstrapcdn.com'], + }, + FileManager: { + 'img-src': ['data:'], + }, + 'Scheduler/GoogleCalendarIntegration': { + 'connect-src': ['https://www.googleapis.com'], + }, + 'Scheduler/CellTemplates': { + 'img-src': ['data:'], + }, + 'ScrollView/Overview': { + 'img-src': ['data:'], + }, + 'TreeList/AIColumns': { + 'connect-src': ['https://public-api.devexpress.com'], + }, + 'TreeList/BatchEditing': { + 'img-src': ['data:'], + }, + 'TreeList/CellEditing': { + 'img-src': ['data:'], + }, + 'TreeList/FixedAndStickyColumns': { + 'img-src': ['data:'], + }, + 'TreeList/MultipleSorting': { + 'img-src': ['data:'], + }, + 'TreeList/SearchPanel': { + 'img-src': ['data:'], + }, }; function buildCspHeader(demoKey) { From f8b56abc03d249b626e8f20c3b43819e58f6edc3 Mon Sep 17 00:00:00 2001 From: EugeniyKiyashko Date: Wed, 4 Mar 2026 12:24:10 +0400 Subject: [PATCH 07/25] extend allow list --- apps/demos/utils/server/csp-check.js | 1 + apps/demos/utils/server/csp-server.js | 91 ++++++++++++++++++- .../utils/visual-tests/inject/csp-listener.js | 14 +-- 3 files changed, 95 insertions(+), 11 deletions(-) diff --git a/apps/demos/utils/server/csp-check.js b/apps/demos/utils/server/csp-check.js index 2c635059c6cc..026a13e4ecaa 100644 --- a/apps/demos/utils/server/csp-check.js +++ b/apps/demos/utils/server/csp-check.js @@ -178,6 +178,7 @@ async function main() { if (totalViolations > 0) { console.log(`\n⚠️ ${totalViolations} CSP violation(s) detected in ${demosWithViolations} demo(s)`); console.log(`Report: ${reportFile}`); + process.exitCode = 1; } else { console.log('\n✅ No CSP violations detected'); } diff --git a/apps/demos/utils/server/csp-server.js b/apps/demos/utils/server/csp-server.js index 6ccf514dc298..9042a4e6b79a 100644 --- a/apps/demos/utils/server/csp-server.js +++ b/apps/demos/utils/server/csp-server.js @@ -1,5 +1,6 @@ /* eslint-disable import/no-extraneous-dependencies */ +const crypto = require('crypto'); const express = require('express'); const serveStatic = require('serve-static'); const cookieParser = require('cookie-parser'); @@ -51,6 +52,11 @@ const CSP_DEMO_ALLOWLIST = { 'DataGrid/Cell': { 'img-src': ['data:'], }, + // AI demo: inline + -
+
diff --git a/apps/demos/testing/common.test.ts b/apps/demos/testing/common.test.ts index b5c6405c2bbe..346cca76faae 100644 --- a/apps/demos/testing/common.test.ts +++ b/apps/demos/testing/common.test.ts @@ -1,6 +1,6 @@ import { glob } from 'glob'; import { join } from 'path'; -import { existsSync, mkdirSync, appendFileSync, writeFileSync } from 'fs'; +import { existsSync, mkdirSync, appendFileSync } from 'fs'; import { createScreenshotsComparer } from 'devextreme-screenshot-comparer'; import { axeCheck, createReport } from '@testcafe-community/axe'; import { ClientFunction } from 'testcafe'; @@ -40,13 +40,11 @@ const isCspEnabled = () => process.env.CSP_REPORT === 'true'; const cspReportDir = join(__dirname, '..', 'csp-reports'); const cspReportFile = join(cspReportDir, 'csp-violations.jsonl'); -if (isCspEnabled()) { - mkdirSync(cspReportDir, { recursive: true }); - writeFileSync(cspReportFile, ''); -} - const writeCspReport = (testName: string, framework: string, violations: any[]) => { if (!violations.length) return; + if (!existsSync(cspReportDir)) { + mkdirSync(cspReportDir, { recursive: true }); + } for (const v of violations) { const entry = { test: testName, diff --git a/apps/demos/utils/server/csp-check.js b/apps/demos/utils/server/csp-check.js index d880dd015990..7c47df7837fe 100644 --- a/apps/demos/utils/server/csp-check.js +++ b/apps/demos/utils/server/csp-check.js @@ -96,9 +96,6 @@ async function main() { return; } - mkdirSync(REPORT_DIR, { recursive: true }); - const reportFile = join(REPORT_DIR, `csp-violations-${FRAMEWORK.toLowerCase()}.jsonl`); - let totalViolations = 0; let demosWithViolations = 0; const allViolations = []; @@ -146,11 +143,12 @@ async function main() { } } + const reportFile = join(REPORT_DIR, `csp-violations-${FRAMEWORK.toLowerCase()}.jsonl`); + if (allViolations.length > 0) { + mkdirSync(REPORT_DIR, { recursive: true }); const lines = allViolations.map((v) => JSON.stringify(v)).join('\n'); writeFileSync(reportFile, `${lines}\n`); - } else { - writeFileSync(reportFile, ''); } console.log(`\n${'='.repeat(60)}`); From 1a631708b91478d6c49080553da5415075f635be Mon Sep 17 00:00:00 2001 From: EugeniyKiyashko Date: Wed, 4 Mar 2026 16:32:55 +0400 Subject: [PATCH 19/25] refactor --- apps/demos/utils/server/csp-check.js | 40 +++++++++++++++++++++++---- apps/demos/utils/server/csp-server.js | 2 +- 2 files changed, 36 insertions(+), 6 deletions(-) diff --git a/apps/demos/utils/server/csp-check.js b/apps/demos/utils/server/csp-check.js index 7c47df7837fe..d0928a8fb506 100644 --- a/apps/demos/utils/server/csp-check.js +++ b/apps/demos/utils/server/csp-check.js @@ -1,4 +1,4 @@ -const { execFile } = require('child_process'); +const { execFile, execFileSync } = require('child_process'); const { join } = require('path'); const { readdirSync, existsSync, writeFileSync, mkdirSync, @@ -11,7 +11,34 @@ const SERVER_URL = process.env.CSP_SERVER_URL || 'http://localhost:8080'; const FRAMEWORK = (process.env.CSP_FRAMEWORKS || 'jQuery').trim(); const CONCURRENCY = parseInt(process.env.CSP_CONCURRENCY, 10) || 8; -const CHROME_PATH = process.env.CHROME_PATH || 'google-chrome-stable'; +function findChrome() { + const candidates = [ + process.env.CHROME_PATH, + 'google-chrome-stable', + 'google-chrome', + 'chromium-browser', + 'chromium', + '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome', + '/usr/bin/google-chrome-stable', + '/usr/bin/google-chrome', + ].filter(Boolean); + + for (const candidate of candidates) { + try { + if (candidate.startsWith('/')) { + execFileSync('test', ['-x', candidate], { stdio: 'ignore' }); + } else { + execFileSync('which', [candidate], { stdio: 'ignore' }); + } + return candidate; + } catch { + // try next candidate + } + } + throw new Error('Chrome not found. Set CHROME_PATH environment variable.'); +} + +const CHROME_PATH = findChrome(); function findDemos() { const demosDir = join(DEMO_ROOT, 'Demos'); @@ -49,18 +76,21 @@ function findDemos() { } function visitPage(url) { - return new Promise((resolve) => { + return new Promise((resolve, reject) => { const child = execFile(CHROME_PATH, [ '--headless=new', '--no-sandbox', '--disable-gpu', '--disable-software-rasterizer', '--disable-dev-shm-usage', - '--screenshot=/dev/null', + '--dump-dom', + '--virtual-time-budget=5000', '--window-size=100,100', url, ], { timeout: 30000 }, () => resolve()); - child.on('error', () => resolve()); + child.on('error', (err) => { + reject(new Error(`Failed to launch Chrome at "${CHROME_PATH}": ${err.message}`)); + }); }); } diff --git a/apps/demos/utils/server/csp-server.js b/apps/demos/utils/server/csp-server.js index 8771b854caed..d1504d163753 100644 --- a/apps/demos/utils/server/csp-server.js +++ b/apps/demos/utils/server/csp-server.js @@ -243,7 +243,7 @@ function cspMiddleware(req, res, next) { res.locals.cspNonce = nonce; } - res.setHeader('Content-Security-Policy-Report-Only', buildCspHeader(demoKey, nonce, framework)); + res.setHeader('Content-Security-Policy', buildCspHeader(demoKey, nonce, framework)); next(); } From 93b98fd5e3ca6096bda7a48edc07f53f20943b29 Mon Sep 17 00:00:00 2001 From: pharret31 Date: Wed, 4 Mar 2026 13:55:56 +0100 Subject: [PATCH 20/25] remove csp issues --- apps/demos/Demos/Accordion/Overview/jQuery/index.html | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/apps/demos/Demos/Accordion/Overview/jQuery/index.html b/apps/demos/Demos/Accordion/Overview/jQuery/index.html index 7cb49e8eaaca..24ca7d32c6ff 100644 --- a/apps/demos/Demos/Accordion/Overview/jQuery/index.html +++ b/apps/demos/Demos/Accordion/Overview/jQuery/index.html @@ -11,11 +11,10 @@ - -
+
From 6314c241a9ebe20c4355d14ed1126d8990531da6 Mon Sep 17 00:00:00 2001 From: dmlvr Date: Wed, 4 Mar 2026 16:20:28 +0200 Subject: [PATCH 21/25] fix CSP issue --- apps/demos/Demos/DataGrid/PDFExportImages/jQuery/index.html | 4 ---- .../Demos/DataGrid/PDFExportMultipleGrids/jQuery/index.html | 3 --- 2 files changed, 7 deletions(-) diff --git a/apps/demos/Demos/DataGrid/PDFExportImages/jQuery/index.html b/apps/demos/Demos/DataGrid/PDFExportImages/jQuery/index.html index ec99855d024d..d3428ce8d006 100644 --- a/apps/demos/Demos/DataGrid/PDFExportImages/jQuery/index.html +++ b/apps/demos/Demos/DataGrid/PDFExportImages/jQuery/index.html @@ -11,10 +11,6 @@ integrity="sha512-+EeCylkt9WHJk5tGJxYdecHOcXFRME7qnbsfeMsdQL6NUPYm2+uGFmyleEqsmVoap/f3dN/sc3BX9t9kHXkHHg==" crossorigin="anonymous" > - - diff --git a/apps/demos/Demos/DataGrid/PDFExportMultipleGrids/jQuery/index.html b/apps/demos/Demos/DataGrid/PDFExportMultipleGrids/jQuery/index.html index c99a50ef7e3b..26b87f672084 100644 --- a/apps/demos/Demos/DataGrid/PDFExportMultipleGrids/jQuery/index.html +++ b/apps/demos/Demos/DataGrid/PDFExportMultipleGrids/jQuery/index.html @@ -15,9 +15,6 @@ integrity="sha512-+EeCylkt9WHJk5tGJxYdecHOcXFRME7qnbsfeMsdQL6NUPYm2+uGFmyleEqsmVoap/f3dN/sc3BX9t9kHXkHHg==" crossorigin="anonymous" > - From d792bf382d86480527e8e1c5478a5cea2d22b19d Mon Sep 17 00:00:00 2001 From: pharret31 Date: Wed, 4 Mar 2026 19:24:57 +0100 Subject: [PATCH 22/25] fix jquery csp issues --- .../Stepper/FormIntegration/jQuery/index.html | 2 +- apps/demos/utils/server/csp-server.js | 31 +++++++++++++++++++ 2 files changed, 32 insertions(+), 1 deletion(-) diff --git a/apps/demos/Demos/Stepper/FormIntegration/jQuery/index.html b/apps/demos/Demos/Stepper/FormIntegration/jQuery/index.html index 808e13261fc6..7658b170789d 100644 --- a/apps/demos/Demos/Stepper/FormIntegration/jQuery/index.html +++ b/apps/demos/Demos/Stepper/FormIntegration/jQuery/index.html @@ -7,7 +7,7 @@ - + diff --git a/apps/demos/utils/server/csp-server.js b/apps/demos/utils/server/csp-server.js index d1504d163753..20b14cdfba68 100644 --- a/apps/demos/utils/server/csp-server.js +++ b/apps/demos/utils/server/csp-server.js @@ -34,9 +34,15 @@ const CSP_DEMO_ALLOWLIST = { 'Button/Icons': { 'font-src': ['https://maxcdn.bootstrapcdn.com'], }, + // Azure Maps SDK: inline styles, blob workers, data: images, + // and font glyphs from atlas.microsoft.com Map: { 'script-src': ['https://atlas.microsoft.com'], + 'style-src': ["'unsafe-inline'"], 'connect-src': ['https://atlas.microsoft.com'], + 'worker-src': ['blob:'], + 'img-src': ['data:'], + 'font-src': ['https://atlas.microsoft.com'], }, 'DataGrid/CollaborativeEditing': { 'connect-src': ['wss://js.devexpress.com'], @@ -73,9 +79,24 @@ const CSP_DEMO_ALLOWLIST = { 'DataGrid/PDFExportImages': { 'img-src': ['data:'], }, + 'DataGrid/RemoteCRUDOperations': { + 'img-src': ['data:'], + }, + 'DataGrid/RemoteGrouping': { + 'img-src': ['data:'], + }, + 'DataGrid/RemoteReordering': { + 'img-src': ['data:'], + }, + 'DataGrid/RemoteVirtualScrolling': { + 'img-src': ['data:'], + }, 'DataGrid/VirtualScrolling': { 'img-src': ['data:'], }, + 'DataGrid/WebAPIService': { + 'img-src': ['data:'], + }, Gantt: { 'img-src': ['data:'], }, @@ -121,17 +142,27 @@ const CSP_DEMO_ALLOWLIST = { 'TreeList/FixedAndStickyColumns': { 'img-src': ['data:'], }, + 'TreeList/FocusedRow': { + 'img-src': ['data:'], + }, 'TreeList/MultipleSorting': { 'img-src': ['data:'], }, 'TreeList/SearchPanel': { 'img-src': ['data:'], }, + 'TreeList/WebAPIService': { + 'img-src': ['data:'], + }, 'TreeList/Overview': { 'img-src': ['data:'], // TODO: fix inline style in cellTemplate (background-image) 'style-src': ["'unsafe-inline'"], }, + // globalize/message.js uses new Function() internally + 'Localization/UsingGlobalize': { + 'script-src': ["'unsafe-eval'"], + }, // AI demo: inline