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
1 change: 1 addition & 0 deletions docs/profile-bundle.md
Original file line number Diff line number Diff line change
Expand Up @@ -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' `<base href="/">` 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

Expand Down
8 changes: 1 addition & 7 deletions src/ArcForges.Web.App/wwwroot/app.css
Original file line number Diff line number Diff line change
@@ -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;
Expand Down
8 changes: 1 addition & 7 deletions src/ArcForges.Web.Operations/wwwroot/app.css
Original file line number Diff line number Diff line change
@@ -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;
Expand Down
5 changes: 4 additions & 1 deletion src/ArcForges.Web.Ui/WasmContentSecurityPolicy.cs
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,9 @@ namespace ArcForges.Web.Ui;
/// The script sources are exactly <c>'self'</c> and <c>'wasm-unsafe-eval'</c> plus the SHA-256 hash of every inline script
/// body of the host page. <c>style-src</c> stays <c>'self'</c>, so components that need inline styles are not used. The
/// policy never carries <c>unsafe-inline</c> or <c>unsafe-eval</c>, and an absolute external script is refused.
/// <c>base-uri</c> is <c>'self'</c>: the shells carry <c>&lt;base href="/"&gt;</c> (CLOUD.85 D1), and <c>'none'</c> blocks that
/// base element, so the relative framework loader resolves under the shell path and the shell never starts (S36). The Site
/// policy keeps <c>base-uri 'none'</c>, because the public pages have no base element.
/// </summary>
public static class WasmContentSecurityPolicy
{
Expand All @@ -33,7 +36,7 @@ public static string FromHostPages(IEnumerable<string> 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;
Expand Down
13 changes: 13 additions & 0 deletions tests/ArcForges.Web.App.Tests/WasmProfilePolicyTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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 <base href="/"> (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);
Expand Down Expand Up @@ -94,4 +96,15 @@ public void TheProfileIsStandaloneWebAssemblyWithAheadOfTimeCompilationOff()
Assert.Contains("<RunAOTCompilation>false</RunAOTCompilation>", 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);
}
}
12 changes: 12 additions & 0 deletions tests/ArcForges.Web.Operations.Tests/OperationsProfileTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -47,4 +48,15 @@ public void TheOperationsProfileIsStandaloneWebAssemblyWithAheadOfTimeCompilatio
Assert.Contains("Sdk=\"Microsoft.NET.Sdk.BlazorWebAssembly\"", project, StringComparison.Ordinal);
Assert.Contains("<RunAOTCompilation>false</RunAOTCompilation>", 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);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,15 @@ public void PagesWithoutScriptsGetTheSelfOnlyScriptSource()
Assert.Equal(SiteOutputTests.ExpectedPolicy, policy);
}

[Fact]
public void TheSitePolicyKeepsBaseUriNoneBecauseThePublicPagesHaveNoBaseElement()
{
var policy = SiteContentSecurityPolicy.FromPages(["<!DOCTYPE html><html><head></head><body>plain</body></html>"]);

Assert.Contains("; base-uri 'none';", policy, StringComparison.Ordinal);
Assert.DoesNotContain("base-uri 'self'", policy, StringComparison.Ordinal);
}

[Fact]
public void AnInlineScriptBodyIsAddedAsItsSha256Hash()
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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()
{
Expand Down
Loading