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.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/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..8a4deb6 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);
@@ -94,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 0ac0c61..45c68c4 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);
@@ -47,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);
+ }
}
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()
{