Files
ersatztv/ErsatzTV/Middleware/SecurityHeadersMiddleware.cs
T
timothyandCodex 2609b4ce59
Build ErsatzTV Image / Docs update reminder (pull_request) Successful in 7s
Build ErsatzTV Image / decisions.md append-only (pull_request) Successful in 7s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 19s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 1m29s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 4m15s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 5m26s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
Build ErsatzTV Image / Docs update reminder (push) Has been skipped
Build ErsatzTV Image / decisions.md append-only (push) Has been skipped
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (push) Has been skipped
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (push) Has been skipped
Build ErsatzTV Image / Build & test (.NET) (push) Failing after 7s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 3m44s
Build ErsatzTV Image / Build & push image (amd64) (push) Has been skipped
fix: add cross-origin resource policy header
Fixes #330

Co-Authored-By: Codex <noreply@openai.com>
2026-07-13 21:25:06 +02:00

81 lines
4.5 KiB
C#

using Microsoft.AspNetCore.Http;
namespace ErsatzTV.Middleware;
/// <summary>
/// Adds security response headers to every response (API, IPTV, artwork, static, and error
/// responses alike — which is why this is middleware rather than an MVC filter).
/// <c>nosniff</c> in particular blunts the MIME-sniffing half of the artwork content-type
/// reflection risk (issue #197). A Content-Security-Policy is enforced everywhere except the
/// Scalar API-reference UI (<c>/docs</c>) and the OpenAPI document (<c>/openapi</c>) — see
/// <see cref="InvokeAsync" />. HSTS is intentionally left out: it is a deployment/TLS decision
/// that belongs with the go-live reverse-proxy posture, not this host middleware.
/// Baseline headers landed for #197; the CSP + Permissions-Policy were added for #319 (ZAP
/// baseline hardening). Cross-Origin-Resource-Policy was added for #330 to prevent browsers
/// from embedding responses through cross-origin no-cors requests.
/// </summary>
public class SecurityHeadersMiddleware(RequestDelegate next)
{
/// <summary>
/// sha256 of the inline theme-bootstrap <c>&lt;script&gt;</c> in the built SPA
/// <c>index.html</c> (<c>ErsatzTV/wwwroot/app</c>). The SPA is served as a static file, so a
/// per-response CSP nonce is not possible — the one inline script is allow-listed by hash
/// instead. If <c>web/index.html</c>'s inline script changes, this hash must change with it;
/// <c>SecurityHeadersMiddlewareTests.Csp_Script_Hash_Should_Match_The_Spa_Index</c>
/// guards the two against drift.
/// </summary>
public const string SpaInlineScriptHash = "sha256-5qq06L57XDT8IqaUeyjnI1l7bZ+ggOaE4GXF4TpnGwM=";
/// <summary>
/// Enforcing CSP for the SPA plus the API/artwork/IPTV responses. Notes on the choices:
/// <list type="bullet">
/// <item><c>script-src 'self'</c> (the Vite module bundle) + the inline theme-bootstrap
/// hash — no <c>'unsafe-inline'</c>/<c>'unsafe-eval'</c> (the SPA has no eval/CSS-in-JS).</item>
/// <item><c>style-src 'unsafe-inline'</c>: React writes inline <c>style=""</c> attributes
/// (there is no styled-components/emotion in the SPA to hash <c>&lt;style&gt;</c> blocks).
/// <c>fonts.googleapis.com</c> is the Geist web-font stylesheet the SPA CSS <c>@import</c>s
/// (with <c>fonts.gstatic.com</c> in <c>font-src</c> for the font files). Self-hosting the
/// font to drop the Google dependency is a follow-on hardening, not this issue.</item>
/// <item><c>img-src data: blob:</c>: favicon/generated-image data URIs and object-URL
/// upload previews.</item>
/// <item><c>connect-src 'self'</c>: the SPA only ever fetches same-origin
/// <c>/api</c>, <c>/artwork</c>, <c>/iptv</c>.</item>
/// </list>
/// Non-HTML responses (JSON, images, m3u) never exercise the script/style directives, so the
/// same policy is safe to apply to them.
/// </summary>
private static readonly string ContentSecurityPolicy = string.Join(
"; ",
"default-src 'self'",
"script-src 'self' '" + SpaInlineScriptHash + "'",
"style-src 'self' 'unsafe-inline' https://fonts.googleapis.com",
"img-src 'self' data: blob:",
"font-src 'self' data: https://fonts.gstatic.com",
"connect-src 'self'",
"object-src 'none'",
"base-uri 'self'",
"frame-ancestors 'none'",
"form-action 'self'");
public Task InvokeAsync(HttpContext context)
{
IHeaderDictionary headers = context.Response.Headers;
headers["X-Content-Type-Options"] = "nosniff";
headers["X-Frame-Options"] = "DENY";
headers["Referrer-Policy"] = "strict-origin-when-cross-origin";
headers["Permissions-Policy"] = "camera=(), microphone=(), geolocation=(), payment=(), usb=()";
headers["Cross-Origin-Resource-Policy"] = "same-origin";
// The Scalar API-reference UI (/docs) and the OpenAPI document (/openapi) rely on inline
// bootstrap scripts/styles that a strict policy would break; hardening that admin surface is
// a #197 follow-up. Everything else — the SPA, /api, /artwork, /iptv — gets the CSP.
PathString path = context.Request.Path;
if (!path.StartsWithSegments("/docs") && !path.StartsWithSegments("/openapi"))
{
headers["Content-Security-Policy"] = ContentSecurityPolicy;
}
return next(context);
}
}