security(#197): harden ApiKeyProvider persistence + add provider tests (review fixes)
Build ErsatzTV Image / Docs update reminder (pull_request) Successful in 7s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 4m17s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 5m22s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped

Cold adversarial review of PR #292 = MERGEABLE-WITH-NITS (no BLOCKER/HIGH). Addresses:
- M1: ApiKeyProvider's never-empty invariant was untested. Add ApiKeyProviderTests
  covering WriteKey precedence, load-existing, empty-file regenerate, generate+persist,
  0600 mode, and still-usable-key-when-persist-fails. ResolveKey extracted to an
  internal seam taking the key path (InternalsVisibleTo ErsatzTV.Tests).
- L2: write-then-chmod race — the key was briefly world-readable. Persist now creates
  the file 0600 atomically via FileStreamOptions.UnixCreateMode (then re-asserts).
- L3: a transient read error on an EXISTING key file silently regenerated + clobbered
  it (invalidating every client key). ResolveKey now rethrows on an unreadable existing
  file (fail loud) and only regenerates when the file is absent or empty.

L4 (LocalhostOnly XFF-spoof under default trust-all) and N5 (length oracle on a
fixed-width key) accepted as documented/cosmetic.

Refs #197 #280

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-12 00:17:44 +02:00
co-authored by Claude Opus 4.8
parent 98ff9a59f5
commit 0fdb2841b7
2 changed files with 166 additions and 23 deletions
@@ -0,0 +1,115 @@
using System.Collections.Generic;
using System.IO;
using ErsatzTV.Services;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging.Abstractions;
using NUnit.Framework;
using Shouldly;
namespace ErsatzTV.Tests.Services;
[TestFixture]
public class ApiKeyProviderTests
{
private DirectoryInfo _tempDir = null!;
private string _keyPath = null!;
[SetUp]
public void SetUp()
{
_tempDir = Directory.CreateTempSubdirectory("etv-apikey-tests");
_keyPath = Path.Combine(_tempDir.FullName, "api.key");
}
[TearDown]
public void TearDown()
{
try
{
_tempDir.Delete(recursive: true);
}
catch
{
// best-effort cleanup
}
}
private static IConfiguration Config(params (string Key, string Value)[] settings)
{
var dict = new Dictionary<string, string?>();
foreach ((string key, string value) in settings)
{
dict[key] = value;
}
return new ConfigurationBuilder().AddInMemoryCollection(dict).Build();
}
[Test]
public void Configured_WriteKey_Takes_Precedence_And_Does_Not_Write_A_File()
{
IConfiguration config = Config((ApiKeyProvider.WriteKeyConfigurationKey, " from-config "));
string key = ApiKeyProvider.ResolveKey(config, _keyPath, NullLogger.Instance);
key.ShouldBe("from-config"); // trimmed
File.Exists(_keyPath).ShouldBeFalse();
}
[Test]
public void Existing_Key_File_Is_Loaded()
{
File.WriteAllText(_keyPath, " persisted-key \n");
string key = ApiKeyProvider.ResolveKey(Config(), _keyPath, NullLogger.Instance);
key.ShouldBe("persisted-key");
}
[Test]
public void Empty_Key_File_Is_Regenerated()
{
File.WriteAllText(_keyPath, " \n");
string key = ApiKeyProvider.ResolveKey(Config(), _keyPath, NullLogger.Instance);
key.ShouldMatch("^[0-9a-f]{64}$");
File.ReadAllText(_keyPath).Trim().ShouldBe(key); // the empty file was overwritten
}
[Test]
public void Generates_And_Persists_A_256_Bit_Key_When_None_Exists()
{
string key = ApiKeyProvider.ResolveKey(Config(), _keyPath, NullLogger.Instance);
key.ShouldMatch("^[0-9a-f]{64}$"); // 32 bytes hex
File.Exists(_keyPath).ShouldBeTrue();
File.ReadAllText(_keyPath).Trim().ShouldBe(key);
}
[Test]
[Platform(Exclude = "Win", Reason = "Unix file mode is not applicable on Windows")]
public void Persisted_Key_File_Is_Owner_Read_Write_Only()
{
ApiKeyProvider.ResolveKey(Config(), _keyPath, NullLogger.Instance);
UnixFileMode mode = File.GetUnixFileMode(_keyPath);
mode.ShouldBe(UnixFileMode.UserRead | UnixFileMode.UserWrite);
}
[Test]
public void Returns_A_Usable_Key_Even_When_Persist_Fails()
{
// Make persistence fail by giving the key a parent path that is a FILE, not a directory:
// Directory.CreateDirectory / file creation then throws, Persist swallows it, and the
// resolver must still return the in-memory generated key (the never-empty invariant, #280).
string fileAsParent = Path.Combine(_tempDir.FullName, "not-a-dir");
File.WriteAllText(fileAsParent, "x");
string unwritable = Path.Combine(fileAsParent, "api.key");
string key = ApiKeyProvider.ResolveKey(Config(), unwritable, NullLogger.Instance);
key.ShouldMatch("^[0-9a-f]{64}$");
File.Exists(unwritable).ShouldBeFalse();
}
}
+51 -23
View File
@@ -30,14 +30,14 @@ public sealed class ApiKeyProvider : IApiKeyProvider
// Defense-in-depth default: gate reads too. Operators behind an authenticating proxy who
// want anonymous catalog reads can opt out with Api:RequireKeyForReads=false.
RequireKeyForReads = configuration.GetValue(RequireKeyForReadsConfigurationKey, true);
ApiKey = ResolveKey(configuration, logger);
ApiKey = ResolveKey(configuration, FileSystemLayout.ApiKeyPath, logger);
}
public string ApiKey { get; }
public bool RequireKeyForReads { get; }
private static string ResolveKey(IConfiguration configuration, ILogger logger)
internal static string ResolveKey(IConfiguration configuration, string keyFilePath, ILogger logger)
{
string configured = configuration[WriteKeyConfigurationKey];
if (!string.IsNullOrWhiteSpace(configured))
@@ -46,31 +46,41 @@ public sealed class ApiKeyProvider : IApiKeyProvider
return configured.Trim();
}
string path = FileSystemLayout.ApiKeyPath;
try
if (File.Exists(keyFilePath))
{
if (File.Exists(path))
string existing;
try
{
string existing = File.ReadAllText(path).Trim();
if (!string.IsNullOrWhiteSpace(existing))
{
logger.LogInformation("Loaded persisted API key from {Path}", path);
return existing;
}
existing = File.ReadAllText(keyFilePath).Trim();
}
}
catch (Exception ex)
{
logger.LogWarning(ex, "Failed to read persisted API key from {Path}; generating a new one", path);
catch (Exception ex)
{
// The file is present but unreadable (e.g. wrong permissions). Fail loud rather than
// silently generate + overwrite it, which would invalidate every client's stored key.
logger.LogError(
ex,
"API key file {Path} exists but could not be read; refusing to overwrite it. Fix its "
+ "permissions or set {ConfigurationKey}.",
keyFilePath,
WriteKeyConfigurationKey);
throw;
}
if (!string.IsNullOrWhiteSpace(existing))
{
logger.LogInformation("Loaded persisted API key from {Path}", keyFilePath);
return existing;
}
logger.LogWarning("Persisted API key file {Path} was empty; generating a new key", keyFilePath);
}
string generated = GenerateKey();
Persist(path, generated, logger);
Persist(keyFilePath, generated, logger);
return generated;
}
private static string GenerateKey() =>
internal static string GenerateKey() =>
// 256 bits of entropy, rendered as lowercase hex so it is trivial to copy/paste with no
// URL-/header-unsafe characters.
Convert.ToHexString(RandomNumberGenerator.GetBytes(32)).ToLowerInvariant();
@@ -85,12 +95,30 @@ public sealed class ApiKeyProvider : IApiKeyProvider
Directory.CreateDirectory(directory);
}
File.WriteAllText(path, key);
// Owner read/write only (0600); no-op / unsupported on Windows.
if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
{
File.SetUnixFileMode(path, UnixFileMode.UserRead | UnixFileMode.UserWrite);
File.WriteAllText(path, key);
}
else
{
// Create the file owner-only (0600) up front so the key is never briefly world-readable
// between write and chmod. SetUnixFileMode afterwards re-asserts it if the file pre-existed
// (UnixCreateMode only applies to a newly-created file, not a truncated one).
const UnixFileMode ownerReadWrite = UnixFileMode.UserRead | UnixFileMode.UserWrite;
var options = new FileStreamOptions
{
Mode = FileMode.Create,
Access = FileAccess.Write,
UnixCreateMode = ownerReadWrite
};
using (var stream = new FileStream(path, options))
using (var writer = new StreamWriter(stream))
{
writer.Write(key);
}
File.SetUnixFileMode(path, ownerReadWrite);
}
logger.LogWarning(