From 719497313568df5db9b4fcf9ffced0f7872383c8 Mon Sep 17 00:00:00 2001 From: Chris0Jeky Date: Sat, 5 Sep 2026 04:22:09 +0100 Subject: [PATCH 1/3] fix: allow same-origin PWA manifests in CSP --- backend/src/Taskdeck.Api/appsettings.json | 2 +- .../SecurityHeadersApiTests.cs | 21 +++++++++++++++++++ 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/backend/src/Taskdeck.Api/appsettings.json b/backend/src/Taskdeck.Api/appsettings.json index 40338b1825..5de38540a3 100644 --- a/backend/src/Taskdeck.Api/appsettings.json +++ b/backend/src/Taskdeck.Api/appsettings.json @@ -113,7 +113,7 @@ "HstsMaxAgeDays": 365, "HstsIncludeSubDomains": false, "HstsPreload": false, - "ContentSecurityPolicy": "default-src 'none'; base-uri 'self'; frame-ancestors 'none'; form-action 'self'; connect-src 'self'; img-src 'self'; font-src 'self'; style-src 'self'; script-src 'self'", + "ContentSecurityPolicy": "default-src 'none'; base-uri 'self'; frame-ancestors 'none'; form-action 'self'; connect-src 'self'; img-src 'self'; font-src 'self'; style-src 'self'; script-src 'self'; manifest-src 'self'", "XFrameOptions": "DENY", "ReferrerPolicy": "no-referrer" }, diff --git a/backend/tests/Taskdeck.Api.Tests/SecurityHeadersApiTests.cs b/backend/tests/Taskdeck.Api.Tests/SecurityHeadersApiTests.cs index dabd5b0f8c..86584df487 100644 --- a/backend/tests/Taskdeck.Api.Tests/SecurityHeadersApiTests.cs +++ b/backend/tests/Taskdeck.Api.Tests/SecurityHeadersApiTests.cs @@ -78,6 +78,27 @@ public async Task SecurityHeaders_CspFontSrc_ShouldAllowOnlySameOriginFonts() fontSrcDirective.Should().NotContain("fonts.gstatic.com"); } + [Fact] + public async Task SecurityHeaders_CspManifestSrc_ShouldAllowOnlySameOriginManifests() + { + using var client = _factory.CreateClient(); + + var response = await client.GetAsync("/health/live"); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + response.Headers.TryGetValues("Content-Security-Policy", out var cspValues).Should().BeTrue(); + var csp = cspValues.Should().ContainSingle().Subject; + csp.Should().Contain("default-src 'none'"); + + var manifestSrcDirective = csp + .Split(';', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + .Should() + .ContainSingle(directive => directive.StartsWith("manifest-src ", StringComparison.Ordinal)) + .Which; + + manifestSrcDirective.Should().Be("manifest-src 'self'"); + } + [Fact] public async Task SecurityHeaders_ShouldBePresent_OnUnauthorizedResponses() { From 630e6b7458c0b156fc28543568f34c6c83a76e2c Mon Sep 17 00:00:00 2001 From: Chris0Jeky Date: Sat, 5 Sep 2026 14:40:30 +0100 Subject: [PATCH 2/3] fix(security-headers): carry manifest-src 'self' in the code-side CSP default too Review round 1 (fresh-context, D-12 sweep): the directive was added to appsettings.json only, so a deployment whose configuration omits SecurityHeaders:ContentSecurityPolicy bound the SecurityHeadersSettings initializer and still emitted a CSP without manifest-src. The two defaults are byte-identical again, the API test asserts the default-src directive exactly and checks the class default, and the PWA behavior and configuration reference docs describe the shipped value. --- .../Services/SecurityHeadersSettings.cs | 2 +- .../SecurityHeadersApiTests.cs | 19 ++++++++++++++----- docs/platform/CONFIGURATION_REFERENCE.md | 2 +- docs/platform/PWA_OFFLINE_BEHAVIOR.md | 7 ++++--- 4 files changed, 20 insertions(+), 10 deletions(-) diff --git a/backend/src/Taskdeck.Application/Services/SecurityHeadersSettings.cs b/backend/src/Taskdeck.Application/Services/SecurityHeadersSettings.cs index 1911ddf1ba..15697c80a9 100644 --- a/backend/src/Taskdeck.Application/Services/SecurityHeadersSettings.cs +++ b/backend/src/Taskdeck.Application/Services/SecurityHeadersSettings.cs @@ -28,7 +28,7 @@ public sealed class SecurityHeadersSettings // removing 'unsafe-inline' does not break Vue reactivity. [Required(AllowEmptyStrings = false)] public string ContentSecurityPolicy { get; set; } = - "default-src 'none'; base-uri 'self'; frame-ancestors 'none'; form-action 'self'; connect-src 'self'; img-src 'self'; font-src 'self'; style-src 'self'; script-src 'self'"; + "default-src 'none'; base-uri 'self'; frame-ancestors 'none'; form-action 'self'; connect-src 'self'; img-src 'self'; font-src 'self'; style-src 'self'; script-src 'self'; manifest-src 'self'"; [Required(AllowEmptyStrings = false)] public string XFrameOptions { get; set; } = "DENY"; diff --git a/backend/tests/Taskdeck.Api.Tests/SecurityHeadersApiTests.cs b/backend/tests/Taskdeck.Api.Tests/SecurityHeadersApiTests.cs index 86584df487..9a20c58040 100644 --- a/backend/tests/Taskdeck.Api.Tests/SecurityHeadersApiTests.cs +++ b/backend/tests/Taskdeck.Api.Tests/SecurityHeadersApiTests.cs @@ -88,15 +88,24 @@ public async Task SecurityHeaders_CspManifestSrc_ShouldAllowOnlySameOriginManife response.StatusCode.Should().Be(HttpStatusCode.OK); response.Headers.TryGetValues("Content-Security-Policy", out var cspValues).Should().BeTrue(); var csp = cspValues.Should().ContainSingle().Subject; - csp.Should().Contain("default-src 'none'"); + var directives = csp.Split(';', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); - var manifestSrcDirective = csp - .Split(';', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + directives + .Should() + .ContainSingle(directive => directive.StartsWith("default-src ", StringComparison.Ordinal)) + .Which.Should().Be("default-src 'none'"); + + directives .Should() .ContainSingle(directive => directive.StartsWith("manifest-src ", StringComparison.Ordinal)) - .Which; + .Which.Should().Be("manifest-src 'self'"); - manifestSrcDirective.Should().Be("manifest-src 'self'"); + // The code-side default must carry the same directive, so a deployment whose + // configuration omits SecurityHeaders:ContentSecurityPolicy behaves the same way. + new SecurityHeadersSettings().ContentSecurityPolicy + .Split(';', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + .Should() + .Contain("manifest-src 'self'"); } [Fact] diff --git a/docs/platform/CONFIGURATION_REFERENCE.md b/docs/platform/CONFIGURATION_REFERENCE.md index d3b040d759..678fb9d92a 100644 --- a/docs/platform/CONFIGURATION_REFERENCE.md +++ b/docs/platform/CONFIGURATION_REFERENCE.md @@ -626,7 +626,7 @@ Development when the key is unset (`SettingsRegistration.cs`). Consumed by | `SecurityHeaders:HstsMaxAgeDays` | `int` | `365` | `max-age` value for HSTS in days. | No | | `SecurityHeaders:HstsIncludeSubDomains` | `bool` | `false` | Include `includeSubDomains` in HSTS. | No | | `SecurityHeaders:HstsPreload` | `bool` | `false` | Include `preload` in HSTS. | No | -| `SecurityHeaders:ContentSecurityPolicy` | `string` | `default-src 'none'; base-uri 'self'; frame-ancestors 'none'; form-action 'self'; connect-src 'self'; img-src 'self'; font-src 'self'; style-src 'self'; script-src 'self'` | Raw CSP string. SEC-29: `'unsafe-inline'` removed from `style-src` — API serves JSON (Swagger excluded from CSP), no inline styles needed. | No | +| `SecurityHeaders:ContentSecurityPolicy` | `string` | `default-src 'none'; base-uri 'self'; frame-ancestors 'none'; form-action 'self'; connect-src 'self'; img-src 'self'; font-src 'self'; style-src 'self'; script-src 'self'; manifest-src 'self'` | Raw CSP string. SEC-29: `'unsafe-inline'` removed from `style-src` — API serves JSON (Swagger excluded from CSP), no inline styles needed. | No | | `SecurityHeaders:XFrameOptions` | `string` | `DENY` | Value for `X-Frame-Options`. | No | | `SecurityHeaders:ReferrerPolicy` | `string` | `no-referrer` | Value for `Referrer-Policy`. | No | diff --git a/docs/platform/PWA_OFFLINE_BEHAVIOR.md b/docs/platform/PWA_OFFLINE_BEHAVIOR.md index 317084dd2f..69144629d0 100644 --- a/docs/platform/PWA_OFFLINE_BEHAVIOR.md +++ b/docs/platform/PWA_OFFLINE_BEHAVIOR.md @@ -168,9 +168,10 @@ Workbox handles cache versioning automatically via content hashing in precache m ## PWA Installability The generated frontend assets meet Chrome's PWA installability criteria when the deployment serves -the same-origin manifest under its CSP. Exact packaged-desktop proof currently finds that the API -CSP omits `manifest-src`, so Chrome blocks `manifest.webmanifest` on that surface; issue `#2045` -tracks the directive and packaged reproof. The generated contract includes: +the same-origin manifest under its CSP. The API's production CSP default (both the shipped +`appsettings.json` and the code-side `SecurityHeadersSettings` default) now includes +`manifest-src 'self'` (PR `#2626`); the packaged-desktop installability reproof is still owed and +issue `#2045` tracks it. The generated contract includes: - Valid `manifest.webmanifest` with `name`, `short_name`, `start_url`, `display: standalone`, and icons (192x192 and 512x512 PNG). - Service worker with fetch handler (provided by Workbox). From 66fe23a5acc63de9290242493eb26a2782636b7a Mon Sep 17 00:00:00 2001 From: Chris0Jeky Date: Sat, 5 Sep 2026 14:43:23 +0100 Subject: [PATCH 3/3] test: add the missing Application using for the CSP default assertion --- backend/tests/Taskdeck.Api.Tests/SecurityHeadersApiTests.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/backend/tests/Taskdeck.Api.Tests/SecurityHeadersApiTests.cs b/backend/tests/Taskdeck.Api.Tests/SecurityHeadersApiTests.cs index 9a20c58040..c374e274fb 100644 --- a/backend/tests/Taskdeck.Api.Tests/SecurityHeadersApiTests.cs +++ b/backend/tests/Taskdeck.Api.Tests/SecurityHeadersApiTests.cs @@ -4,6 +4,7 @@ using Microsoft.AspNetCore.Mvc.Testing; using Microsoft.Net.Http.Headers; using Taskdeck.Api.Tests.Support; +using Taskdeck.Application.Services; using Xunit; namespace Taskdeck.Api.Tests;