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/app/app.tpl.html b/src/Exceptionless.Web/ClientApp.angular/app/app.tpl.html index b0679ce735..4faf9810d2 100644 --- a/src/Exceptionless.Web/ClientApp.angular/app/app.tpl.html +++ b/src/Exceptionless.Web/ClientApp.angular/app/app.tpl.html @@ -25,9 +25,7 @@ {{appVm.apiVersionNumber}}  - + © 2025 Exceptionless diff --git a/src/Exceptionless.Web/ClientApp.angular/components/object-dump/handlebars-service.js b/src/Exceptionless.Web/ClientApp.angular/components/object-dump/handlebars-service.js deleted file mode 100644 index ead4de3909..0000000000 --- a/src/Exceptionless.Web/ClientApp.angular/components/object-dump/handlebars-service.js +++ /dev/null @@ -1,178 +0,0 @@ -/* global Handlebars:false */ - -(function () { - "use strict"; - - angular.module("exceptionless.object-dump").factory("handlebarsService", function () { - var _defaultsRegistered = false; - var _templates = { - __default: "{{> valueDump}}", - pre: "{{#ifHasData this}}
{{> valueDump}}
{{/ifHasData}}", - }; - var _compiledTemplates = {}; - - function getTemplate(templateKey) { - if (!_defaultsRegistered) { - registerDefaults(); - _defaultsRegistered = true; - } - - // if we cant find a template with the given key, use the default template. - if (!templateKey || !_templates.hasOwnProperty(templateKey)) templateKey = "__default"; - - // check to see if we already have a compiled version of this template - if (_compiledTemplates.hasOwnProperty(templateKey)) return _compiledTemplates[templateKey]; - - var source = _templates[templateKey]; - var template = Handlebars.compile(source); - - // add the compiled template to the cache - _compiledTemplates[templateKey] = template; - return _compiledTemplates[templateKey]; - } - - function isEmpty(value) { - if (value === null) { - return true; - } - - if (typeof value === "object" || value instanceof Object) { - if (Object.keys(value).length > 0) { - return false; - } - - return true; - } - - if (Object.prototype.toString.call(value) === "[object Array]" || value instanceof Array) { - if (value.length > 0) { - return false; - } - - return true; - } - - return false; - } - - function reflect(value) { - return { - value: value, - type: typeof value, - isString: typeof value === "string" || value instanceof String, - isNumber: typeof value === "number" || value instanceof Number, - isBoolean: typeof value === "boolean" || value instanceof Boolean, - isArray: Object.prototype.toString.call(value) === "[object Array]" || value instanceof Array, - isObject: (typeof value === "object" || value instanceof Object) && value !== null, - isNull: value === null, - isEmptyValue: isEmpty(value), - isSimpleType: - !(Object.prototype.toString.call(value) === "[object Array]" || value instanceof Array) && - !(typeof value === "object" || value instanceof Object), - }; - } - - function registerDefaults() { - registerPartials(); - registerHelpers(); - } - - function registerHelpers() { - Handlebars.registerHelper("reflect", function (context, options) { - return options.fn(reflect(context)); - }); - - Handlebars.registerHelper("properties", function (context, options) { - var fn = options.fn; - var ret = ""; - var data; - if (options.data) data = Handlebars.createFrame(options.data); - if (context) { - Object.keys(context || {}).forEach(function (key) { - var info = reflect(context[key]); - info.name = key; - ret += fn(info, { data: data }); - }); - } - return ret; - }); - - Handlebars.registerHelper("ifHasData", function (context, options) { - if (isEmpty(context)) return options.inverse(this); - return options.fn(this); - }); - } - - function registerPartials() { - Handlebars.registerPartial( - "objectDump", - "{{#ifHasData this}}" + - '\r\n' + - "{{#properties this}}" + - " \r\n" + - " \r\n" + - " \r\n" + - " \r\n" + - "{{/properties}}" + - "
{{name}}\r\n" + - " {{#with value}}{{> valueDump}}{{/with}}" + - "
\r\n" + - "{{/ifHasData}}" - ); - - Handlebars.registerPartial( - "arrayDump", - "{{#ifHasData this}}" + - "\r\n" + - "{{/ifHasData}}" - ); - - Handlebars.registerPartial( - "valueDump", - "{{#reflect this}}" + - "{{#if isArray}}" + - "{{#with value}}{{> arrayDump}}{{/with}}" + - "{{else}}" + - "{{#if isObject}}" + - " {{#with value}}{{> objectDump}}{{/with}}" + - "{{else}}" + - "{{#if isBoolean}}" + - "{{#if value}}" + - "True\r\n" + - "{{else}}" + - "False\r\n" + - "{{/if}}" + - "{{else}}" + - "{{#if isNull}}" + - "(Null)\r\n" + - "{{else}}" + - "{{value}}\r\n" + - "{{/if}}" + - "{{/if}}" + - "{{/if}}" + - "{{/if}}" + - "{{/reflect}}" - ); - } - - function registerTemplate(key, template) { - _templates[key] = template; - } - - var service = { - getTemplate: getTemplate, - isEmpty: isEmpty, - reflect: reflect, - registerDefaults: registerDefaults, - registerHelpers: registerHelpers, - registerPartials: registerPartials, - registerTemplate: registerTemplate, - }; - - return service; - }); -})(); 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 8370699499..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 @@ -1,9 +1,89 @@ (function () { "use strict"; - // NOTE: We had a ton of existing handlebars code that would of been time consuming to convert to angular. - // We will convert this when porting to angular 2.0. - angular.module("exceptionless.object-dump").directive("objectDump", function (handlebarsService) { + angular.module("exceptionless.object-dump").directive("objectDump", function () { + function isArray(value) { + return Object.prototype.toString.call(value) === "[object Array]" || value instanceof Array; + } + + function isObject(value) { + return (typeof value === "object" || value instanceof Object) && value !== null && !isArray(value); + } + + function isEmpty(value) { + return (isArray(value) || isObject(value)) && Object.keys(value).length === 0; + } + + function formatValue(value) { + if (typeof value === "boolean" || value instanceof Boolean) { + return value ? "True\r\n" : "False\r\n"; + } + + if (value === null) { + return "(Null)\r\n"; + } + + return String(value) + "\r\n"; + } + + function renderArray(value, depth) { + var list = document.createElement("ul"); + + value.forEach(function (item) { + var listItem = document.createElement("li"); + listItem.appendChild(renderValue(item, depth + 1)); + list.appendChild(listItem); + }); + + return list; + } + + function renderObject(value, depth) { + var table = document.createElement("table"); + 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"); + var heading = document.createElement("th"); + var cell = document.createElement("td"); + + heading.textContent = key; + cell.appendChild(renderValue(value[key], depth + 1)); + row.appendChild(heading); + row.appendChild(cell); + table.appendChild(row); + }); + + return table; + } + + function renderValue(value, depth) { + if (isEmpty(value)) { + return document.createTextNode("(Empty)\r\n"); + } + + if (isArray(value)) { + return renderArray(value, depth); + } + + if (isObject(value)) { + return renderObject(value, depth); + } + + return document.createTextNode(formatValue(value)); + } + + function replaceContent(element, content) { + while (element.firstChild) { + element.removeChild(element.firstChild); + } + + element.appendChild(content); + } + return { restrict: "E", scope: { @@ -17,17 +97,24 @@ try { var content = scope.content; - var template = handlebarsService.getTemplate(scope.templateKey); + var usePreformattedText = scope.templateKey === "pre"; if (typeof content === "string" || content instanceof String) { try { content = JSON.parse(scope.content); } catch (ex) { - template = handlebarsService.getTemplate("pre"); + usePreformattedText = true; } } - element.html(template(content)); + var renderedContent = renderValue(content, 0); + if (usePreformattedText && !isEmpty(content)) { + var pre = document.createElement("pre"); + pre.appendChild(renderedContent); + renderedContent = pre; + } + + replaceContent(element[0], renderedContent); } catch (ex) { element.text(scope.content); } 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/src/Exceptionless.Web/ClientApp.angular/components/ui-scroll/ui-scroll-directive.js b/src/Exceptionless.Web/ClientApp.angular/components/ui-scroll/ui-scroll-directive.js index ea59c60bb5..40aff8af60 100644 --- a/src/Exceptionless.Web/ClientApp.angular/components/ui-scroll/ui-scroll-directive.js +++ b/src/Exceptionless.Web/ClientApp.angular/components/ui-scroll/ui-scroll-directive.js @@ -6,6 +6,7 @@ restrict: "AC", link: function (scope, el, attr) { el.on("click", function (e) { + e.preventDefault(); $location.hash(attr.uiScroll); $anchorScroll(); }); diff --git a/src/Exceptionless.Web/ClientApp.angular/grunt/csp-middleware.js b/src/Exceptionless.Web/ClientApp.angular/grunt/csp-middleware.js new file mode 100644 index 0000000000..a109c72dab --- /dev/null +++ b/src/Exceptionless.Web/ClientApp.angular/grunt/csp-middleware.js @@ -0,0 +1,308 @@ +/* eslint-env node */ + +"use strict"; + +var crypto = require("crypto"); + +var CSP_HEADER = "Content-Security-Policy"; +var HTML_CACHE_CONTROL = "no-store"; +var NONCE_BYTE_LENGTH = 32; +var SCRIPT_NONCE_PATTERN = /\s+nonce(?:\s*=\s*(?:"[^"]*"|'[^']*'|[^\s>]+))?/gi; +var SCRIPT_ELEMENT_PATTERN = /(])*)>([\s\S]*?)(<\/script\s*>)/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", + "https://www.youtube.com", + "https://player.vimeo.com", + "https://fast.wistia.net", +]; + +var intercomDownloadSources = ["https://downloads.intercomcdn.com"]; + +var intercomUploadSources = ["https://uploads.intercomcdn.com", "https://uploads.intercomusercontent.com"]; + +var intercomAttachmentSources = [ + "https://*.intercom-attachments-1.com", + "https://*.intercom-attachments-2.com", + "https://*.intercom-attachments-3.com", + "https://*.intercom-attachments-4.com", + "https://*.intercom-attachments-5.com", + "https://*.intercom-attachments-6.com", + "https://*.intercom-attachments-7.com", + "https://*.intercom-attachments-8.com", + "https://*.intercom-attachments-9.com", +]; + +var contentSecurityPolicyDirectives = [ + ["default-src", ["'self'"]], + [ + "script-src", + [ + "'strict-dynamic'", + "'self'", + "https://js.stripe.com", + "https://*.js.stripe.com", + "https://maps.googleapis.com", + "https://app.intercom.io", + "https://widget.intercom.io", + "https://js.intercomcdn.com", + "https://cdn.jsdelivr.net", + ], + ], + ["script-src-attr", ["'none'"]], + ["style-src", ["'self'", "'unsafe-inline'", "https://fonts.googleapis.com", "https://cdn.jsdelivr.net"]], + [ + "img-src", + [ + "'self'", + "blob:", + "data:", + "https://*.stripe.com", + "https://*.link.com", + "https://js.intercomcdn.com", + "https://static.intercomassets.com", + "https://gifs.intercomcdn.com", + "https://video-messages.intercomcdn.com", + "https://messenger-apps.intercom.io", + ] + .concat(intercomDownloadSources) + .concat(intercomUploadSources) + .concat(intercomAttachmentSources) + .concat(["https://user-images.githubusercontent.com", "https://www.gravatar.com"]), + ], + [ + "font-src", + [ + "'self'", + "https://fonts.gstatic.com", + "https://js.intercomcdn.com", + "https://fonts.intercomcdn.com", + "https://cdn.jsdelivr.net", + ], + ], + [ + "connect-src", + [ + "'self'", + "https://collector.exceptionless.io", + "https://config.exceptionless.io", + "https://heartbeat.exceptionless.io", + "https://api.stripe.com", + "https://maps.googleapis.com", + "https://link.com", + "https://*.link.com", + "https://via.intercom.io", + "https://api.intercom.io", + "https://api-iam.intercom.io", + "https://api-ping.intercom.io", + "https://*.intercom-messenger.com", + "wss://*.intercom-messenger.com", + "https://nexus-websocket-a.intercom.io", + "wss://nexus-websocket-a.intercom.io", + "https://nexus-websocket-b.intercom.io", + "wss://nexus-websocket-b.intercom.io", + ].concat(intercomUploadSources), + ], + [ + "frame-src", + [ + "'self'", + "https://js.stripe.com", + "https://*.js.stripe.com", + "https://hooks.stripe.com", + "https://link.com", + "https://*.link.com", + ].concat(intercomChildSources), + ], + ["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"]], + ["manifest-src", ["'self'"]], + ["base-uri", ["'none'"]], + ["object-src", ["'none'"]], + ["frame-ancestors", ["'none'"]], +]; + +function createNonce() { + return crypto.randomBytes(NONCE_BYTE_LENGTH).toString("base64"); +} + +function createContentSecurityPolicy(nonce) { + return contentSecurityPolicyDirectives + .map(function (directive) { + var name = directive[0]; + var sources = directive[1].slice(); + + if (name === "script-src") { + sources.unshift("'nonce-" + nonce + "'"); + } else if (name === "connect-src") { + sources.push("ws:", "wss:"); + } + + return name + " " + sources.join(" "); + }) + .join("; "); +} + +function stampScriptNonces(html, nonce) { + return html.replace( + SCRIPT_ELEMENT_PATTERN, + function (scriptElement, scriptTagName, attributes, content, closingTag) { + var attributesWithoutNonce = attributes.replace(SCRIPT_NONCE_PATTERN, ""); + + return scriptTagName + ' nonce="' + nonce + '"' + attributesWithoutNonce + ">" + content + closingTag; + } + ); +} + +function isHtmlRequest(request) { + if (request.method !== "GET" && request.method !== "HEAD") { + return false; + } + + var headers = request.headers || {}; + var accept = headers.accept || ""; + var pathname = (request.url || "").split("?", 1)[0]; + var lastSegment = pathname.substring(pathname.lastIndexOf("/") + 1); + + return accept.indexOf("text/html") !== -1 || pathname === "/index.html" || lastSegment.indexOf(".") === -1; +} + +function removeConditionalRequestHeaders(request) { + if (!request.headers) { + return; + } + + delete request.headers["if-modified-since"]; + delete request.headers["if-none-match"]; +} + +function removeHeaderFromCollection(headers, name) { + if (!headers || typeof headers !== "object") { + return; + } + + Object.keys(headers).forEach(function (headerName) { + if (headerName.toLowerCase() === name) { + delete headers[headerName]; + } + }); +} + +function prepareHtmlHeaders(response, policy) { + if (response.headersSent) { + return; + } + + response.setHeader(CSP_HEADER, policy); + response.setHeader("Cache-Control", HTML_CACHE_CONTROL); + response.removeHeader("Content-Length"); + response.removeHeader("ETag"); + response.removeHeader("Expires"); + response.removeHeader("Last-Modified"); +} + +function toBuffer(chunk, encoding) { + if (Buffer.isBuffer(chunk)) { + return chunk; + } + + return Buffer.from(chunk, typeof encoding === "string" ? encoding : undefined); +} + +function createCspMiddleware() { + return function cspMiddleware(request, response, next) { + if (!isHtmlRequest(request)) { + next(); + return; + } + + var nonce = createNonce(); + var policy = createContentSecurityPolicy(nonce); + var chunks = []; + var originalEnd = response.end; + var originalWrite = response.write; + var originalWriteHead = response.writeHead; + + removeConditionalRequestHeaders(request); + prepareHtmlHeaders(response, policy); + + response.writeHead = function writeHead(statusCode, statusMessage, headers) { + var args = [statusCode]; + var responseHeaders = headers; + + if (typeof statusMessage === "string") { + args.push(statusMessage); + if (headers) { + args.push(headers); + } + } else if (statusMessage) { + responseHeaders = statusMessage; + args.push(statusMessage); + } + + removeHeaderFromCollection(responseHeaders, "content-length"); + removeHeaderFromCollection(responseHeaders, "etag"); + removeHeaderFromCollection(responseHeaders, "expires"); + removeHeaderFromCollection(responseHeaders, "last-modified"); + prepareHtmlHeaders(response, policy); + + return originalWriteHead.apply(response, args); + }; + + response.write = function write(chunk, encoding, callback) { + var completed = typeof encoding === "function" ? encoding : callback; + chunks.push(toBuffer(chunk, encoding)); + + if (completed) { + process.nextTick(completed); + } + + return true; + }; + + response.end = function end(chunk, encoding, callback) { + var completed = callback; + if (typeof chunk === "function") { + completed = chunk; + chunk = undefined; + encoding = undefined; + } else if (typeof encoding === "function") { + completed = encoding; + encoding = undefined; + } + + if (chunk !== undefined && chunk !== null) { + chunks.push(toBuffer(chunk, encoding)); + } + + var html = Buffer.concat(chunks).toString("utf8"); + var body = Buffer.from(stampScriptNonces(html, nonce), "utf8"); + + prepareHtmlHeaders(response, policy); + if (!response.headersSent) { + response.setHeader("Content-Length", body.length); + } + + response.write = originalWrite; + response.writeHead = originalWriteHead; + response.end = originalEnd; + + return originalEnd.call(response, body, completed); + }; + + next(); + }; +} + +module.exports = { + CSP_HEADER: CSP_HEADER, + HTML_CACHE_CONTROL: HTML_CACHE_CONTROL, + NONCE_BYTE_LENGTH: NONCE_BYTE_LENGTH, + createContentSecurityPolicy: createContentSecurityPolicy, + createCspMiddleware: createCspMiddleware, + createNonce: createNonce, + stampScriptNonces: stampScriptNonces, +}; diff --git a/src/Exceptionless.Web/ClientApp.angular/grunt/csp-middleware.test.js b/src/Exceptionless.Web/ClientApp.angular/grunt/csp-middleware.test.js new file mode 100644 index 0000000000..aabe373028 --- /dev/null +++ b/src/Exceptionless.Web/ClientApp.angular/grunt/csp-middleware.test.js @@ -0,0 +1,173 @@ +/* eslint-env node */ + +"use strict"; + +var assert = require("node:assert/strict"); +var http = require("node:http"); +var test = require("node:test"); +var livereload = require("connect-livereload"); +var csp = require("./csp-middleware"); + +function getScriptDirective(policy) { + return getDirective(policy, "script-src"); +} + +function getDirective(policy, name) { + return policy.split("; ").find(function (directive) { + return directive.startsWith(name + " "); + }); +} + +function getNonce(policy) { + var match = getScriptDirective(policy).match(/'nonce-([^']+)'/); + return match && match[1]; +} + +function getScriptTags(html) { + return html.match(/]*>/gi) || []; +} + +function startServer() { + var cspMiddleware = csp.createCspMiddleware(); + var livereloadMiddleware = livereload({ disableCompression: true, port: 35729 }); + var sourceHtml = [ + "", + "", + '', + '', + '', + "", + "", + ].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("preserves script-like text inside inline scripts", function () { + var html = ''; + + 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 () { + 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.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'"); +}); + +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/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/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..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,10 +1,17 @@ -module.exports = function (grunt) { +var devCertificate = require("../dev-certificate"); + +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: liveReloadPort, + livereload: liveReloadOptions, livereloadOnError: false, spawn: false, }, @@ -12,4 +19,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/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..c067df7a49 100644 --- a/src/Exceptionless.Web/ClientApp.angular/package.json +++ b/src/Exceptionless.Web/ClientApp.angular/package.json @@ -5,6 +5,7 @@ "build": "npx grunt build", "serve": "npx grunt serve", "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", @@ -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..7669af1deb --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/lib/server/content-security-policy.test.ts @@ -0,0 +1,151 @@ +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( + `` + ); + }); + + it('preserves script-like text inside inline scripts', () => { + 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(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'"]); + 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_InlineScriptContainsScriptLikeText_PreservesContent() + { + 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); + Assert.DoesNotContain("intercomcdn.eu", policy, StringComparison.Ordinal); + Assert.DoesNotContain(".eu.intercom.io", policy, StringComparison.Ordinal); + Assert.DoesNotContain(".au.intercom.io", policy, StringComparison.Ordinal); + Assert.DoesNotContain("au.intercomcdn.com", policy, StringComparison.Ordinal); + Assert.DoesNotContain("static.au.intercomassets.com", policy, StringComparison.Ordinal); + Assert.DoesNotContain("intercom-attachments.eu", policy, StringComparison.Ordinal); + Assert.DoesNotContain("au.intercom-attachments.com", 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); + } +}