From 2523056065af8490b3c24a37aaf03f18af3403c2 Mon Sep 17 00:00:00 2001 From: sammiller Date: Fri, 9 Oct 2026 22:48:08 +0100 Subject: [PATCH 1/2] [WEB.40] Allow the shell base element in the profile CSP (S36) The WebAssembly profile policy emitted base-uri 'none', which blocks the of the Account and Chat shells. The relative framework loader then resolves under the shell path and the shells never start. Profiles now emit base-uri 'self'; the Site policy keeps base-uri 'none'. The Ui, App and Operations policy tests and the profile bundle doc pin the new token, and the Site test pins base-uri 'none'. Co-Authored-By: Claude Haiku 5.5 --- docs/profile-bundle.md | 1 + src/ArcForges.Web.Ui/WasmContentSecurityPolicy.cs | 5 ++++- tests/ArcForges.Web.App.Tests/WasmProfilePolicyTests.cs | 2 ++ .../OperationsProfileTests.cs | 1 + .../ContentSecurityPolicyTests.cs | 9 +++++++++ .../WasmContentSecurityPolicyTests.cs | 8 ++++++++ 6 files changed, 25 insertions(+), 1 deletion(-) diff --git a/docs/profile-bundle.md b/docs/profile-bundle.md index 282e50a..0fc0421 100644 --- a/docs/profile-bundle.md +++ b/docs/profile-bundle.md @@ -15,6 +15,7 @@ The Account and Chat profiles are one published application (`src/ArcForges.Web. - Every `_framework` file is fingerprinted (ten lower-case characters before `.js`, `.wasm` or `.dat`, including the `.br` and `.gz` siblings) or is one of the two unfingerprinted loaders. Any other framework file fails the build, so the immutable rule covers fingerprinted content only. - The bundle verifies its own name, its strict tar layout, its manifest and every member's digest, the profile pages, each profile policy against its page, and the exact headers file. - Each profile policy stays within the Cloudflare header line budget. No `unsafe-eval`, no `unsafe-inline`, and `wasm-unsafe-eval` only where the App policy requires it (P2-021). +- Each profile policy sets `base-uri 'self'`, so the shells' `` takes effect. `base-uri 'none'` blocks that element, the relative framework loader then resolves under `/account/` or `/chat/`, and the shell never starts (S36). The public Site policy keeps `base-uri 'none'`. ## Size budgets diff --git a/src/ArcForges.Web.Ui/WasmContentSecurityPolicy.cs b/src/ArcForges.Web.Ui/WasmContentSecurityPolicy.cs index 590f0c8..0a134b4 100644 --- a/src/ArcForges.Web.Ui/WasmContentSecurityPolicy.cs +++ b/src/ArcForges.Web.Ui/WasmContentSecurityPolicy.cs @@ -9,6 +9,9 @@ namespace ArcForges.Web.Ui; /// The script sources are exactly 'self' and 'wasm-unsafe-eval' plus the SHA-256 hash of every inline script /// body of the host page. style-src stays 'self', so components that need inline styles are not used. The /// policy never carries unsafe-inline or unsafe-eval, and an absolute external script is refused. +/// base-uri is 'self': the shells carry <base href="/"> (CLOUD.85 D1), and 'none' blocks that +/// base element, so the relative framework loader resolves under the shell path and the shell never starts (S36). The Site +/// policy keeps base-uri 'none', because the public pages have no base element. /// public static class WasmContentSecurityPolicy { @@ -33,7 +36,7 @@ public static string FromHostPages(IEnumerable pages) hashes.Add("'sha256-" + Convert.ToBase64String(SHA256.HashData(Encoding.UTF8.GetBytes(body))) + "'"); var scriptSources = string.Join(' ', new[] { "'self'", WasmUnsafeEval }.Concat(hashes)); var policy = "default-src 'self'; script-src " + scriptSources - + "; style-src 'self'; img-src 'self' data:; font-src 'self'; connect-src 'self'; object-src 'none'; base-uri 'none'; frame-ancestors 'none'; form-action 'none'"; + + "; style-src 'self'; img-src 'self' data:; font-src 'self'; connect-src 'self'; object-src 'none'; base-uri 'self'; frame-ancestors 'none'; form-action 'none'"; if (policy.Length >= HeaderLineBudget) throw new InvalidOperationException("The Content-Security-Policy exceeds the Workers header line budget."); return policy; diff --git a/tests/ArcForges.Web.App.Tests/WasmProfilePolicyTests.cs b/tests/ArcForges.Web.App.Tests/WasmProfilePolicyTests.cs index a6571a3..4334e95 100644 --- a/tests/ArcForges.Web.App.Tests/WasmProfilePolicyTests.cs +++ b/tests/ArcForges.Web.App.Tests/WasmProfilePolicyTests.cs @@ -24,6 +24,8 @@ public void TheAppPolicyHasExactlyTheWasmScriptTokensAndSelfOnlyStyles() Assert.Equal(new[] { "'self'" }, directives["style-src"]); Assert.Equal(new[] { "'self'" }, directives["default-src"]); Assert.Equal(new[] { "'self'" }, directives["connect-src"]); + // The shells carry (CLOUD.85 D1), so the profile policy must admit the base element (S36). + Assert.Equal(new[] { "'self'" }, directives["base-uri"]); Assert.DoesNotContain("'unsafe-inline'", policy, StringComparison.Ordinal); Assert.DoesNotContain("'unsafe-eval'", policy, StringComparison.Ordinal); Assert.True(policy.Length < WasmContentSecurityPolicy.HeaderLineBudget); diff --git a/tests/ArcForges.Web.Operations.Tests/OperationsProfileTests.cs b/tests/ArcForges.Web.Operations.Tests/OperationsProfileTests.cs index 0ac0c61..a03d3f6 100644 --- a/tests/ArcForges.Web.Operations.Tests/OperationsProfileTests.cs +++ b/tests/ArcForges.Web.Operations.Tests/OperationsProfileTests.cs @@ -28,6 +28,7 @@ public void ThePolicyHasTheSameExactTokenSetAsTheAppProfiles() var directives = WasmContentSecurityPolicy.ParseDirectives(WasmContentSecurityPolicy.FromHostPages([html])); Assert.Equal(new[] { "'self'", "'wasm-unsafe-eval'" }, directives["script-src"]); Assert.Equal(new[] { "'self'" }, directives["style-src"]); + Assert.Equal(new[] { "'self'" }, directives["base-uri"]); var policy = WasmContentSecurityPolicy.FromHostPages([html]); Assert.DoesNotContain("'unsafe-inline'", policy, StringComparison.Ordinal); Assert.DoesNotContain("'unsafe-eval'", policy, StringComparison.Ordinal); diff --git a/tests/ArcForges.Web.Site.Tests/ContentSecurityPolicyTests.cs b/tests/ArcForges.Web.Site.Tests/ContentSecurityPolicyTests.cs index d63d922..3aad627 100644 --- a/tests/ArcForges.Web.Site.Tests/ContentSecurityPolicyTests.cs +++ b/tests/ArcForges.Web.Site.Tests/ContentSecurityPolicyTests.cs @@ -16,6 +16,15 @@ public void PagesWithoutScriptsGetTheSelfOnlyScriptSource() Assert.Equal(SiteOutputTests.ExpectedPolicy, policy); } + [Fact] + public void TheSitePolicyKeepsBaseUriNoneBecauseThePublicPagesHaveNoBaseElement() + { + var policy = SiteContentSecurityPolicy.FromPages(["plain"]); + + Assert.Contains("; base-uri 'none';", policy, StringComparison.Ordinal); + Assert.DoesNotContain("base-uri 'self'", policy, StringComparison.Ordinal); + } + [Fact] public void AnInlineScriptBodyIsAddedAsItsSha256Hash() { diff --git a/tests/ArcForges.Web.Ui.Tests/WasmContentSecurityPolicyTests.cs b/tests/ArcForges.Web.Ui.Tests/WasmContentSecurityPolicyTests.cs index fe9727d..e1db65c 100644 --- a/tests/ArcForges.Web.Ui.Tests/WasmContentSecurityPolicyTests.cs +++ b/tests/ArcForges.Web.Ui.Tests/WasmContentSecurityPolicyTests.cs @@ -25,6 +25,14 @@ public void StyleSourcesAreSelfOnlyAndTheDefaultIsSelf() Assert.Equal(new[] { "'none'" }, directives["object-src"]); } + [Fact] + public void BaseUriIsSelfForEveryProfilePolicySoTheShellBaseElementTakesEffect() + { + var directives = WasmContentSecurityPolicy.ParseDirectives(WasmContentSecurityPolicy.FromHostPages([HostPage])); + Assert.Equal(new[] { "'self'" }, directives["base-uri"]); + Assert.DoesNotContain("'none'", directives["base-uri"]); + } + [Fact] public void NoUnsafeTokenAppearsInAnyDirective() { From 6795f18a30e75ccfb8982544fe54f4b8ecffd540 Mon Sep 17 00:00:00 2001 From: sammiller Date: Fri, 9 Oct 2026 23:35:17 +0100 Subject: [PATCH 2/2] [WEB.40] Remove the Tailwind preamble from the profile stylesheets (S37) The profile stylesheets began with React-era Tailwind v4 lines (an @import of tailwindcss, two @source lines and an @theme block). Nothing compiles them, so each profile requested a missing /tailwindcss stylesheet and --font-sans was undefined. The @theme variables now live on :root with the same names and values, and the rest of each stylesheet is unchanged. Each profile test project asserts that its stylesheet carries no @import "tailwindcss", @source, @theme, @apply or @tailwind and defines --font-sans on :root. Co-Authored-By: Claude Haiku 5.5 --- src/ArcForges.Web.App/wwwroot/app.css | 8 +------- src/ArcForges.Web.Operations/wwwroot/app.css | 8 +------- .../ArcForges.Web.App.Tests/WasmProfilePolicyTests.cs | 11 +++++++++++ .../OperationsProfileTests.cs | 11 +++++++++++ 4 files changed, 24 insertions(+), 14 deletions(-) diff --git a/src/ArcForges.Web.App/wwwroot/app.css b/src/ArcForges.Web.App/wwwroot/app.css index 43d9e6e..4b87d67 100644 --- a/src/ArcForges.Web.App/wwwroot/app.css +++ b/src/ArcForges.Web.App/wwwroot/app.css @@ -1,16 +1,10 @@ /* SPDX-License-Identifier: AGPL-3.0-only */ -@import "tailwindcss" source(none); -@source "../../../apps/site/app"; -@source "./"; -@theme { +:root { --color-paper: #f4f3ed; --color-ink: #1e2824; --color-forest: #235844; --font-sans: "Segoe UI", "Helvetica Neue", Arial, sans-serif; -} - -:root { font-family: var(--font-sans); color: #1e2824; background: #f4f3ed; diff --git a/src/ArcForges.Web.Operations/wwwroot/app.css b/src/ArcForges.Web.Operations/wwwroot/app.css index 92ff8a0..7ab931d 100644 --- a/src/ArcForges.Web.Operations/wwwroot/app.css +++ b/src/ArcForges.Web.Operations/wwwroot/app.css @@ -1,16 +1,10 @@ /* SPDX-License-Identifier: AGPL-3.0-only */ -@import "tailwindcss" source(none); -@source "../../../apps/site/app"; -@source "./"; -@theme { +:root { --color-paper: #f4f3ed; --color-ink: #1e2824; --color-forest: #235844; --font-sans: "Segoe UI", "Helvetica Neue", Arial, sans-serif; -} - -:root { font-family: var(--font-sans); color: #1e2824; background: #f4f3ed; diff --git a/tests/ArcForges.Web.App.Tests/WasmProfilePolicyTests.cs b/tests/ArcForges.Web.App.Tests/WasmProfilePolicyTests.cs index 4334e95..8a4deb6 100644 --- a/tests/ArcForges.Web.App.Tests/WasmProfilePolicyTests.cs +++ b/tests/ArcForges.Web.App.Tests/WasmProfilePolicyTests.cs @@ -96,4 +96,15 @@ public void TheProfileIsStandaloneWebAssemblyWithAheadOfTimeCompilationOff() Assert.Contains("false", project, StringComparison.Ordinal); Assert.DoesNotContain("Microsoft.AspNetCore.Components.Server", project, StringComparison.Ordinal); } + + [Fact] + public void TheProfileStylesheetCarriesNoTailwindPreambleAndDefinesItsTokensOnRoot() + { + // S37: nothing compiles the profile stylesheet, so Tailwind at-rules would only request a missing /tailwindcss. + var css = AppSource("wwwroot/app.css"); + foreach (var token in new[] { "@import \"tailwindcss\"", "@source", "@theme", "@apply", "@tailwind" }) + Assert.DoesNotContain(token, css, StringComparison.Ordinal); + Assert.Contains("--font-sans:", css, StringComparison.Ordinal); + Assert.Matches(new Regex(":root\\s*\\{[^}]*--font-sans:", RegexOptions.Singleline), css); + } } diff --git a/tests/ArcForges.Web.Operations.Tests/OperationsProfileTests.cs b/tests/ArcForges.Web.Operations.Tests/OperationsProfileTests.cs index a03d3f6..45c68c4 100644 --- a/tests/ArcForges.Web.Operations.Tests/OperationsProfileTests.cs +++ b/tests/ArcForges.Web.Operations.Tests/OperationsProfileTests.cs @@ -48,4 +48,15 @@ public void TheOperationsProfileIsStandaloneWebAssemblyWithAheadOfTimeCompilatio Assert.Contains("Sdk=\"Microsoft.NET.Sdk.BlazorWebAssembly\"", project, StringComparison.Ordinal); Assert.Contains("false", project, StringComparison.Ordinal); } + + [Fact] + public void TheProfileStylesheetCarriesNoTailwindPreambleAndDefinesItsTokensOnRoot() + { + // S37: nothing compiles the profile stylesheet, so Tailwind at-rules would only request a missing /tailwindcss. + var css = File.ReadAllText(Path.Combine(Root, "src", "ArcForges.Web.Operations", "wwwroot", "app.css")); + foreach (var token in new[] { "@import \"tailwindcss\"", "@source", "@theme", "@apply", "@tailwind" }) + Assert.DoesNotContain(token, css, StringComparison.Ordinal); + Assert.Contains("--font-sans:", css, StringComparison.Ordinal); + Assert.Matches(new System.Text.RegularExpressions.Regex(":root\\s*\\{[^}]*--font-sans:", System.Text.RegularExpressions.RegexOptions.Singleline), css); + } }