Skip to content

Commit d1bc1ca

Browse files
authored
Fix CSP nonce support to resolve browser console warnings (#789)
### Summary & Motivation Fix browser console warnings about Content Security Policy violations for dynamically created style elements by adding nonce support. The application already had CSP headers blocking malicious scripts and styles. However, dynamically created style elements (used by UI components for positioning and styling) triggered CSP violations in the browser console because they lacked nonce attributes. This change generates unique nonces per request and configures the frontend to automatically apply them to legitimate dynamic styles. - Generate cryptographically random nonce per request using `RandomNumberGenerator` - Add nonce directives to CSP headers for `script-src`, `script-src-elem`, `style-src`, and `style-src-elem` - Inject nonce into HTML via meta tag for client-side access - Configure Rsbuild security nonce for webpack bundle loading - Intercept `document.createElement` to automatically add nonce attribute to dynamically created style elements - Add e2e test validating CSP blocks malicious inline scripts and styles ### Downstream projects 1. Add CSP nonce configuration to `your-self-contained-system/WebApp/rsbuild.config.ts`: ```diff export default defineConfig({ + security: { + nonce: "{{cspNonce}}" + }, tools: { ``` 2. Add CSP nonce meta tag and interception script to `your-self-contained-system/WebApp/public/index.html`: ```diff + <meta name="csp-nonce" content="%CSP_NONCE%" /> <title>Your Application Title</title> + <script nonce="{{cspNonce}}"> + window.__webpack_nonce__=document.querySelector('meta[name="csp-nonce"]').content; + const o=document.createElement; + document.createElement=t=>{const e=o.call(document,t);return t.toLowerCase()==='style'&&e.setAttribute('nonce',window.__webpack_nonce__),e}; + </script> ``` ### Checklist - [x] I have added tests, or done manual regression tests - [x] I have updated the documentation, if necessary
2 parents da11a43 + 4413b07 commit d1bc1ca

File tree

7 files changed

+105
-10
lines changed

7 files changed

+105
-10
lines changed

application/account-management/WebApp/public/index.html

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
<!doctype html>
2-
<html class="light" lang="%LOCALE%">
2+
<html lang="%LOCALE%">
33
<head>
44
<meta charset="UTF-8" />
55
<meta content="nofollow" name="robots" />
@@ -9,7 +9,13 @@
99
<meta name="mobile-web-app-capable" content="yes" />
1010
<meta name="format-detection" content="telephone=no" />
1111
<meta name="theme-color" content="#000000" />
12+
<meta name="csp-nonce" content="%CSP_NONCE%" />
1213
<title>PlatformPlatform</title>
14+
<script nonce="{{cspNonce}}">
15+
globalThis.__webpack_nonce__=document.querySelector('meta[name="csp-nonce"]').content;
16+
const o=document.createElement;
17+
document.createElement=t=>{const e=o.call(document,t);if(t.toLowerCase()==='style') { e.setAttribute('nonce',globalThis.__webpack_nonce__); }return e};
18+
</script>
1319
<link href="/favicon.ico" rel="icon" type="image/x-icon">
1420
<link href="/apple-touch-icon.png" rel="apple-touch-icon" sizes="180x180">
1521
<link rel="manifest" href="/manifest.json">

application/account-management/WebApp/rsbuild.config.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,9 @@ import { pluginTypeCheck } from "@rsbuild/plugin-type-check";
1111
const customBuildEnv: CustomBuildEnv = {};
1212

1313
export default defineConfig({
14+
security: {
15+
nonce: "{{cspNonce}}"
16+
},
1417
tools: {
1518
rspack: {
1619
// Exclude tests/e2e directory from file watching to prevent hot reloading issues
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
import { expect } from "@playwright/test";
2+
import { test } from "@shared/e2e/fixtures/page-auth";
3+
import { createTestContext } from "@shared/e2e/utils/test-assertions";
4+
import { step } from "@shared/e2e/utils/test-step-wrapper";
5+
6+
test.describe("@smoke", () => {
7+
test("should block inline scripts and styles injected without valid nonce", async ({ page }) => {
8+
createTestContext(page);
9+
10+
await step("Navigate to landing page & verify CSP nonce configuration")(async () => {
11+
const response = await page.goto("/");
12+
13+
await expect(page).toHaveURL("/");
14+
15+
// Verify meta tag exists
16+
const nonceMetaExists = await page.locator('meta[name="csp-nonce"]').count();
17+
expect(nonceMetaExists).toBe(1);
18+
19+
// Verify CSP headers require nonce for scripts and styles
20+
const cspHeader = response?.headers()["content-security-policy"];
21+
expect(cspHeader).toBeTruthy();
22+
expect(cspHeader).toContain("script-src");
23+
expect(cspHeader).toContain("'nonce-");
24+
expect(cspHeader).toContain("style-src");
25+
})();
26+
27+
await step("Inject malicious script via innerHTML & verify execution is blocked")(async () => {
28+
const scriptBlocked = await page.evaluate(() => {
29+
// Attacker tries to inject script via innerHTML (XSS attack)
30+
const container = document.createElement("div");
31+
container.innerHTML = "<script>window.__xssAttack__ = true;</script>";
32+
const script =
33+
container.querySelector("script") ??
34+
(() => {
35+
throw new Error("Failed to create script element");
36+
})();
37+
document.head.appendChild(script);
38+
39+
// Check if script executed. Should be false (blocked by CSP).
40+
return !(window as unknown as { __xssAttack__?: boolean }).__xssAttack__;
41+
});
42+
43+
expect(scriptBlocked).toBe(true);
44+
})();
45+
46+
await step("Inject malicious CSS via innerHTML & verify styles are blocked")(async () => {
47+
const cssBlocked = await page.evaluate(() => {
48+
// Attacker tries to inject CSS via innerHTML (XSS attack)
49+
const container = document.createElement("div");
50+
container.innerHTML = "<style>body { border: 10px solid red !important; }</style>";
51+
const style =
52+
container.querySelector("style") ??
53+
(() => {
54+
throw new Error("Failed to create style element");
55+
})();
56+
document.head.appendChild(style);
57+
58+
// Check if malicious CSS was applied. Should NOT have red border (blocked by CSP).
59+
const border = window.getComputedStyle(document.body).border;
60+
return !border.includes("10px") || !border.includes("red");
61+
});
62+
63+
expect(cssBlocked).toBe(true);
64+
})();
65+
});
66+
});

application/back-office/WebApp/public/index.html

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,10 +9,15 @@
99
<meta name="mobile-web-app-capable" content="yes" />
1010
<meta name="format-detection" content="telephone=no" />
1111
<meta name="theme-color" content="#000000" />
12+
<meta name="csp-nonce" content="%CSP_NONCE%" />
1213
<title>PlatformPlatform - Back Office</title>
14+
<script nonce="{{cspNonce}}">
15+
globalThis.__webpack_nonce__=document.querySelector('meta[name="csp-nonce"]').content;
16+
const o=document.createElement;
17+
document.createElement=t=>{const e=o.call(document,t);if(t.toLowerCase()==='style') { e.setAttribute('nonce',globalThis.__webpack_nonce__); }return e};
18+
</script>
1319
<link href="/favicon.ico" rel="icon" type="image/x-icon">
1420
<link href="/apple-touch-icon.png" rel="apple-touch-icon" sizes="180x180">
15-
<title>PlatformPlatform - Back Office</title>
1621
<link rel="manifest" href="/manifest.json">
1722
</head>
1823
<body>

application/back-office/WebApp/rsbuild.config.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,9 @@ import { pluginTypeCheck } from "@rsbuild/plugin-type-check";
1111
const customBuildEnv: CustomBuildEnv = {};
1212

1313
export default defineConfig({
14+
security: {
15+
nonce: "{{cspNonce}}"
16+
},
1417
tools: {
1518
rspack: {
1619
// Exclude tests/e2e directory from file watching to prevent hot reloading issues

application/shared-kernel/SharedKernel/SinglePageApp/SinglePageAppConfiguration.cs

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -152,8 +152,10 @@ private string GetContentSecurityPolicies()
152152

153153
var contentSecurityPolicies = new[]
154154
{
155-
$"script-src {trustedHosts} 'strict-dynamic' https:",
156-
$"script-src-elem {trustedHosts}",
155+
$"script-src {trustedHosts} 'nonce-{{NONCE_PLACEHOLDER}}' 'strict-dynamic' https:",
156+
$"script-src-elem {trustedHosts} 'nonce-{{NONCE_PLACEHOLDER}}'",
157+
$"style-src {trustedHosts} 'nonce-{{NONCE_PLACEHOLDER}}'",
158+
$"style-src-elem {trustedHosts} 'nonce-{{NONCE_PLACEHOLDER}}'",
157159
$"default-src {trustedHosts}",
158160
$"connect-src {trustedHosts} data:",
159161
$"img-src {trustedHosts} data: blob:",

application/shared-kernel/SharedKernel/SinglePageApp/SinglePageAppFallbackExtensions.cs

Lines changed: 16 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
using System.Security.Cryptography;
12
using System.Text.Encodings.Web;
23
using System.Text.Json;
34
using Microsoft.AspNetCore.Antiforgery;
@@ -32,7 +33,9 @@ public static IApplicationBuilder UseSinglePageAppFallback(this WebApplication a
3233
{
3334
app.Map("/remoteEntry.js", (HttpContext context, SinglePageAppConfiguration singlePageAppConfiguration) =>
3435
{
35-
SetResponseHttpHeaders(singlePageAppConfiguration, context.Response.Headers, "application/javascript");
36+
var nonce = Convert.ToBase64String(RandomNumberGenerator.GetBytes(16));
37+
38+
SetResponseHttpHeaders(singlePageAppConfiguration, context.Response.Headers, "application/javascript", nonce);
3639

3740
var javaScript = singlePageAppConfiguration.GetRemoteEntryJs();
3841
return context.Response.WriteAsync(javaScript);
@@ -54,11 +57,13 @@ SinglePageAppConfiguration singlePageAppConfiguration
5457
return context.Response.WriteAsync("404 Not Found");
5558
}
5659

57-
SetResponseHttpHeaders(singlePageAppConfiguration, context.Response.Headers, "text/html; charset=utf-8");
60+
var nonce = Convert.ToBase64String(RandomNumberGenerator.GetBytes(16));
61+
62+
SetResponseHttpHeaders(singlePageAppConfiguration, context.Response.Headers, "text/html; charset=utf-8", nonce);
5863

5964
var antiforgeryHttpHeaderToken = GenerateAntiforgeryTokens(antiforgery, context);
6065

61-
var html = GetHtmlWithEnvironment(singlePageAppConfiguration, executionContext.UserInfo, antiforgeryHttpHeaderToken);
66+
var html = GetHtmlWithEnvironment(singlePageAppConfiguration, executionContext.UserInfo, antiforgeryHttpHeaderToken, nonce);
6267

6368
return context.Response.WriteAsync(html);
6469
}
@@ -74,7 +79,8 @@ SinglePageAppConfiguration singlePageAppConfiguration
7479
private static void SetResponseHttpHeaders(
7580
SinglePageAppConfiguration singlePageAppConfiguration,
7681
IHeaderDictionary responseHeaders,
77-
StringValues contentType
82+
StringValues contentType,
83+
string nonce
7884
)
7985
{
8086
// No cache headers
@@ -89,7 +95,8 @@ StringValues contentType
8995
responseHeaders.Append("Permissions-Policy", singlePageAppConfiguration.PermissionPolicies);
9096

9197
// Content security policy header
92-
responseHeaders.Append("Content-Security-Policy", singlePageAppConfiguration.ContentSecurityPolicies);
98+
var contentSecurityPolicy = singlePageAppConfiguration.ContentSecurityPolicies.Replace("{NONCE_PLACEHOLDER}", nonce);
99+
responseHeaders.Append("Content-Security-Policy", contentSecurityPolicy);
93100

94101
// Content type header
95102
responseHeaders.Append("Content-Type", contentType);
@@ -119,7 +126,8 @@ private static string GenerateAntiforgeryTokens(IAntiforgery antiforgery, HttpCo
119126
private static string GetHtmlWithEnvironment(
120127
SinglePageAppConfiguration singlePageAppConfiguration,
121128
UserInfo userInfo,
122-
string antiforgeryHttpHeaderToken
129+
string antiforgeryHttpHeaderToken,
130+
string nonce
123131
)
124132
{
125133
var userInfoEncoded = JsonSerializer.Serialize(userInfo, SinglePageAppConfiguration.JsonHtmlEncodingOptions);
@@ -131,6 +139,8 @@ string antiforgeryHttpHeaderToken
131139
html = html.Replace("%ENCODED_USER_INFO_ENV%", userInfoEscaped);
132140
html = html.Replace("%LOCALE%", userInfo.Locale);
133141
html = html.Replace("%ANTIFORGERY_TOKEN%", antiforgeryHttpHeaderToken);
142+
html = html.Replace("%CSP_NONCE%", nonce);
143+
html = html.Replace("{{cspNonce}}", nonce);
134144

135145
foreach (var variable in singlePageAppConfiguration.StaticRuntimeEnvironment)
136146
{

0 commit comments

Comments
 (0)