diff --git a/application/account-management/WebApp/public/index.html b/application/account-management/WebApp/public/index.html
index 295028b5e8..5dbac09093 100644
--- a/application/account-management/WebApp/public/index.html
+++ b/application/account-management/WebApp/public/index.html
@@ -1,5 +1,5 @@
-
+
@@ -9,7 +9,13 @@
+
PlatformPlatform
+
diff --git a/application/account-management/WebApp/rsbuild.config.ts b/application/account-management/WebApp/rsbuild.config.ts
index a5f275e6de..8f27782645 100644
--- a/application/account-management/WebApp/rsbuild.config.ts
+++ b/application/account-management/WebApp/rsbuild.config.ts
@@ -11,6 +11,9 @@ import { pluginTypeCheck } from "@rsbuild/plugin-type-check";
const customBuildEnv: CustomBuildEnv = {};
export default defineConfig({
+ security: {
+ nonce: "{{cspNonce}}"
+ },
tools: {
rspack: {
// Exclude tests/e2e directory from file watching to prevent hot reloading issues
diff --git a/application/account-management/WebApp/tests/e2e/csp-nonce-flows.spec.ts b/application/account-management/WebApp/tests/e2e/csp-nonce-flows.spec.ts
new file mode 100644
index 0000000000..a0d7ab37e5
--- /dev/null
+++ b/application/account-management/WebApp/tests/e2e/csp-nonce-flows.spec.ts
@@ -0,0 +1,66 @@
+import { expect } from "@playwright/test";
+import { test } from "@shared/e2e/fixtures/page-auth";
+import { createTestContext } from "@shared/e2e/utils/test-assertions";
+import { step } from "@shared/e2e/utils/test-step-wrapper";
+
+test.describe("@smoke", () => {
+ test("should block inline scripts and styles injected without valid nonce", async ({ page }) => {
+ createTestContext(page);
+
+ await step("Navigate to landing page & verify CSP nonce configuration")(async () => {
+ const response = await page.goto("/");
+
+ await expect(page).toHaveURL("/");
+
+ // Verify meta tag exists
+ const nonceMetaExists = await page.locator('meta[name="csp-nonce"]').count();
+ expect(nonceMetaExists).toBe(1);
+
+ // Verify CSP headers require nonce for scripts and styles
+ const cspHeader = response?.headers()["content-security-policy"];
+ expect(cspHeader).toBeTruthy();
+ expect(cspHeader).toContain("script-src");
+ expect(cspHeader).toContain("'nonce-");
+ expect(cspHeader).toContain("style-src");
+ })();
+
+ await step("Inject malicious script via innerHTML & verify execution is blocked")(async () => {
+ const scriptBlocked = await page.evaluate(() => {
+ // Attacker tries to inject script via innerHTML (XSS attack)
+ const container = document.createElement("div");
+ container.innerHTML = "";
+ const script =
+ container.querySelector("script") ??
+ (() => {
+ throw new Error("Failed to create script element");
+ })();
+ document.head.appendChild(script);
+
+ // Check if script executed. Should be false (blocked by CSP).
+ return !(window as unknown as { __xssAttack__?: boolean }).__xssAttack__;
+ });
+
+ expect(scriptBlocked).toBe(true);
+ })();
+
+ await step("Inject malicious CSS via innerHTML & verify styles are blocked")(async () => {
+ const cssBlocked = await page.evaluate(() => {
+ // Attacker tries to inject CSS via innerHTML (XSS attack)
+ const container = document.createElement("div");
+ container.innerHTML = "";
+ const style =
+ container.querySelector("style") ??
+ (() => {
+ throw new Error("Failed to create style element");
+ })();
+ document.head.appendChild(style);
+
+ // Check if malicious CSS was applied. Should NOT have red border (blocked by CSP).
+ const border = window.getComputedStyle(document.body).border;
+ return !border.includes("10px") || !border.includes("red");
+ });
+
+ expect(cssBlocked).toBe(true);
+ })();
+ });
+});
diff --git a/application/back-office/WebApp/public/index.html b/application/back-office/WebApp/public/index.html
index 61b3da9f42..4d8c366ad7 100644
--- a/application/back-office/WebApp/public/index.html
+++ b/application/back-office/WebApp/public/index.html
@@ -9,10 +9,15 @@
+
PlatformPlatform - Back Office
+
- PlatformPlatform - Back Office
diff --git a/application/back-office/WebApp/rsbuild.config.ts b/application/back-office/WebApp/rsbuild.config.ts
index 6c3e40deb1..69584edbe1 100644
--- a/application/back-office/WebApp/rsbuild.config.ts
+++ b/application/back-office/WebApp/rsbuild.config.ts
@@ -11,6 +11,9 @@ import { pluginTypeCheck } from "@rsbuild/plugin-type-check";
const customBuildEnv: CustomBuildEnv = {};
export default defineConfig({
+ security: {
+ nonce: "{{cspNonce}}"
+ },
tools: {
rspack: {
// Exclude tests/e2e directory from file watching to prevent hot reloading issues
diff --git a/application/shared-kernel/SharedKernel/SinglePageApp/SinglePageAppConfiguration.cs b/application/shared-kernel/SharedKernel/SinglePageApp/SinglePageAppConfiguration.cs
index 83f3fa267d..84a3067012 100644
--- a/application/shared-kernel/SharedKernel/SinglePageApp/SinglePageAppConfiguration.cs
+++ b/application/shared-kernel/SharedKernel/SinglePageApp/SinglePageAppConfiguration.cs
@@ -152,8 +152,10 @@ private string GetContentSecurityPolicies()
var contentSecurityPolicies = new[]
{
- $"script-src {trustedHosts} 'strict-dynamic' https:",
- $"script-src-elem {trustedHosts}",
+ $"script-src {trustedHosts} 'nonce-{{NONCE_PLACEHOLDER}}' 'strict-dynamic' https:",
+ $"script-src-elem {trustedHosts} 'nonce-{{NONCE_PLACEHOLDER}}'",
+ $"style-src {trustedHosts} 'nonce-{{NONCE_PLACEHOLDER}}'",
+ $"style-src-elem {trustedHosts} 'nonce-{{NONCE_PLACEHOLDER}}'",
$"default-src {trustedHosts}",
$"connect-src {trustedHosts} data:",
$"img-src {trustedHosts} data: blob:",
diff --git a/application/shared-kernel/SharedKernel/SinglePageApp/SinglePageAppFallbackExtensions.cs b/application/shared-kernel/SharedKernel/SinglePageApp/SinglePageAppFallbackExtensions.cs
index dc8c9ed0cb..bea4f36dfc 100644
--- a/application/shared-kernel/SharedKernel/SinglePageApp/SinglePageAppFallbackExtensions.cs
+++ b/application/shared-kernel/SharedKernel/SinglePageApp/SinglePageAppFallbackExtensions.cs
@@ -1,3 +1,4 @@
+using System.Security.Cryptography;
using System.Text.Encodings.Web;
using System.Text.Json;
using Microsoft.AspNetCore.Antiforgery;
@@ -32,7 +33,9 @@ public static IApplicationBuilder UseSinglePageAppFallback(this WebApplication a
{
app.Map("/remoteEntry.js", (HttpContext context, SinglePageAppConfiguration singlePageAppConfiguration) =>
{
- SetResponseHttpHeaders(singlePageAppConfiguration, context.Response.Headers, "application/javascript");
+ var nonce = Convert.ToBase64String(RandomNumberGenerator.GetBytes(16));
+
+ SetResponseHttpHeaders(singlePageAppConfiguration, context.Response.Headers, "application/javascript", nonce);
var javaScript = singlePageAppConfiguration.GetRemoteEntryJs();
return context.Response.WriteAsync(javaScript);
@@ -54,11 +57,13 @@ SinglePageAppConfiguration singlePageAppConfiguration
return context.Response.WriteAsync("404 Not Found");
}
- SetResponseHttpHeaders(singlePageAppConfiguration, context.Response.Headers, "text/html; charset=utf-8");
+ var nonce = Convert.ToBase64String(RandomNumberGenerator.GetBytes(16));
+
+ SetResponseHttpHeaders(singlePageAppConfiguration, context.Response.Headers, "text/html; charset=utf-8", nonce);
var antiforgeryHttpHeaderToken = GenerateAntiforgeryTokens(antiforgery, context);
- var html = GetHtmlWithEnvironment(singlePageAppConfiguration, executionContext.UserInfo, antiforgeryHttpHeaderToken);
+ var html = GetHtmlWithEnvironment(singlePageAppConfiguration, executionContext.UserInfo, antiforgeryHttpHeaderToken, nonce);
return context.Response.WriteAsync(html);
}
@@ -74,7 +79,8 @@ SinglePageAppConfiguration singlePageAppConfiguration
private static void SetResponseHttpHeaders(
SinglePageAppConfiguration singlePageAppConfiguration,
IHeaderDictionary responseHeaders,
- StringValues contentType
+ StringValues contentType,
+ string nonce
)
{
// No cache headers
@@ -89,7 +95,8 @@ StringValues contentType
responseHeaders.Append("Permissions-Policy", singlePageAppConfiguration.PermissionPolicies);
// Content security policy header
- responseHeaders.Append("Content-Security-Policy", singlePageAppConfiguration.ContentSecurityPolicies);
+ var contentSecurityPolicy = singlePageAppConfiguration.ContentSecurityPolicies.Replace("{NONCE_PLACEHOLDER}", nonce);
+ responseHeaders.Append("Content-Security-Policy", contentSecurityPolicy);
// Content type header
responseHeaders.Append("Content-Type", contentType);
@@ -119,7 +126,8 @@ private static string GenerateAntiforgeryTokens(IAntiforgery antiforgery, HttpCo
private static string GetHtmlWithEnvironment(
SinglePageAppConfiguration singlePageAppConfiguration,
UserInfo userInfo,
- string antiforgeryHttpHeaderToken
+ string antiforgeryHttpHeaderToken,
+ string nonce
)
{
var userInfoEncoded = JsonSerializer.Serialize(userInfo, SinglePageAppConfiguration.JsonHtmlEncodingOptions);
@@ -131,6 +139,8 @@ string antiforgeryHttpHeaderToken
html = html.Replace("%ENCODED_USER_INFO_ENV%", userInfoEscaped);
html = html.Replace("%LOCALE%", userInfo.Locale);
html = html.Replace("%ANTIFORGERY_TOKEN%", antiforgeryHttpHeaderToken);
+ html = html.Replace("%CSP_NONCE%", nonce);
+ html = html.Replace("{{cspNonce}}", nonce);
foreach (var variable in singlePageAppConfiguration.StaticRuntimeEnvironment)
{