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) {