From 5d237f3f99916dfa2b6f36fdda7b1c1a930559d8 Mon Sep 17 00:00:00 2001 From: Blake Niemyjski Date: Thu, 9 Jul 2026 21:54:50 -0500 Subject: [PATCH 1/4] Secure SPA scripts with strict CSP --- .../ClientApp.angular/.eslintrc.json | 12 +- .../ClientApp.angular/Gruntfile.js | 24 +- .../ClientApp.angular/app/app.tpl.html | 4 +- .../object-dump/handlebars-service.js | 178 -------- .../object-dump/object-dump-directive.js | 96 ++++- .../ui-scroll/ui-scroll-directive.js | 1 + .../ClientApp.angular/grunt/csp-middleware.js | 338 +++++++++++++++ .../grunt/csp-middleware.test.js | 162 ++++++++ .../grunt/dev-certificate.js | 44 ++ .../grunt/live-reload-options.js | 15 + .../grunt/task-configs/connect.js | 42 +- .../grunt/task-configs/watch.js | 13 +- .../grunt/watch-config.test.js | 29 ++ .../ClientApp.angular/grunt/watch-tasks.js | 18 + .../ClientApp.angular/index.html | 4 +- .../ClientApp.angular/package-lock.json | 42 +- .../ClientApp.angular/package.json | 5 +- .../ClientApp/src/hooks.server.ts | 14 + .../server/content-security-policy.test.ts | 125 ++++++ .../src/lib/server/content-security-policy.ts | 197 +++++++++ .../Security/FrontendContentSecurityPolicy.cs | 145 +++++++ src/Exceptionless.Web/Startup.cs | 67 +-- .../Handlers/SpaIndexHtmlMiddleware.cs | 91 ++++ .../Handlers/SpaIndexHtmlMiddlewareTests.cs | 392 ++++++++++++++++++ 24 files changed, 1719 insertions(+), 339 deletions(-) delete mode 100644 src/Exceptionless.Web/ClientApp.angular/components/object-dump/handlebars-service.js create mode 100644 src/Exceptionless.Web/ClientApp.angular/grunt/csp-middleware.js create mode 100644 src/Exceptionless.Web/ClientApp.angular/grunt/csp-middleware.test.js create mode 100644 src/Exceptionless.Web/ClientApp.angular/grunt/dev-certificate.js create mode 100644 src/Exceptionless.Web/ClientApp.angular/grunt/live-reload-options.js create mode 100644 src/Exceptionless.Web/ClientApp.angular/grunt/watch-config.test.js create mode 100644 src/Exceptionless.Web/ClientApp.angular/grunt/watch-tasks.js create mode 100644 src/Exceptionless.Web/ClientApp/src/hooks.server.ts create mode 100644 src/Exceptionless.Web/ClientApp/src/lib/server/content-security-policy.test.ts create mode 100644 src/Exceptionless.Web/ClientApp/src/lib/server/content-security-policy.ts create mode 100644 src/Exceptionless.Web/Security/FrontendContentSecurityPolicy.cs create mode 100644 src/Exceptionless.Web/Utility/Handlers/SpaIndexHtmlMiddleware.cs create mode 100644 tests/Exceptionless.Tests/Utility/Handlers/SpaIndexHtmlMiddlewareTests.cs diff --git a/src/Exceptionless.Web/ClientApp.angular/.eslintrc.json b/src/Exceptionless.Web/ClientApp.angular/.eslintrc.json index 78b777e866..ea390fd87a 100644 --- a/src/Exceptionless.Web/ClientApp.angular/.eslintrc.json +++ b/src/Exceptionless.Web/ClientApp.angular/.eslintrc.json @@ -12,7 +12,17 @@ "moment": "readonly", "require": "readonly" }, - "overrides": [], + "overrides": [ + { + "env": { + "node": true + }, + "files": ["grunt/**/*.test.js"], + "parserOptions": { + "ecmaVersion": 2021 + } + } + ], "parserOptions": { "ecmaVersion": 2015 }, diff --git a/src/Exceptionless.Web/ClientApp.angular/Gruntfile.js b/src/Exceptionless.Web/ClientApp.angular/Gruntfile.js index 1855753c41..9f34c58769 100644 --- a/src/Exceptionless.Web/ClientApp.angular/Gruntfile.js +++ b/src/Exceptionless.Web/ClientApp.angular/Gruntfile.js @@ -27,8 +27,9 @@ var createFolderGlobs = function (fileTypePatterns) { .concat(fileTypePatterns); }; -module.exports = function (grunt) { +function configureGrunt(grunt) { var path = require("path"); + var getWatchTasks = require("./grunt/watch-tasks"); // load createFolderGlobs in options so it can be used across task configurations grunt.option("folderGlobs", createFolderGlobs); @@ -66,26 +67,15 @@ module.exports = function (grunt) { grunt.event.on("watch", function (action, filepath) { // https://github.com/gruntjs/grunt-contrib-watch/issues/156 - var tasksToRun = []; + var tasksToRun = getWatchTasks(filepath); - if (filepath.lastIndexOf(".html") !== -1 && filepath.lastIndexOf(".html") === filepath.length - 5) { - // validate the changed html file - grunt.config("htmlangular.main.files.src", [filepath]); - tasksToRun.push("htmlangular"); - } - - if (filepath.lastIndexOf(".js") !== -1 && filepath.lastIndexOf(".js") === filepath.length - 3) { + if (tasksToRun.indexOf("eslint") !== -1) { // lint the changed js file grunt.config("eslint.main.src", filepath); - tasksToRun.push("eslint"); - } - - // if index.html changed, we need to reread the ', + '', + '', + "", + "", + ].join(""); + + var server = http.createServer(function (request, response) { + cspMiddleware(request, response, function () { + livereloadMiddleware(request, response, function () { + response.setHeader("Content-Type", "text/html; charset=utf-8"); + response.end(sourceHtml); + }); + }); + }); + + return new Promise(function (resolve) { + server.listen(0, "127.0.0.1", function () { + resolve(server); + }); + }); +} + +function getServerUrl(server) { + return "http://127.0.0.1:" + server.address().port + "/"; +} + +test("creates a unique cryptographic nonce for each HTML response", async function (context) { + var server = await startServer(); + context.after(function () { + server.close(); + }); + + var firstResponse = await fetch(getServerUrl(server), { headers: { Accept: "text/html" } }); + var secondResponse = await fetch(getServerUrl(server), { headers: { Accept: "text/html" } }); + var firstNonce = getNonce(firstResponse.headers.get(csp.CSP_HEADER)); + var secondNonce = getNonce(secondResponse.headers.get(csp.CSP_HEADER)); + + assert.equal(Buffer.from(firstNonce, "base64").length, csp.NONCE_BYTE_LENGTH); + assert.equal(Buffer.from(secondNonce, "base64").length, csp.NONCE_BYTE_LENGTH); + assert.notEqual(firstNonce, secondNonce); +}); + +test("stamps every parser-inserted script after LiveReload injection", async function (context) { + var server = await startServer(); + context.after(function () { + server.close(); + }); + + var response = await fetch(getServerUrl(server), { headers: { Accept: "text/html" } }); + var policy = response.headers.get(csp.CSP_HEADER); + var nonce = getNonce(policy); + var html = await response.text(); + var scriptTags = getScriptTags(html); + + assert.equal(scriptTags.length, 5); + assert.match(html, /\/livereload\.js\?snipver=1/); + assert.doesNotMatch(html, /nonce="stale"/); + assert.doesNotMatch(html, /nonce=also-stale/); + assert.match(html, /data-state="ready > pending"/); + scriptTags.forEach(function (scriptTag) { + var matches = scriptTag.match(/\snonce="([^"]+)"/g) || []; + assert.deepEqual(matches, [' nonce="' + nonce + '"']); + }); +}); + +test("serves HTML with a strict script policy and no cache", async function (context) { + var server = await startServer(); + context.after(function () { + server.close(); + }); + + var response = await fetch(getServerUrl(server), { headers: { Accept: "text/html" } }); + var policy = response.headers.get(csp.CSP_HEADER); + var scriptDirective = getScriptDirective(policy); + var styleDirective = getDirective(policy, "style-src"); + var connectDirective = getDirective(policy, "connect-src"); + var directiveNames = policy.split("; ").map(function (directive) { + return directive.split(" ", 1)[0]; + }); + + assert.equal(response.headers.get("Cache-Control"), csp.HTML_CACHE_CONTROL); + assert.deepEqual(directiveNames, [ + "default-src", + "script-src", + "script-src-attr", + "style-src", + "img-src", + "font-src", + "connect-src", + "frame-src", + "media-src", + "worker-src", + "form-action", + "manifest-src", + "base-uri", + "object-src", + "frame-ancestors", + ]); + assert.equal(getDirective(policy, "default-src"), "default-src 'self'"); + assert.match(scriptDirective, /'nonce-[^']+'/); + assert.match(scriptDirective, /'strict-dynamic'/); + assert.doesNotMatch(scriptDirective, /'unsafe-inline'/); + assert.doesNotMatch(scriptDirective, /'unsafe-eval'/); + assert.match(policy, /script-src-attr 'none'/); + assert.match(styleDirective, /'unsafe-inline'/); + assert.match(connectDirective, /(?:^| )ws:(?: |$)/); + assert.match(connectDirective, /(?:^| )wss:(?: |$)/); + assert.doesNotMatch(policy, /(?:^|[ ;])http:/); + assert.equal(getDirective(policy, "base-uri"), "base-uri 'none'"); + assert.equal(getDirective(policy, "object-src"), "object-src 'none'"); + assert.equal(getDirective(policy, "frame-ancestors"), "frame-ancestors 'none'"); +}); + +test("leaves Angular template XHR caching unchanged", async function (context) { + var server = await startServer(); + context.after(function () { + server.close(); + }); + + var response = await fetch(getServerUrl(server) + "app/auth/login.tpl.html", { + headers: { Accept: "application/json, text/plain, */*" }, + }); + + assert.equal(response.headers.get(csp.CSP_HEADER), null); + assert.equal(response.headers.get("Cache-Control"), null); +}); diff --git a/src/Exceptionless.Web/ClientApp.angular/grunt/dev-certificate.js b/src/Exceptionless.Web/ClientApp.angular/grunt/dev-certificate.js new file mode 100644 index 0000000000..d7ab3ddfcb --- /dev/null +++ b/src/Exceptionless.Web/ClientApp.angular/grunt/dev-certificate.js @@ -0,0 +1,44 @@ +/* eslint-env node */ + +"use strict"; + +var childProcess = require("child_process"); +var fs = require("fs"); +var path = require("path"); + +function getDevCertificate() { + var baseFolder = + process.env.APPDATA !== undefined && process.env.APPDATA !== "" + ? path.join(process.env.APPDATA, "ASP.NET", "https") + : path.join(process.env.HOME, ".aspnet", "https"); + var certificateName = process.env.npm_package_name; + + if (!certificateName) { + throw new Error("Invalid certificate name. Run this command through an npm script."); + } + + var certFilePath = path.join(baseFolder, certificateName + ".pem"); + var keyFilePath = path.join(baseFolder, certificateName + ".key"); + + if (!fs.existsSync(certFilePath) || !fs.existsSync(keyFilePath)) { + fs.mkdirSync(baseFolder, { recursive: true }); + childProcess.execFileSync("dotnet", [ + "dev-certs", + "https", + "--export-path", + certFilePath, + "--format", + "Pem", + "--no-password", + ]); + } + + return { + cert: fs.readFileSync(certFilePath, "utf8"), + key: fs.readFileSync(keyFilePath, "utf8"), + }; +} + +module.exports = { + getDevCertificate: getDevCertificate, +}; diff --git a/src/Exceptionless.Web/ClientApp.angular/grunt/live-reload-options.js b/src/Exceptionless.Web/ClientApp.angular/grunt/live-reload-options.js new file mode 100644 index 0000000000..84f07e8081 --- /dev/null +++ b/src/Exceptionless.Web/ClientApp.angular/grunt/live-reload-options.js @@ -0,0 +1,15 @@ +/* eslint-env node */ + +"use strict"; + +module.exports = function createLiveReloadOptions(port, useHttps, certificate) { + if (!useHttps) { + return port; + } + + return { + cert: certificate.cert, + key: certificate.key, + port: port, + }; +}; diff --git a/src/Exceptionless.Web/ClientApp.angular/grunt/task-configs/connect.js b/src/Exceptionless.Web/ClientApp.angular/grunt/task-configs/connect.js index 0a3dfc3006..9870047d9e 100644 --- a/src/Exceptionless.Web/ClientApp.angular/grunt/task-configs/connect.js +++ b/src/Exceptionless.Web/ClientApp.angular/grunt/task-configs/connect.js @@ -1,17 +1,16 @@ -var path = require("path"); -var fs = require("fs"); -var s = require("child_process"); // eslint-disable-next-line import/no-extraneous-dependencies var livereload = require("connect-livereload"); // eslint-disable-next-line import/no-extraneous-dependencies var proxyRequest = require("grunt-connect-proxy2/lib/utils").proxyRequest; +var csp = require("../csp-middleware"); +var devCertificate = require("../dev-certificate"); module.exports = function () { var target = getTarget(); var useHttps = String(process.env.USE_HTTPS || "").toLowerCase() === "true"; var port = Number(process.env.PORT) || 7121; var liveReloadPort = Number(process.env.LIVERELOAD_PORT) || 35729; - var certs = useHttps ? generateCerts() : { cert: undefined, key: undefined }; + var certs = useHttps ? devCertificate.getDevCertificate() : { cert: undefined, key: undefined }; return { main: { @@ -22,7 +21,12 @@ module.exports = function () { cert: certs.cert, middleware: function (connect, options, middlewares) { middlewares.unshift(proxyRequest); - middlewares.splice(1, 0, livereload({ port: liveReloadPort })); + middlewares.splice( + 1, + 0, + csp.createCspMiddleware(), + livereload({ disableCompression: true, port: liveReloadPort }) + ); return middlewares; }, }, @@ -83,31 +87,3 @@ function getTarget() { return { host: "localhost", port: 7110, ssl: false }; } - -function generateCerts() { - var baseFolder = - process.env.APPDATA !== undefined && process.env.APPDATA !== "" - ? `${process.env.APPDATA}/ASP.NET/https` - : `${process.env.HOME}/.aspnet/https`; - var certificateName = process.env.npm_package_name; - - if (!certificateName) { - // eslint-disable-next-line no-console - console.error("Invalid certificate name. Run this script in the context of an npm script."); - process.exit(-1); - } - - var certFilePath = path.join(baseFolder, `${certificateName}.pem`); - var keyFilePath = path.join(baseFolder, `${certificateName}.key`); - - if (!fs.existsSync(certFilePath) || !fs.existsSync(keyFilePath)) { - var outp = s.execSync(`dotnet dev-certs https --export-path "${certFilePath}" --format Pem --no-password`); - // eslint-disable-next-line no-console - console.log(outp.toString()); - } - - return { - cert: fs.readFileSync(certFilePath, "utf8"), - key: fs.readFileSync(keyFilePath, "utf8"), - }; -} diff --git a/src/Exceptionless.Web/ClientApp.angular/grunt/task-configs/watch.js b/src/Exceptionless.Web/ClientApp.angular/grunt/task-configs/watch.js index 722d21ef9b..7749847375 100644 --- a/src/Exceptionless.Web/ClientApp.angular/grunt/task-configs/watch.js +++ b/src/Exceptionless.Web/ClientApp.angular/grunt/task-configs/watch.js @@ -1,10 +1,15 @@ -module.exports = function (grunt) { +var devCertificate = require("../dev-certificate"); +var createLiveReloadOptions = require("../live-reload-options"); + +function createWatchConfig(grunt) { var liveReloadPort = Number(process.env.LIVERELOAD_PORT) || 35729; + var useHttps = String(process.env.USE_HTTPS || "").toLowerCase() === "true"; + var certificate = useHttps ? devCertificate.getDevCertificate() : {}; return { main: { options: { - livereload: liveReloadPort, + livereload: createLiveReloadOptions(liveReloadPort, useHttps, certificate), livereloadOnError: false, spawn: false, }, @@ -12,4 +17,6 @@ module.exports = function (grunt) { tasks: [], // all the tasks are run dynamically during the watch event handler }, }; -}; +} + +module.exports = createWatchConfig; diff --git a/src/Exceptionless.Web/ClientApp.angular/grunt/watch-config.test.js b/src/Exceptionless.Web/ClientApp.angular/grunt/watch-config.test.js new file mode 100644 index 0000000000..f041709122 --- /dev/null +++ b/src/Exceptionless.Web/ClientApp.angular/grunt/watch-config.test.js @@ -0,0 +1,29 @@ +/* eslint-env node */ + +"use strict"; + +var assert = require("node:assert/strict"); +var test = require("node:test"); +var createLiveReloadOptions = require("./live-reload-options"); +var getWatchTasks = require("./watch-tasks"); + +test("uses the numeric LiveReload port for HTTP development", function () { + assert.equal(createLiveReloadOptions(35729, false, {}), 35729); +}); + +test("uses the development certificate for HTTPS LiveReload", function () { + assert.deepEqual(createLiveReloadOptions(35729, true, { cert: "certificate", key: "private-key" }), { + cert: "certificate", + key: "private-key", + port: 35729, + }); +}); + +test("reloads HTML without invoking the obsolete remote validator", function () { + assert.deepEqual(getWatchTasks("app/app.tpl.html"), []); + assert.deepEqual(getWatchTasks("index.html"), ["dom_munger:read"]); +}); + +test("still lints changed JavaScript", function () { + assert.deepEqual(getWatchTasks("components/example.js"), ["eslint"]); +}); diff --git a/src/Exceptionless.Web/ClientApp.angular/grunt/watch-tasks.js b/src/Exceptionless.Web/ClientApp.angular/grunt/watch-tasks.js new file mode 100644 index 0000000000..d48fb5ab8a --- /dev/null +++ b/src/Exceptionless.Web/ClientApp.angular/grunt/watch-tasks.js @@ -0,0 +1,18 @@ +/* eslint-env node */ + +"use strict"; + +module.exports = function getWatchTasks(filepath) { + var tasks = []; + + // HTML reloads directly; the legacy remote validator is excluded from builds and fails on current Node versions. + if (filepath.endsWith(".js")) { + tasks.push("eslint"); + } + + if (filepath === "index.html") { + tasks.push("dom_munger:read"); + } + + return tasks; +}; diff --git a/src/Exceptionless.Web/ClientApp.angular/index.html b/src/Exceptionless.Web/ClientApp.angular/index.html index a65c9f7172..326b03c884 100644 --- a/src/Exceptionless.Web/ClientApp.angular/index.html +++ b/src/Exceptionless.Web/ClientApp.angular/index.html @@ -1,5 +1,5 @@ - + Exceptionless @@ -93,7 +93,6 @@ - @@ -174,7 +173,6 @@ - diff --git a/src/Exceptionless.Web/ClientApp.angular/package-lock.json b/src/Exceptionless.Web/ClientApp.angular/package-lock.json index 2865348065..0df84146e3 100644 --- a/src/Exceptionless.Web/ClientApp.angular/package-lock.json +++ b/src/Exceptionless.Web/ClientApp.angular/package-lock.json @@ -37,7 +37,6 @@ "d3": "3.5.17", "exceptionless": "1.6.4", "font-awesome": "4.7.0", - "handlebars": "4.7.7", "jquery": "3.7.0", "less": "4.1.3", "li": "1.3.0", @@ -54,6 +53,7 @@ "twix": "1.3.0" }, "devDependencies": { + "connect-livereload": "0.6.1", "cross-env": "7.0.3", "eslint": "^8.45.0", "eslint-config-airbnb-base": "^15.0.0", @@ -4633,39 +4633,6 @@ "node": ">=6" } }, - "node_modules/handlebars": { - "version": "4.7.7", - "resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.7.7.tgz", - "integrity": "sha512-aAcXm5OAfE/8IXkcZvCepKU3VzW1/39Fb5ZuqMtgI/hT8X2YgoMvBY5dLhq/cpOvw7Lk1nK/UF71aLG/ZnVYRA==", - "dependencies": { - "minimist": "^1.2.5", - "neo-async": "^2.6.0", - "source-map": "^0.6.1", - "wordwrap": "^1.0.0" - }, - "bin": { - "handlebars": "bin/handlebars" - }, - "engines": { - "node": ">=0.4.7" - }, - "optionalDependencies": { - "uglify-js": "^3.1.4" - } - }, - "node_modules/handlebars/node_modules/source-map": { - "version": "0.6.1", - "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", - "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/handlebars/node_modules/wordwrap": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/wordwrap/-/wordwrap-1.0.0.tgz", - "integrity": "sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q==" - }, "node_modules/has": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/has/-/has-1.0.3.tgz", @@ -6438,11 +6405,6 @@ "node": ">= 0.6" } }, - "node_modules/neo-async": { - "version": "2.6.2", - "resolved": "https://registry.npmjs.org/neo-async/-/neo-async-2.6.2.tgz", - "integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==" - }, "node_modules/netmask": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/netmask/-/netmask-1.0.6.tgz", @@ -9073,7 +9035,7 @@ "version": "3.17.4", "resolved": "https://registry.npmjs.org/uglify-js/-/uglify-js-3.17.4.tgz", "integrity": "sha512-T9q82TJI9e/C1TAxYvfb16xO120tMVFZrGA3f9/P4424DNu6ypK103y0GPFVa17yotwSyZW5iYXgjYHkGrJW/g==", - "devOptional": true, + "dev": true, "bin": { "uglifyjs": "bin/uglifyjs" }, diff --git a/src/Exceptionless.Web/ClientApp.angular/package.json b/src/Exceptionless.Web/ClientApp.angular/package.json index 120fe16e81..5d5c1386c2 100644 --- a/src/Exceptionless.Web/ClientApp.angular/package.json +++ b/src/Exceptionless.Web/ClientApp.angular/package.json @@ -4,7 +4,8 @@ "scripts": { "build": "npx grunt build", "serve": "npx grunt serve", - "prod-serve": "cross-env ASPNETCORE_URLS=https://api.exceptionless.io npx grunt serve", + "prod-serve": "cross-env API_HTTPS=https://api.exceptionless.io npx grunt serve", + "test": "node --test grunt/csp-middleware.test.js grunt/watch-config.test.js", "lint": "eslint . --ext .js", "lint:fix": "eslint . --ext .js --fix", "prettier": "prettier . --write --ignore-unknown", @@ -12,6 +13,7 @@ "prettier:staged": "prettier --write --ignore-unknown" }, "devDependencies": { + "connect-livereload": "0.6.1", "cross-env": "7.0.3", "eslint": "^8.45.0", "eslint-config-airbnb-base": "^15.0.0", @@ -71,7 +73,6 @@ "d3": "3.5.17", "exceptionless": "1.6.4", "font-awesome": "4.7.0", - "handlebars": "4.7.7", "jquery": "3.7.0", "less": "4.1.3", "li": "1.3.0", diff --git a/src/Exceptionless.Web/ClientApp/src/hooks.server.ts b/src/Exceptionless.Web/ClientApp/src/hooks.server.ts new file mode 100644 index 0000000000..e0a33adb49 --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/hooks.server.ts @@ -0,0 +1,14 @@ +import type { Handle } from '@sveltejs/kit'; + +import { building } from '$app/environment'; +import { secureHtmlResponse } from '$lib/server/content-security-policy'; + +export const handle: Handle = async ({ event, resolve }) => { + const response = await resolve(event); + + if (building) { + return response; + } + + return secureHtmlResponse(response, { allowDevelopmentConnections: true }); +}; diff --git a/src/Exceptionless.Web/ClientApp/src/lib/server/content-security-policy.test.ts b/src/Exceptionless.Web/ClientApp/src/lib/server/content-security-policy.test.ts new file mode 100644 index 0000000000..8038af8520 --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/lib/server/content-security-policy.test.ts @@ -0,0 +1,125 @@ +import { describe, expect, it } from 'vitest'; + +import { addNonceToScripts, createContentSecurityPolicy, createNonce, secureHtmlResponse } from './content-security-policy'; + +describe('createNonce', () => { + it('creates unique base64-encoded 32-byte nonces', () => { + const nonces = Array.from({ length: 32 }, () => createNonce()); + + expect(new Set(nonces)).toHaveLength(nonces.length); + for (const nonce of nonces) { + expect(nonce).toMatch(/^[A-Za-z\d+/]{43}=$/); + expect(Buffer.from(nonce, 'base64')).toHaveLength(32); + } + }); +}); + +describe('addNonceToScripts', () => { + it('adds the nonce to every script opening tag', () => { + const nonce = createNonce(); + const html = ''; + + expect(addNonceToScripts(html, nonce)).toBe(``); + }); + + it('replaces existing quoted, unquoted, and boolean nonce attributes', () => { + const nonce = createNonce(); + const html = ``; + + expect(addNonceToScripts(html, nonce)).toBe( + `` + ); + }); +}); + +describe('createContentSecurityPolicy', () => { + it('uses a strict nonce policy with compatibility sources', () => { + const nonce = createNonce(); + const policy = createContentSecurityPolicy(nonce); + const scriptDirective = getDirective(policy, 'script-src'); + const connectDirective = getDirective(policy, 'connect-src'); + + expect(scriptDirective).toContain(`'nonce-${nonce}'`); + expect(scriptDirective).toContain("'strict-dynamic'"); + expect(scriptDirective).toContain("'self'"); + expect(scriptDirective).toContain('https://js.stripe.com'); + expect(scriptDirective).toContain('https://*.js.stripe.com'); + expect(scriptDirective).toContain('https://widget.intercom.io'); + expect(scriptDirective).not.toContain("'unsafe-inline'"); + expect(scriptDirective).not.toContain("'unsafe-eval'"); + expect(getDirective(policy, 'script-src-attr')).toEqual(["'none'"]); + + expect(connectDirective).toContain("'self'"); + expect(connectDirective).toContain('https://api.stripe.com'); + expect(connectDirective).toContain('wss://*.intercom-messenger.com'); + expect(connectDirective).not.toContain('ws:'); + expect(connectDirective).not.toContain('wss:'); + + expect(getDirective(policy, 'img-src')).not.toContain('http://www.gravatar.com'); + + expect(getDirective(policy, 'base-uri')).toEqual(["'none'"]); + expect(getDirective(policy, 'object-src')).toEqual(["'none'"]); + expect(getDirective(policy, 'frame-ancestors')).toEqual(["'none'"]); + }); + + it('allows broad WebSocket schemes only when development connections are requested', () => { + const policy = createContentSecurityPolicy(createNonce(), { allowDevelopmentConnections: true }); + const connectDirective = getDirective(policy, 'connect-src'); + + expect(connectDirective).toContain('ws:'); + expect(connectDirective).toContain('wss:'); + }); +}); + +describe('secureHtmlResponse', () => { + it('buffers chunked HTML, nonces every script, and prevents nonce/body caching', async () => { + const encoder = new TextEncoder(); + const stream = new ReadableStream({ + start(controller) { + controller.enqueue(encoder.encode('start()')); + controller.close(); + } + }); + const originalResponse = new Response(stream, { + headers: { + 'content-length': '123', + 'content-type': 'text/html; charset=utf-8', + etag: 'stale-after-transformation' + } + }); + + const response = await secureHtmlResponse(originalResponse, { allowDevelopmentConnections: true }); + const html = await response.text(); + const nonce = html.match(/"; + bool nextCalled = false; + var middleware = CreateMiddleware( + new Dictionary { [filePath] = html }, + context => + { + nextCalled = true; + return Task.CompletedTask; + }); + var context = CreateContext(requestPath); + context.Response.Headers.ETag = "\"cached\""; + context.Response.Headers.LastModified = DateTimeOffset.UtcNow.ToString("R"); + + await middleware.InvokeAsync(context, new TestNonceService("fresh-nonce")); + + string responseBody = ReadResponseBody(context); + Assert.False(nextCalled); + Assert.Equal(StatusCodes.Status200OK, context.Response.StatusCode); + Assert.Equal("text/html; charset=utf-8", context.Response.ContentType); + Assert.Equal("no-store", context.Response.Headers.CacheControl); + Assert.False(context.Response.Headers.ContainsKey(HeaderNames.ETag)); + Assert.False(context.Response.Headers.ContainsKey(HeaderNames.LastModified)); + Assert.Equal(2, CountOccurrences(responseBody, "")] + [InlineData("")] + [InlineData("")] + [InlineData("")] + public void AddScriptNonce_ScriptWithExistingNonce_ReplacesNonce(string html) + { + string result = SpaIndexHtmlMiddleware.AddScriptNonce(html, "new"); + + Assert.Equal(1, CountOccurrences(result, "nonce=\"new\"")); + Assert.DoesNotContain("old", result, StringComparison.Ordinal); + } + + [Fact] + public void AddScriptNonce_QuotedGreaterThanInAttribute_PreservesOpeningTag() + { + const string html = ""; + + string result = SpaIndexHtmlMiddleware.AddScriptNonce(html, "new"); + + Assert.Equal("", result); + } + + [Fact] + public void AddScriptNonce_SimilarlyNamedAttributes_PreservesAttributes() + { + const string html = ""; + + string result = SpaIndexHtmlMiddleware.AddScriptNonce(html, "new"); + + Assert.Contains("data-nonce=\"keep\"", result, StringComparison.Ordinal); + Assert.Contains("noncevalue=\"keep\"", result, StringComparison.Ordinal); + Assert.Contains("nonce-value=\"keep\"", result, StringComparison.Ordinal); + Assert.Equal(1, CountOccurrences(result, "nonce=\"new\"")); + } + + [Fact] + public async Task InvokeAsync_HeadIndexRequest_WritesHeadersWithoutBody() + { + const string html = ""; + var middleware = CreateMiddleware(new Dictionary { ["index.html"] = html }); + var context = CreateContext("/index.html", HttpMethods.Head); + + await middleware.InvokeAsync(context, new TestNonceService("head-nonce")); + + string expectedResponse = ""; + Assert.Equal(StatusCodes.Status200OK, context.Response.StatusCode); + Assert.Equal("text/html; charset=utf-8", context.Response.ContentType); + Assert.Equal("no-store", context.Response.Headers.CacheControl); + Assert.Equal(Encoding.UTF8.GetByteCount(expectedResponse), context.Response.ContentLength); + Assert.Equal(String.Empty, ReadResponseBody(context)); + } + + [Fact] + public async Task InvokeAsync_NonIndexRequest_CallsNext() + { + bool nextCalled = false; + var middleware = CreateMiddleware( + new Dictionary { ["app.js"] = "console.log('ok');" }, + context => + { + nextCalled = true; + return Task.CompletedTask; + }); + var context = CreateContext("/app.js"); + + await middleware.InvokeAsync(context, new TestNonceService("unused")); + + Assert.True(nextCalled); + Assert.Equal(String.Empty, ReadResponseBody(context)); + Assert.False(context.Response.Headers.ContainsKey(HeaderNames.CacheControl)); + } + + [Fact] + public async Task InvokeAsync_MissingIndexFile_CallsNext() + { + bool nextCalled = false; + var middleware = CreateMiddleware( + new Dictionary(), + context => + { + nextCalled = true; + context.Response.StatusCode = StatusCodes.Status404NotFound; + return Task.CompletedTask; + }); + var context = CreateContext("/next/index.html"); + + await middleware.InvokeAsync(context, new TestNonceService("unused")); + + Assert.True(nextCalled); + Assert.Equal(StatusCodes.Status404NotFound, context.Response.StatusCode); + Assert.False(context.Response.Headers.ContainsKey(HeaderNames.CacheControl)); + } + + [Fact] + public async Task InvokeAsync_PostIndexRequest_CallsNext() + { + bool nextCalled = false; + var middleware = CreateMiddleware( + new Dictionary { ["index.html"] = "" }, + context => + { + nextCalled = true; + return Task.CompletedTask; + }); + var context = CreateContext("/index.html", HttpMethods.Post); + + await middleware.InvokeAsync(context, new TestNonceService("unused")); + + Assert.True(nextCalled); + Assert.Equal(String.Empty, ReadResponseBody(context)); + } + + [Fact] + public async Task Configure_IndexAndFallbackRoutes_ServeFreshNoncedHtml() + { + string webRoot = Path.Combine(Path.GetTempPath(), "Exceptionless-SpaIndexHtmlMiddlewareTests", Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(Path.Combine(webRoot, "next")); + await File.WriteAllTextAsync(Path.Combine(webRoot, "index.html"), "root", TestContext.Current.CancellationToken); + await File.WriteAllTextAsync(Path.Combine(webRoot, "next", "index.html"), "next", TestContext.Current.CancellationToken); + await File.WriteAllTextAsync(Path.Combine(webRoot, "app.js"), "console.log('static');", TestContext.Current.CancellationToken); + + try + { + using IHost host = await CreatePipelineHostAsync(webRoot); + using HttpClient client = host.GetTestClient(); + string? previousNonce = null; + (string Path, string Marker)[] routes = + [ + ("/", "root"), + ("/index.html", "root"), + ("/legacy/deep-route", "root"), + ("/next/", "next"), + ("/next/index.html", "next"), + ("/next/deep-route", "next") + ]; + + foreach ((string path, string marker) in routes) + { + using HttpResponseMessage response = await client.GetAsync(path, TestContext.Current.CancellationToken); + string body = await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken); + string policy = response.Headers.GetValues("Content-Security-Policy").Single(); + string nonce = GetScriptNonce(body); + + Assert.Equal(StatusCodes.Status200OK, (int)response.StatusCode); + Assert.Contains(marker, body, StringComparison.Ordinal); + Assert.Equal("no-store", response.Headers.CacheControl?.ToString()); + Assert.Contains($"'nonce-{nonce}'", policy, StringComparison.Ordinal); + Assert.Contains("'strict-dynamic'", policy, StringComparison.Ordinal); + Assert.NotEqual(previousNonce, nonce); + previousNonce = nonce; + } + + using HttpResponseMessage staticResponse = await client.GetAsync("/app.js", TestContext.Current.CancellationToken); + Assert.Equal("console.log('static');", await staticResponse.Content.ReadAsStringAsync(TestContext.Current.CancellationToken)); + Assert.NotEqual("no-store", staticResponse.Headers.CacheControl?.ToString()); + + using HttpResponseMessage scalarResponse = await client.GetAsync("/docs/", TestContext.Current.CancellationToken); + string scalarBody = await scalarResponse.Content.ReadAsStringAsync(TestContext.Current.CancellationToken); + string scalarNonce = GetNonceAttribute(scalarBody); + string scalarPolicy = scalarResponse.Headers.GetValues("Content-Security-Policy").Single(); + Assert.Equal("no-store", scalarResponse.Headers.CacheControl?.ToString()); + Assert.Contains($"'nonce-{scalarNonce}'", scalarPolicy, StringComparison.Ordinal); + } + finally + { + Directory.Delete(webRoot, recursive: true); + } + } + + [Fact] + public async Task ConfigureContentSecurityPolicy_DefaultAndScriptDirectives_AreLockedDown() + { + var builder = new CspBuilder(); + FrontendContentSecurityPolicy.Configure(builder); + var options = builder.BuildCspOptions(); + + (_, string policy) = options.ToString(new TestNonceService("policy-nonce")); + string scriptDirective = GetDirective(policy, "script-src"); + string imageDirective = GetDirective(policy, "img-src"); + + Assert.Contains("default-src 'self'", policy, StringComparison.Ordinal); + Assert.Contains("object-src 'none'", policy, StringComparison.Ordinal); + Assert.Contains("base-uri 'none'", policy, StringComparison.Ordinal); + Assert.Contains("frame-ancestors 'none'", policy, StringComparison.Ordinal); + Assert.Contains("form-action 'self'", policy, StringComparison.Ordinal); + Assert.Contains("manifest-src 'self'", policy, StringComparison.Ordinal); + Assert.Contains("worker-src 'self' blob:", policy, StringComparison.Ordinal); + Assert.Contains("frame-src 'self' https://*.js.stripe.com", policy, StringComparison.Ordinal); + Assert.Contains("connect-src 'self'", policy, StringComparison.Ordinal); + Assert.Contains("https://api.stripe.com", policy, StringComparison.Ordinal); + Assert.Contains("img-src 'self' data: blob: https://*.stripe.com https://*.link.com", policy, StringComparison.Ordinal); + Assert.Contains("https://uploads.intercomcdn.com", imageDirective, StringComparison.Ordinal); + Assert.Contains("wss://*.intercom-messenger.com", policy, StringComparison.Ordinal); + Assert.Contains("'nonce-policy-nonce'", scriptDirective, StringComparison.Ordinal); + Assert.Contains("'strict-dynamic'", scriptDirective, StringComparison.Ordinal); + Assert.DoesNotContain("'unsafe-inline'", scriptDirective, StringComparison.Ordinal); + Assert.DoesNotContain("'unsafe-eval'", scriptDirective, StringComparison.Ordinal); + Assert.DoesNotContain("http://", policy, StringComparison.Ordinal); + + var apiContext = new DefaultHttpContext(); + apiContext.Request.Path = "/api/v2/about"; + var sendingHeaderContext = new CspSendingHeaderContext(apiContext); + await options.OnSendingHeader(sendingHeaderContext); + Assert.True(sendingHeaderContext.ShouldNotSend); + } + + [Fact] + public void AddCsp_SeparateScopes_ProvidesDistinct32ByteNonces() + { + var services = new ServiceCollection(); + services.AddCsp(nonceByteAmount: 32); + using ServiceProvider provider = services.BuildServiceProvider(); + string firstNonce; + + using (IServiceScope firstScope = provider.CreateScope()) + { + var nonceService = firstScope.ServiceProvider.GetRequiredService(); + firstNonce = nonceService.GetNonce(); + Assert.Equal(firstNonce, nonceService.GetNonce()); + Assert.Equal(32, Convert.FromBase64String(firstNonce).Length); + } + + using IServiceScope secondScope = provider.CreateScope(); + string secondNonce = secondScope.ServiceProvider.GetRequiredService().GetNonce(); + Assert.Equal(32, Convert.FromBase64String(secondNonce).Length); + Assert.NotEqual(firstNonce, secondNonce); + } + + private static SpaIndexHtmlMiddleware CreateMiddleware(IReadOnlyDictionary files, RequestDelegate? next = null) + { + return new SpaIndexHtmlMiddleware( + next ?? (_ => Task.CompletedTask), + new InMemoryFileProvider(files)); + } + + private static async Task CreatePipelineHostAsync(string webRoot) + { + IHost host = Host.CreateDefaultBuilder() + .ConfigureWebHost(webBuilder => webBuilder + .UseContentRoot(webRoot) + .UseWebRoot(webRoot) + .UseTestServer() + .ConfigureServices(services => + { + services.AddCsp(nonceByteAmount: 32); + services.AddRouting(); + }) + .Configure(app => + { + app.UseCsp(FrontendContentSecurityPolicy.Configure); + app.UseDefaultFiles(); + app.UseMiddleware(); + app.UseStaticFiles(); + app.UseRouting(); + app.UseEndpoints(endpoints => + { + endpoints.MapScalarApiReference("/docs", (options, context) => + options.WithNonce(context.RequestServices.GetRequiredService().GetNonce())); + endpoints.MapFallback("{**slug:nonfile}", Startup.CreateSpaFallbackRequestDelegate(endpoints)); + }); + })) + .Build(); + + await host.StartAsync(TestContext.Current.CancellationToken); + return host; + } + + private static DefaultHttpContext CreateContext(string path, string method = "GET") + { + var context = new DefaultHttpContext(); + context.Request.Method = method; + context.Request.Path = path; + context.Response.Body = new MemoryStream(); + return context; + } + + private static string ReadResponseBody(DefaultHttpContext context) + { + context.Response.Body.Position = 0; + using var reader = new StreamReader(context.Response.Body, leaveOpen: true); + return reader.ReadToEnd(); + } + + private static int CountOccurrences(string value, string search) + { + return value.Split(search, StringSplitOptions.None).Length - 1; + } + + private static string GetScriptNonce(string html) + { + Assert.Contains("= 0); + nonceStart += noncePrefix.Length; + int nonceEnd = html.IndexOf('"', nonceStart); + Assert.True(nonceEnd > nonceStart); + return System.Net.WebUtility.HtmlDecode(html[nonceStart..nonceEnd]); + } + + private static string GetDirective(string policy, string directiveName) + { + return policy.Split(';').Single(directive => directive.StartsWith(directiveName + " ", StringComparison.Ordinal)); + } + + private sealed class TestNonceService(string nonce) : ICspNonceService + { + public string GetNonce() => nonce; + } + + private sealed class InMemoryFileProvider(IReadOnlyDictionary files) : IFileProvider + { + public IDirectoryContents GetDirectoryContents(string subpath) => NotFoundDirectoryContents.Singleton; + + public IFileInfo GetFileInfo(string subpath) + { + return files.TryGetValue(subpath.TrimStart('/'), out string? content) + ? new InMemoryFileInfo(subpath, content) + : new NotFoundFileInfo(subpath); + } + + public IChangeToken Watch(string filter) => NullChangeToken.Singleton; + } + + private sealed class InMemoryFileInfo(string name, string content) : IFileInfo + { + public bool Exists => true; + public long Length => Encoding.UTF8.GetByteCount(content); + public string? PhysicalPath => null; + public string Name => name; + public DateTimeOffset LastModified => DateTimeOffset.MinValue; + public bool IsDirectory => false; + + public Stream CreateReadStream() => new MemoryStream(Encoding.UTF8.GetBytes(content), writable: false); + } +} From 3b34a7ec170566c93c2ed3507681f2522e821aad Mon Sep 17 00:00:00 2001 From: Blake Niemyjski Date: Fri, 10 Jul 2026 10:19:51 -0500 Subject: [PATCH 2/4] Scope Intercom CSP sources to US --- .../ClientApp.angular/grunt/csp-middleware.js | 40 ++----------------- .../grunt/csp-middleware.test.js | 5 +++ .../server/content-security-policy.test.ts | 7 ++++ .../src/lib/server/content-security-policy.ts | 29 +++----------- .../Security/FrontendContentSecurityPolicy.cs | 31 ++------------ .../Handlers/SpaIndexHtmlMiddlewareTests.cs | 7 ++++ 6 files changed, 31 insertions(+), 88 deletions(-) diff --git a/src/Exceptionless.Web/ClientApp.angular/grunt/csp-middleware.js b/src/Exceptionless.Web/ClientApp.angular/grunt/csp-middleware.js index 51f7d13e01..66ab00faa9 100644 --- a/src/Exceptionless.Web/ClientApp.angular/grunt/csp-middleware.js +++ b/src/Exceptionless.Web/ClientApp.angular/grunt/csp-middleware.js @@ -10,6 +10,7 @@ var NONCE_BYTE_LENGTH = 32; var SCRIPT_NONCE_PATTERN = /\s+nonce(?:\s*=\s*(?:"[^"]*"|'[^']*'|[^\s>]+))?/gi; var SCRIPT_TAG_PATTERN = /])*)>/gi; +// Exceptionless uses Intercom's US endpoints. Keep region-specific sources scoped to that workspace. var intercomChildSources = [ "https://intercom-sheets.com", "https://www.intercom-reporting.com", @@ -18,19 +19,9 @@ var intercomChildSources = [ "https://fast.wistia.net", ]; -var intercomDownloadSources = [ - "https://downloads.intercomcdn.com", - "https://downloads.intercomcdn.eu", - "https://downloads.au.intercomcdn.com", -]; +var intercomDownloadSources = ["https://downloads.intercomcdn.com"]; -var intercomUploadSources = [ - "https://uploads.intercomcdn.com", - "https://uploads.intercomcdn.eu", - "https://uploads.au.intercomcdn.com", - "https://uploads.eu.intercomcdn.com", - "https://uploads.intercomusercontent.com", -]; +var intercomUploadSources = ["https://uploads.intercomcdn.com", "https://uploads.intercomusercontent.com"]; var intercomAttachmentSources = [ "https://*.intercom-attachments-1.com", @@ -42,8 +33,6 @@ var intercomAttachmentSources = [ "https://*.intercom-attachments-7.com", "https://*.intercom-attachments-8.com", "https://*.intercom-attachments-9.com", - "https://*.intercom-attachments.eu", - "https://*.au.intercom-attachments.com", ]; var contentSecurityPolicyDirectives = [ @@ -74,13 +63,9 @@ var contentSecurityPolicyDirectives = [ "https://*.link.com", "https://js.intercomcdn.com", "https://static.intercomassets.com", - "https://static.intercomassets.eu", - "https://static.au.intercomassets.com", "https://gifs.intercomcdn.com", "https://video-messages.intercomcdn.com", "https://messenger-apps.intercom.io", - "https://messenger-apps.eu.intercom.io", - "https://messenger-apps.au.intercom.io", ] .concat(intercomDownloadSources) .concat(intercomUploadSources) @@ -110,11 +95,7 @@ var contentSecurityPolicyDirectives = [ "https://*.link.com", "https://via.intercom.io", "https://api.intercom.io", - "https://api.au.intercom.io", - "https://api.eu.intercom.io", "https://api-iam.intercom.io", - "https://api-iam.eu.intercom.io", - "https://api-iam.au.intercom.io", "https://api-ping.intercom.io", "https://*.intercom-messenger.com", "wss://*.intercom-messenger.com", @@ -122,10 +103,6 @@ var contentSecurityPolicyDirectives = [ "wss://nexus-websocket-a.intercom.io", "https://nexus-websocket-b.intercom.io", "wss://nexus-websocket-b.intercom.io", - "https://nexus-europe-websocket.intercom.io", - "wss://nexus-europe-websocket.intercom.io", - "https://nexus-australia-websocket.intercom.io", - "wss://nexus-australia-websocket.intercom.io", ].concat(intercomUploadSources), ], [ @@ -141,16 +118,7 @@ var contentSecurityPolicyDirectives = [ ], ["media-src", ["'self'", "blob:", "https://js.intercomcdn.com"].concat(intercomDownloadSources)], ["worker-src", ["'self'", "blob:"].concat(intercomChildSources)], - [ - "form-action", - [ - "'self'", - "https://intercom.help", - "https://api-iam.intercom.io", - "https://api-iam.eu.intercom.io", - "https://api-iam.au.intercom.io", - ], - ], + ["form-action", ["'self'", "https://intercom.help", "https://api-iam.intercom.io"]], ["manifest-src", ["'self'"]], ["base-uri", ["'none'"]], ["object-src", ["'none'"]], diff --git a/src/Exceptionless.Web/ClientApp.angular/grunt/csp-middleware.test.js b/src/Exceptionless.Web/ClientApp.angular/grunt/csp-middleware.test.js index 7fa64cffb2..2a8a1e998b 100644 --- a/src/Exceptionless.Web/ClientApp.angular/grunt/csp-middleware.test.js +++ b/src/Exceptionless.Web/ClientApp.angular/grunt/csp-middleware.test.js @@ -142,6 +142,11 @@ test("serves HTML with a strict script policy and no cache", async function (con assert.match(connectDirective, /(?:^| )ws:(?: |$)/); assert.match(connectDirective, /(?:^| )wss:(?: |$)/); assert.doesNotMatch(policy, /(?:^|[ ;])http:/); + assert.doesNotMatch(policy, /intercomcdn\.eu|\.eu\.intercom\.io|\.au\.intercom\.io|au\.intercomcdn\.com/); + assert.doesNotMatch( + policy, + /static\.au\.intercomassets\.com|intercom-attachments\.eu|au\.intercom-attachments\.com/ + ); assert.equal(getDirective(policy, "base-uri"), "base-uri 'none'"); assert.equal(getDirective(policy, "object-src"), "object-src 'none'"); assert.equal(getDirective(policy, "frame-ancestors"), "frame-ancestors 'none'"); diff --git a/src/Exceptionless.Web/ClientApp/src/lib/server/content-security-policy.test.ts b/src/Exceptionless.Web/ClientApp/src/lib/server/content-security-policy.test.ts index 8038af8520..ab7eda0852 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/server/content-security-policy.test.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/server/content-security-policy.test.ts @@ -56,6 +56,13 @@ describe('createContentSecurityPolicy', () => { expect(connectDirective).not.toContain('wss:'); expect(getDirective(policy, 'img-src')).not.toContain('http://www.gravatar.com'); + expect(policy).not.toContain('intercomcdn.eu'); + expect(policy).not.toContain('.eu.intercom.io'); + expect(policy).not.toContain('.au.intercom.io'); + expect(policy).not.toContain('au.intercomcdn.com'); + expect(policy).not.toContain('static.au.intercomassets.com'); + expect(policy).not.toContain('intercom-attachments.eu'); + expect(policy).not.toContain('au.intercom-attachments.com'); expect(getDirective(policy, 'base-uri')).toEqual(["'none'"]); expect(getDirective(policy, 'object-src')).toEqual(["'none'"]); diff --git a/src/Exceptionless.Web/ClientApp/src/lib/server/content-security-policy.ts b/src/Exceptionless.Web/ClientApp/src/lib/server/content-security-policy.ts index b210ea1e88..0bc5535e99 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/server/content-security-policy.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/server/content-security-policy.ts @@ -5,6 +5,7 @@ const NONCE_PATTERN = /^[A-Za-z\d+/]{43}=$/; const NONCE_ATTRIBUTE_PATTERN = /\s+nonce(?:\s*=\s*(?:"[^"]*"|'[^']*'|[^\s>]+))?/gi; const SCRIPT_OPENING_TAG_PATTERN = /])*)>/gi; +// Exceptionless uses Intercom's US endpoints. Keep region-specific sources scoped to that workspace. const intercomChildSources = [ 'https://intercom-sheets.com', 'https://www.intercom-reporting.com', @@ -13,15 +14,9 @@ const intercomChildSources = [ 'https://fast.wistia.net' ] as const; -const intercomDownloadSources = ['https://downloads.intercomcdn.com', 'https://downloads.intercomcdn.eu', 'https://downloads.au.intercomcdn.com'] as const; +const intercomDownloadSources = ['https://downloads.intercomcdn.com'] as const; -const intercomUploadSources = [ - 'https://uploads.intercomcdn.com', - 'https://uploads.intercomcdn.eu', - 'https://uploads.au.intercomcdn.com', - 'https://uploads.eu.intercomcdn.com', - 'https://uploads.intercomusercontent.com' -] as const; +const intercomUploadSources = ['https://uploads.intercomcdn.com', 'https://uploads.intercomusercontent.com'] as const; const intercomAttachmentSources = [ 'https://*.intercom-attachments-1.com', @@ -32,9 +27,7 @@ const intercomAttachmentSources = [ 'https://*.intercom-attachments-6.com', 'https://*.intercom-attachments-7.com', 'https://*.intercom-attachments-8.com', - 'https://*.intercom-attachments-9.com', - 'https://*.intercom-attachments.eu', - 'https://*.au.intercom-attachments.com' + 'https://*.intercom-attachments-9.com' ] as const; const contentSecurityPolicyDirectives: ReadonlyArray = [ @@ -65,13 +58,9 @@ const contentSecurityPolicyDirectives: ReadonlyArray Date: Fri, 10 Jul 2026 10:44:57 -0500 Subject: [PATCH 3/4] Harden legacy object dump rendering --- .../object-dump/object-dump-directive.js | 23 +++--- .../object-dump/object-dump-directive.less | 5 ++ tests/http/events.http | 73 +++++++++++++++++++ 3 files changed, 91 insertions(+), 10 deletions(-) diff --git a/src/Exceptionless.Web/ClientApp.angular/components/object-dump/object-dump-directive.js b/src/Exceptionless.Web/ClientApp.angular/components/object-dump/object-dump-directive.js index 64eb07c488..1aa0389b8c 100644 --- a/src/Exceptionless.Web/ClientApp.angular/components/object-dump/object-dump-directive.js +++ b/src/Exceptionless.Web/ClientApp.angular/components/object-dump/object-dump-directive.js @@ -26,21 +26,24 @@ return String(value) + "\r\n"; } - function renderArray(value) { + function renderArray(value, depth) { var list = document.createElement("ul"); value.forEach(function (item) { var listItem = document.createElement("li"); - listItem.appendChild(renderValue(item)); + listItem.appendChild(renderValue(item, depth + 1)); list.appendChild(listItem); }); return list; } - function renderObject(value) { + function renderObject(value, depth) { var table = document.createElement("table"); - table.className = "table table-striped table-bordered table-fixed table-key-value b-t object-dump"; + table.className = "table table-striped table-bordered table-key-value b-t object-dump"; + if (depth === 0) { + table.classList.add("table-fixed"); + } Object.keys(value).forEach(function (key) { var row = document.createElement("tr"); @@ -48,7 +51,7 @@ var cell = document.createElement("td"); heading.textContent = key; - cell.appendChild(renderValue(value[key])); + cell.appendChild(renderValue(value[key], depth + 1)); row.appendChild(heading); row.appendChild(cell); table.appendChild(row); @@ -57,17 +60,17 @@ return table; } - function renderValue(value) { + function renderValue(value, depth) { if (isEmpty(value)) { - return document.createTextNode(""); + return document.createTextNode("(Empty)\r\n"); } if (isArray(value)) { - return renderArray(value); + return renderArray(value, depth); } if (isObject(value)) { - return renderObject(value); + return renderObject(value, depth); } return document.createTextNode(formatValue(value)); @@ -104,7 +107,7 @@ } } - var renderedContent = renderValue(content); + var renderedContent = renderValue(content, 0); if (usePreformattedText && !isEmpty(content)) { var pre = document.createElement("pre"); pre.appendChild(renderedContent); diff --git a/src/Exceptionless.Web/ClientApp.angular/components/object-dump/object-dump-directive.less b/src/Exceptionless.Web/ClientApp.angular/components/object-dump/object-dump-directive.less index a589271d47..8e98394ba3 100644 --- a/src/Exceptionless.Web/ClientApp.angular/components/object-dump/object-dump-directive.less +++ b/src/Exceptionless.Web/ClientApp.angular/components/object-dump/object-dump-directive.less @@ -6,3 +6,8 @@ list-style: none; margin-bottom: 8px; } + +.object-dump .object-dump > tbody > tr > th:first-child { + min-width: 90px; + width: 90px; +} diff --git a/tests/http/events.http b/tests/http/events.http index 271fdcaaa6..a67ebc89c8 100644 --- a/tests/http/events.http +++ b/tests/http/events.http @@ -115,6 +115,79 @@ Content-Type: application/json } } +### Post Complex Object Renderer Dogfood Event +# @name postComplexObjectEvent +POST {{apiUrl}}/events?access_token={{clientToken}} +Content-Type: application/json + +{ + "type": "log", + "source": "Complex Object Renderer Dogfood", + "message": "Reusable mixed-shape payload for legacy and Svelte object renderer parity", + "reference_id": "complex-object-dogfood-v1", + "data": { + "Complex Object Dogfood": { + "primitives": { + "null_value": null, + "true_value": true, + "false_value": false, + "zero": 0, + "positive_integer": 42, + "negative_integer": -17, + "decimal": 1234.5678, + "scientific_number": 1.2e3, + "empty_string": "", + "plain_string": "Exceptionless object rendering", + "unicode": "Snowman β˜ƒ, rocket πŸš€, Japanese δΎ‹", + "multiline": "first line\nsecond line\nthird line", + "iso_date_string": "2026-07-10T15:30:45.123Z", + "guid_string": "6f9619ff-8b86-d011-b42d-00cf4fc964ff", + "url_string": "https://example.test/path?alpha=1&beta=two", + "html_like_text": "" + }, + "empty_containers": { + "empty_object": {}, + "empty_array": [] + }, + "arrays": { + "numbers": [0, 1, -2, 3.14159, 1000000], + "strings": ["alpha", "", "line one\nline two", "not markup"], + "mixed": [null, true, false, 0, "text", { "nested": "object" }, ["nested", "array"]], + "objects": [ + { "id": 1, "name": "first", "enabled": true }, + { "id": 2, "name": "second", "enabled": false, "metadata": { "score": 9.5 } } + ] + }, + "deep_object": { + "level_1": { + "level_2": { + "level_3": { + "level_4": { + "level_5": { + "leaf": "deep value", + "nullable_leaf": null, + "leaf_array": [1, { "two": [3, 4] }] + } + } + } + } + } + }, + "unusual_keys": { + "key with spaces": "spaces", + "dotted.key": "dot", + "slash/key": "slash", + "colon:key": "colon", + "@symbol_key": "at sign" + } + } + } +} + +### Find Complex Object Renderer Dogfood Event +GET {{apiUrl}}/events/by-ref/complex-object-dogfood-v1 +Authorization: Bearer {{token}} + ### GET Submit Random Parameters GET {{apiUrl}}/events/submit?access_token={{clientToken}}&foo=bar&edit=&spam=eggs=ham&tags=blue&tags=red&message=foo From 267fb2cdd2962595590c23bb97e49d3952741524 Mon Sep 17 00:00:00 2001 From: Blake Niemyjski Date: Fri, 10 Jul 2026 11:35:25 -0500 Subject: [PATCH 4/4] fix: harden strict CSP response handling --- .../ClientApp.angular/Gruntfile.js | 24 +++- .../ClientApp.angular/grunt/csp-middleware.js | 14 +- .../grunt/csp-middleware.test.js | 6 + .../grunt/live-reload-options.js | 15 -- .../grunt/object-dump-directive.test.js | 131 ++++++++++++++++++ .../grunt/task-configs/watch.js | 6 +- .../grunt/watch-config.test.js | 29 ---- .../ClientApp.angular/grunt/watch-tasks.js | 18 --- .../ClientApp.angular/package.json | 4 +- .../server/content-security-policy.test.ts | 19 +++ .../src/lib/server/content-security-policy.ts | 9 +- .../Handlers/SpaIndexHtmlMiddleware.cs | 8 +- .../Handlers/SpaIndexHtmlMiddlewareTests.cs | 10 ++ tests/http/events.http | 73 ---------- 14 files changed, 205 insertions(+), 161 deletions(-) delete mode 100644 src/Exceptionless.Web/ClientApp.angular/grunt/live-reload-options.js create mode 100644 src/Exceptionless.Web/ClientApp.angular/grunt/object-dump-directive.test.js delete mode 100644 src/Exceptionless.Web/ClientApp.angular/grunt/watch-config.test.js delete mode 100644 src/Exceptionless.Web/ClientApp.angular/grunt/watch-tasks.js diff --git a/src/Exceptionless.Web/ClientApp.angular/Gruntfile.js b/src/Exceptionless.Web/ClientApp.angular/Gruntfile.js index 9f34c58769..1855753c41 100644 --- a/src/Exceptionless.Web/ClientApp.angular/Gruntfile.js +++ b/src/Exceptionless.Web/ClientApp.angular/Gruntfile.js @@ -27,9 +27,8 @@ var createFolderGlobs = function (fileTypePatterns) { .concat(fileTypePatterns); }; -function configureGrunt(grunt) { +module.exports = function (grunt) { var path = require("path"); - var getWatchTasks = require("./grunt/watch-tasks"); // load createFolderGlobs in options so it can be used across task configurations grunt.option("folderGlobs", createFolderGlobs); @@ -67,15 +66,26 @@ function configureGrunt(grunt) { grunt.event.on("watch", function (action, filepath) { // https://github.com/gruntjs/grunt-contrib-watch/issues/156 - var tasksToRun = getWatchTasks(filepath); + var tasksToRun = []; - if (tasksToRun.indexOf("eslint") !== -1) { + if (filepath.lastIndexOf(".html") !== -1 && filepath.lastIndexOf(".html") === filepath.length - 5) { + // validate the changed html file + grunt.config("htmlangular.main.files.src", [filepath]); + tasksToRun.push("htmlangular"); + } + + if (filepath.lastIndexOf(".js") !== -1 && filepath.lastIndexOf(".js") === filepath.length - 3) { // lint the changed js file grunt.config("eslint.main.src", filepath); + tasksToRun.push("eslint"); + } + + // if index.html changed, we need to reread the '; + + assert.equal(csp.stampScriptNonces(html, "nonce"), ''); +}); + test("serves HTML with a strict script policy and no cache", async function (context) { var server = await startServer(); context.after(function () { diff --git a/src/Exceptionless.Web/ClientApp.angular/grunt/live-reload-options.js b/src/Exceptionless.Web/ClientApp.angular/grunt/live-reload-options.js deleted file mode 100644 index 84f07e8081..0000000000 --- a/src/Exceptionless.Web/ClientApp.angular/grunt/live-reload-options.js +++ /dev/null @@ -1,15 +0,0 @@ -/* eslint-env node */ - -"use strict"; - -module.exports = function createLiveReloadOptions(port, useHttps, certificate) { - if (!useHttps) { - return port; - } - - return { - cert: certificate.cert, - key: certificate.key, - port: port, - }; -}; diff --git a/src/Exceptionless.Web/ClientApp.angular/grunt/object-dump-directive.test.js b/src/Exceptionless.Web/ClientApp.angular/grunt/object-dump-directive.test.js new file mode 100644 index 0000000000..dcf97263ca --- /dev/null +++ b/src/Exceptionless.Web/ClientApp.angular/grunt/object-dump-directive.test.js @@ -0,0 +1,131 @@ +/* eslint-env node */ + +"use strict"; + +var assert = require("node:assert/strict"); +var test = require("node:test"); + +test("renders false, null, empty, nested, and HTML-like values without executable markup", function (context) { + var directiveFactory; + var documentStub = createDocumentStub(); + global.angular = { + module: function () { + return { + directive: function (name, factory) { + assert.equal(name, "objectDump"); + directiveFactory = factory; + }, + }; + }, + }; + global.document = documentStub; + context.after(function () { + delete global.angular; + delete global.document; + }); + + var modulePath = require.resolve("../components/object-dump/object-dump-directive"); + delete require.cache[modulePath]; + require("../components/object-dump/object-dump-directive"); + + var root = documentStub.createElement("object-dump"); + var element = [root]; + element.text = function (value) { + root.textContent = String(value); + }; + var htmlLikeText = ""; + + directiveFactory().link( + { + content: { + false_value: false, + null_value: null, + empty_object: {}, + empty_array: [], + nested: { html_like_text: htmlLikeText }, + }, + }, + element + ); + + assert.match(root.textContent, /false_valueFalse\r\n/); + assert.match(root.textContent, /null_value\(Null\)\r\n/); + assert.match(root.textContent, /empty_object\(Empty\)\r\n/); + assert.match(root.textContent, /empty_array\(Empty\)\r\n/); + assert.ok(root.textContent.includes(htmlLikeText)); + assert.equal(findElements(root, "SCRIPT").length, 0); + + var tables = findElements(root, "TABLE"); + assert.equal(tables.length, 2); + assert.match(tables[0].className, /(?:^| )table-fixed(?: |$)/); + assert.doesNotMatch(tables[1].className, /(?:^| )table-fixed(?: |$)/); +}); + +function createDocumentStub() { + return { + createElement: function (tagName) { + return new ElementStub(tagName); + }, + createTextNode: function (value) { + return new TextNodeStub(value); + }, + }; +} + +function ElementStub(tagName) { + var self = this; + this.tagName = tagName.toUpperCase(); + this.childNodes = []; + this.className = ""; + this.classList = { + add: function (name) { + self.className = self.className ? self.className + " " + name : name; + }, + }; +} + +ElementStub.prototype.appendChild = function (child) { + this.childNodes.push(child); + return child; +}; + +ElementStub.prototype.removeChild = function (child) { + var index = this.childNodes.indexOf(child); + if (index !== -1) { + this.childNodes.splice(index, 1); + } + + return child; +}; + +Object.defineProperty(ElementStub.prototype, "firstChild", { + get: function () { + return this.childNodes[0] || null; + }, +}); + +Object.defineProperty(ElementStub.prototype, "textContent", { + get: function () { + return this.childNodes + .map(function (child) { + return child.textContent; + }) + .join(""); + }, + set: function (value) { + this.childNodes = [new TextNodeStub(value)]; + }, +}); + +function TextNodeStub(value) { + this.textContent = String(value); + this.childNodes = []; +} + +function findElements(node, tagName) { + var matches = node.tagName === tagName ? [node] : []; + node.childNodes.forEach(function (child) { + matches = matches.concat(findElements(child, tagName)); + }); + return matches; +} diff --git a/src/Exceptionless.Web/ClientApp.angular/grunt/task-configs/watch.js b/src/Exceptionless.Web/ClientApp.angular/grunt/task-configs/watch.js index 7749847375..eb94a482ae 100644 --- a/src/Exceptionless.Web/ClientApp.angular/grunt/task-configs/watch.js +++ b/src/Exceptionless.Web/ClientApp.angular/grunt/task-configs/watch.js @@ -1,15 +1,17 @@ var devCertificate = require("../dev-certificate"); -var createLiveReloadOptions = require("../live-reload-options"); function createWatchConfig(grunt) { var liveReloadPort = Number(process.env.LIVERELOAD_PORT) || 35729; var useHttps = String(process.env.USE_HTTPS || "").toLowerCase() === "true"; var certificate = useHttps ? devCertificate.getDevCertificate() : {}; + var liveReloadOptions = useHttps + ? { cert: certificate.cert, key: certificate.key, port: liveReloadPort } + : liveReloadPort; return { main: { options: { - livereload: createLiveReloadOptions(liveReloadPort, useHttps, certificate), + livereload: liveReloadOptions, livereloadOnError: false, spawn: false, }, diff --git a/src/Exceptionless.Web/ClientApp.angular/grunt/watch-config.test.js b/src/Exceptionless.Web/ClientApp.angular/grunt/watch-config.test.js deleted file mode 100644 index f041709122..0000000000 --- a/src/Exceptionless.Web/ClientApp.angular/grunt/watch-config.test.js +++ /dev/null @@ -1,29 +0,0 @@ -/* eslint-env node */ - -"use strict"; - -var assert = require("node:assert/strict"); -var test = require("node:test"); -var createLiveReloadOptions = require("./live-reload-options"); -var getWatchTasks = require("./watch-tasks"); - -test("uses the numeric LiveReload port for HTTP development", function () { - assert.equal(createLiveReloadOptions(35729, false, {}), 35729); -}); - -test("uses the development certificate for HTTPS LiveReload", function () { - assert.deepEqual(createLiveReloadOptions(35729, true, { cert: "certificate", key: "private-key" }), { - cert: "certificate", - key: "private-key", - port: 35729, - }); -}); - -test("reloads HTML without invoking the obsolete remote validator", function () { - assert.deepEqual(getWatchTasks("app/app.tpl.html"), []); - assert.deepEqual(getWatchTasks("index.html"), ["dom_munger:read"]); -}); - -test("still lints changed JavaScript", function () { - assert.deepEqual(getWatchTasks("components/example.js"), ["eslint"]); -}); diff --git a/src/Exceptionless.Web/ClientApp.angular/grunt/watch-tasks.js b/src/Exceptionless.Web/ClientApp.angular/grunt/watch-tasks.js deleted file mode 100644 index d48fb5ab8a..0000000000 --- a/src/Exceptionless.Web/ClientApp.angular/grunt/watch-tasks.js +++ /dev/null @@ -1,18 +0,0 @@ -/* eslint-env node */ - -"use strict"; - -module.exports = function getWatchTasks(filepath) { - var tasks = []; - - // HTML reloads directly; the legacy remote validator is excluded from builds and fails on current Node versions. - if (filepath.endsWith(".js")) { - tasks.push("eslint"); - } - - if (filepath === "index.html") { - tasks.push("dom_munger:read"); - } - - return tasks; -}; diff --git a/src/Exceptionless.Web/ClientApp.angular/package.json b/src/Exceptionless.Web/ClientApp.angular/package.json index 5d5c1386c2..c067df7a49 100644 --- a/src/Exceptionless.Web/ClientApp.angular/package.json +++ b/src/Exceptionless.Web/ClientApp.angular/package.json @@ -4,8 +4,8 @@ "scripts": { "build": "npx grunt build", "serve": "npx grunt serve", - "prod-serve": "cross-env API_HTTPS=https://api.exceptionless.io npx grunt serve", - "test": "node --test grunt/csp-middleware.test.js grunt/watch-config.test.js", + "prod-serve": "cross-env ASPNETCORE_URLS=https://api.exceptionless.io npx grunt serve", + "test": "node --test grunt/csp-middleware.test.js grunt/object-dump-directive.test.js", "lint": "eslint . --ext .js", "lint:fix": "eslint . --ext .js --fix", "prettier": "prettier . --write --ignore-unknown", diff --git a/src/Exceptionless.Web/ClientApp/src/lib/server/content-security-policy.test.ts b/src/Exceptionless.Web/ClientApp/src/lib/server/content-security-policy.test.ts index ab7eda0852..7669af1deb 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/server/content-security-policy.test.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/server/content-security-policy.test.ts @@ -30,6 +30,13 @@ describe('addNonceToScripts', () => { `` ); }); + + it('preserves script-like text inside inline scripts', () => { + const nonce = createNonce(); + const html = ''; + + expect(addNonceToScripts(html, nonce)).toBe(``); + }); }); describe('createContentSecurityPolicy', () => { @@ -119,6 +126,18 @@ describe('secureHtmlResponse', () => { expect(response.headers.has('content-security-policy')).toBe(false); expect(response.headers.has('cache-control')).toBe(false); }); + + it.each([204, 205, 304])('leaves bodyless HTML responses untouched for status %i', async (status) => { + const originalResponse = new Response(null, { + headers: { 'content-type': 'text/html; charset=utf-8' }, + status + }); + + const response = await secureHtmlResponse(originalResponse, { allowDevelopmentConnections: true }); + + expect(response).toBe(originalResponse); + expect(response.headers.has('content-security-policy')).toBe(false); + }); }); function getDirective(policy: string, name: string): string[] { diff --git a/src/Exceptionless.Web/ClientApp/src/lib/server/content-security-policy.ts b/src/Exceptionless.Web/ClientApp/src/lib/server/content-security-policy.ts index 0bc5535e99..3e0d782846 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/server/content-security-policy.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/server/content-security-policy.ts @@ -3,7 +3,7 @@ import { randomBytes } from 'node:crypto'; const NONCE_BYTE_LENGTH = 32; const NONCE_PATTERN = /^[A-Za-z\d+/]{43}=$/; const NONCE_ATTRIBUTE_PATTERN = /\s+nonce(?:\s*=\s*(?:"[^"]*"|'[^']*'|[^\s>]+))?/gi; -const SCRIPT_OPENING_TAG_PATTERN = /])*)>/gi; +const SCRIPT_ELEMENT_PATTERN = /(])*)>([\s\S]*?)(<\/script\s*>)/gi; // Exceptionless uses Intercom's US endpoints. Keep region-specific sources scoped to that workspace. const intercomChildSources = [ @@ -121,11 +121,10 @@ interface ContentSecurityPolicyOptions { export function addNonceToScripts(html: string, nonce: string): string { validateNonce(nonce); - return html.replace(SCRIPT_OPENING_TAG_PATTERN, (openingTag, attributes: string) => { + return html.replace(SCRIPT_ELEMENT_PATTERN, (_scriptElement, scriptTagName: string, attributes: string, content: string, closingTag: string) => { const attributesWithoutNonce = attributes.replace(NONCE_ATTRIBUTE_PATTERN, ''); - const scriptTagName = openingTag.slice(0, '`; + return `${scriptTagName} nonce="${nonce}"${attributesWithoutNonce}>${content}${closingTag}`; }); } @@ -151,7 +150,7 @@ export function createNonce(): string { } export async function secureHtmlResponse(response: Response, options: ContentSecurityPolicyOptions = {}): Promise { - if (!response.headers.get('content-type')?.startsWith('text/html')) { + if (!response.headers.get('content-type')?.startsWith('text/html') || response.body === null) { return response; } diff --git a/src/Exceptionless.Web/Utility/Handlers/SpaIndexHtmlMiddleware.cs b/src/Exceptionless.Web/Utility/Handlers/SpaIndexHtmlMiddleware.cs index d8a6db567d..45154ca118 100644 --- a/src/Exceptionless.Web/Utility/Handlers/SpaIndexHtmlMiddleware.cs +++ b/src/Exceptionless.Web/Utility/Handlers/SpaIndexHtmlMiddleware.cs @@ -76,15 +76,15 @@ public async Task InvokeAsync(HttpContext context, ICspNonceService nonceService internal static string AddScriptNonce(string html, string nonce) { - return ScriptTagRegex().Replace(html, match => + return ScriptElementRegex().Replace(html, match => { string attributes = NonceAttributeRegex().Replace(match.Groups["attributes"].Value, String.Empty); - return $"", result); } + [Fact] + public void AddScriptNonce_InlineScriptContainsScriptLikeText_PreservesContent() + { + const string html = ""; + + string result = SpaIndexHtmlMiddleware.AddScriptNonce(html, "new"); + + Assert.Equal("", result); + } + [Fact] public void AddScriptNonce_SimilarlyNamedAttributes_PreservesAttributes() { diff --git a/tests/http/events.http b/tests/http/events.http index a67ebc89c8..271fdcaaa6 100644 --- a/tests/http/events.http +++ b/tests/http/events.http @@ -115,79 +115,6 @@ Content-Type: application/json } } -### Post Complex Object Renderer Dogfood Event -# @name postComplexObjectEvent -POST {{apiUrl}}/events?access_token={{clientToken}} -Content-Type: application/json - -{ - "type": "log", - "source": "Complex Object Renderer Dogfood", - "message": "Reusable mixed-shape payload for legacy and Svelte object renderer parity", - "reference_id": "complex-object-dogfood-v1", - "data": { - "Complex Object Dogfood": { - "primitives": { - "null_value": null, - "true_value": true, - "false_value": false, - "zero": 0, - "positive_integer": 42, - "negative_integer": -17, - "decimal": 1234.5678, - "scientific_number": 1.2e3, - "empty_string": "", - "plain_string": "Exceptionless object rendering", - "unicode": "Snowman β˜ƒ, rocket πŸš€, Japanese δΎ‹", - "multiline": "first line\nsecond line\nthird line", - "iso_date_string": "2026-07-10T15:30:45.123Z", - "guid_string": "6f9619ff-8b86-d011-b42d-00cf4fc964ff", - "url_string": "https://example.test/path?alpha=1&beta=two", - "html_like_text": "" - }, - "empty_containers": { - "empty_object": {}, - "empty_array": [] - }, - "arrays": { - "numbers": [0, 1, -2, 3.14159, 1000000], - "strings": ["alpha", "", "line one\nline two", "not markup"], - "mixed": [null, true, false, 0, "text", { "nested": "object" }, ["nested", "array"]], - "objects": [ - { "id": 1, "name": "first", "enabled": true }, - { "id": 2, "name": "second", "enabled": false, "metadata": { "score": 9.5 } } - ] - }, - "deep_object": { - "level_1": { - "level_2": { - "level_3": { - "level_4": { - "level_5": { - "leaf": "deep value", - "nullable_leaf": null, - "leaf_array": [1, { "two": [3, 4] }] - } - } - } - } - } - }, - "unusual_keys": { - "key with spaces": "spaces", - "dotted.key": "dot", - "slash/key": "slash", - "colon:key": "colon", - "@symbol_key": "at sign" - } - } - } -} - -### Find Complex Object Renderer Dogfood Event -GET {{apiUrl}}/events/by-ref/complex-object-dogfood-v1 -Authorization: Bearer {{token}} - ### GET Submit Random Parameters GET {{apiUrl}}/events/submit?access_token={{clientToken}}&foo=bar&edit=&spam=eggs=ham&tags=blue&tags=red&message=foo