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>
157 lines
6.7 KiB
C#
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><script></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");
|
|
}
|
|
}
|