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
Fixes #330 Co-Authored-By: Codex <noreply@openai.com>
81 lines
4.5 KiB
C#
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><script></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><style></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);
|
|
}
|
|
}
|