Files
ersatztv/ErsatzTV.Tests/Middleware/SecurityHeadersMiddlewareTests.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

157 lines
6.7 KiB
C#

using System.Runtime.CompilerServices;
using System.Security.Cryptography;
using System.Text;
using System.Text.RegularExpressions;
using ErsatzTV.Middleware;
using Microsoft.AspNetCore.Http;
using NUnit.Framework;
using Shouldly;
namespace ErsatzTV.Tests.Middleware;
[TestFixture]
public class SecurityHeadersMiddlewareTests
{
private static async Task<HttpContext> Invoke(string path)
{
var nextCalled = false;
var middleware = new SecurityHeadersMiddleware(_ =>
{
nextCalled = true;
return Task.CompletedTask;
});
var context = new DefaultHttpContext();
context.Request.Path = path;
await middleware.InvokeAsync(context);
nextCalled.ShouldBeTrue();
return context;
}
[Test]
public async Task Should_Set_Baseline_Security_Headers()
{
HttpContext context = await Invoke("/app");
context.Response.Headers["X-Content-Type-Options"].ToString().ShouldBe("nosniff");
context.Response.Headers["X-Frame-Options"].ToString().ShouldBe("DENY");
context.Response.Headers["Referrer-Policy"].ToString().ShouldBe("strict-origin-when-cross-origin");
context.Response.Headers["Permissions-Policy"].ToString().ShouldNotBeEmpty();
context.Response.Headers["Cross-Origin-Resource-Policy"].ToString().ShouldBe("same-origin");
}
[TestCase("/api/v1/channels")]
[TestCase("/artwork/x.jpg")]
[TestCase("/iptv/channels.m3u")]
[TestCase("/docs")]
[TestCase("/openapi/v1.json")]
public async Task Should_Set_Cross_Origin_Resource_Policy_On_Every_Response(string path)
{
HttpContext context = await Invoke(path);
context.Response.Headers["Cross-Origin-Resource-Policy"].ToString().ShouldBe("same-origin");
}
[Test]
public async Task Should_Enforce_Csp_On_Spa_And_Api_Responses()
{
foreach (string path in new[] { "/", "/app", "/api/v1/channels", "/artwork/x.jpg", "/iptv/channels.m3u" })
{
HttpContext context = await Invoke(path);
string csp = context.Response.Headers["Content-Security-Policy"].ToString();
csp.ShouldNotBeEmpty($"CSP should be enforced on {path}");
csp.ShouldContain("default-src 'self'");
csp.ShouldContain("script-src 'self' '" + SecurityHeadersMiddleware.SpaInlineScriptHash + "'");
csp.ShouldContain("connect-src 'self'");
csp.ShouldContain("frame-ancestors 'none'");
csp.ShouldContain("object-src 'none'");
// The SPA CSS @imports the Geist web font from Google (verified by live-E2E).
csp.ShouldContain("style-src 'self' 'unsafe-inline' https://fonts.googleapis.com");
csp.ShouldContain("font-src 'self' data: https://fonts.gstatic.com");
// No 'unsafe-inline'/'unsafe-eval' in the script directive.
csp.ShouldNotContain("script-src 'self' 'unsafe");
}
}
[Test]
public async Task Should_Not_Send_Csp_On_Scalar_Docs_Or_Openapi()
{
// Scalar (/docs) and the OpenAPI document (/openapi) rely on inline bootstrap scripts/styles
// a strict CSP would break; they are intentionally excluded (baseline headers still apply).
foreach (string path in new[] { "/docs", "/docs/", "/openapi/v1.json" })
{
HttpContext context = await Invoke(path);
context.Response.Headers.ContainsKey("Content-Security-Policy").ShouldBeFalse($"CSP must not be set on {path}");
// The cheap baseline headers still apply everywhere.
context.Response.Headers["X-Content-Type-Options"].ToString().ShouldBe("nosniff");
context.Response.Headers["Cross-Origin-Resource-Policy"].ToString().ShouldBe("same-origin");
}
}
/// <summary>
/// Guards <see cref="SecurityHeadersMiddleware.SpaInlineScriptHash" /> against drift from the
/// SPA's <c>index.html</c>: every inline <c>&lt;script&gt;</c> the browser will hash-check must
/// be allow-listed by the CSP, or the enforcing policy silently breaks the SPA. Hashes the
/// built <c>wwwroot/app/index.html</c> when present (the exact bytes the browser hashes), else
/// the committed <c>web/index.html</c> source in a fresh CI checkout where the built artifact is
/// gitignored/absent (Vite copies the inline script verbatim, so the two agree).
/// </summary>
[Test]
public void Csp_Script_Hash_Should_Match_The_Spa_Index()
{
string indexPath = SpaIndexSourcePath();
File.Exists(indexPath).ShouldBeTrue($"SPA source index.html not found at {indexPath}");
List<string> inlineHashes = InlineScriptHashes(File.ReadAllText(indexPath));
inlineHashes.ShouldNotBeEmpty("expected the SPA index.html to contain the inline theme-bootstrap script");
foreach (string hash in inlineHashes)
{
hash.ShouldBe(
SecurityHeadersMiddleware.SpaInlineScriptHash,
"an inline <script> in wwwroot/app/index.html is not allow-listed by the CSP — " +
"update SecurityHeadersMiddleware.SpaInlineScriptHash to this value");
}
}
private static List<string> InlineScriptHashes(string html)
{
var hashes = new List<string>();
foreach (Match m in Regex.Matches(html, "<script([^>]*)>(.*?)</script>", RegexOptions.Singleline))
{
// Skip external scripts (they load from 'self', not by hash) and empty bodies.
if (m.Groups[1].Value.Contains("src", StringComparison.OrdinalIgnoreCase))
{
continue;
}
string body = m.Groups[2].Value;
if (string.IsNullOrWhiteSpace(body))
{
continue;
}
byte[] digest = SHA256.HashData(Encoding.UTF8.GetBytes(body));
hashes.Add("sha256-" + Convert.ToBase64String(digest));
}
return hashes;
}
private static string SpaIndexSourcePath([CallerFilePath] string callerFilePath = "")
{
// callerFilePath = <repo>/ErsatzTV.Tests/Middleware/SecurityHeadersMiddlewareTests.cs
string repoRoot = Path.GetFullPath(Path.Combine(Path.GetDirectoryName(callerFilePath)!, "..", ".."));
// Prefer the built index.html — it is the exact bytes the browser hashes, so a local build
// catches any Vite transform of the inline script, not just a source edit. It is gitignored,
// though, so in a fresh CI checkout it is absent and we fall back to the committed source
// (Vite copies the inline script verbatim, so the two agree today).
string built = Path.Combine(repoRoot, "ErsatzTV", "wwwroot", "app", "index.html");
return File.Exists(built) ? built : Path.Combine(repoRoot, "web", "index.html");
}
}