Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion application/account-management/WebApp/public/index.html
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
<!doctype html>
<html class="light" lang="%LOCALE%">
<html lang="%LOCALE%">
<head>
<meta charset="UTF-8" />
<meta content="nofollow" name="robots" />
Expand All @@ -9,7 +9,13 @@
<meta name="mobile-web-app-capable" content="yes" />
<meta name="format-detection" content="telephone=no" />
<meta name="theme-color" content="#000000" />
<meta name="csp-nonce" content="%CSP_NONCE%" />
<title>PlatformPlatform</title>
<script nonce="{{cspNonce}}">
globalThis.__webpack_nonce__=document.querySelector('meta[name="csp-nonce"]').content;
const o=document.createElement;
document.createElement=t=>{const e=o.call(document,t);if(t.toLowerCase()==='style') { e.setAttribute('nonce',globalThis.__webpack_nonce__); }return e};
</script>
<link href="/favicon.ico" rel="icon" type="image/x-icon">
<link href="/apple-touch-icon.png" rel="apple-touch-icon" sizes="180x180">
<link rel="manifest" href="/manifest.json">
Expand Down
3 changes: 3 additions & 0 deletions application/account-management/WebApp/rsbuild.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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 = "<script>window.__xssAttack__ = true;</script>";
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 = "<style>body { border: 10px solid red !important; }</style>";
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);
})();
});
});
7 changes: 6 additions & 1 deletion application/back-office/WebApp/public/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,15 @@
<meta name="mobile-web-app-capable" content="yes" />
<meta name="format-detection" content="telephone=no" />
<meta name="theme-color" content="#000000" />
<meta name="csp-nonce" content="%CSP_NONCE%" />
<title>PlatformPlatform - Back Office</title>
<script nonce="{{cspNonce}}">
globalThis.__webpack_nonce__=document.querySelector('meta[name="csp-nonce"]').content;
const o=document.createElement;
document.createElement=t=>{const e=o.call(document,t);if(t.toLowerCase()==='style') { e.setAttribute('nonce',globalThis.__webpack_nonce__); }return e};
</script>
<link href="/favicon.ico" rel="icon" type="image/x-icon">
<link href="/apple-touch-icon.png" rel="apple-touch-icon" sizes="180x180">
<title>PlatformPlatform - Back Office</title>
<link rel="manifest" href="/manifest.json">
</head>
<body>
Expand Down
3 changes: 3 additions & 0 deletions application/back-office/WebApp/rsbuild.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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:",
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
using System.Security.Cryptography;
using System.Text.Encodings.Web;
using System.Text.Json;
using Microsoft.AspNetCore.Antiforgery;
Expand Down Expand Up @@ -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);
Expand All @@ -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);
}
Expand All @@ -74,7 +79,8 @@ SinglePageAppConfiguration singlePageAppConfiguration
private static void SetResponseHttpHeaders(
SinglePageAppConfiguration singlePageAppConfiguration,
IHeaderDictionary responseHeaders,
StringValues contentType
StringValues contentType,
string nonce
)
{
// No cache headers
Expand All @@ -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);
Expand Down Expand Up @@ -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);
Expand All @@ -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)
{
Expand Down
Loading