diff --git a/backend/src/Taskdeck.Api/appsettings.json b/backend/src/Taskdeck.Api/appsettings.json index 40338b182..5de38540a 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/src/Taskdeck.Application/Services/SecurityHeadersSettings.cs b/backend/src/Taskdeck.Application/Services/SecurityHeadersSettings.cs index 1911ddf1b..15697c80a 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 dabd5b0f8..c374e274f 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; @@ -78,6 +79,36 @@ 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; + var directives = 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.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] public async Task SecurityHeaders_ShouldBePresent_OnUnauthorizedResponses() { diff --git a/docs/platform/CONFIGURATION_REFERENCE.md b/docs/platform/CONFIGURATION_REFERENCE.md index d3b040d75..678fb9d92 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 317084dd2..69144629d 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).