fix(inbound-api): resolve InboundAPI-001/003/005 — concurrent handler cache, constant-time API key compare, script trust-model enforcement

This commit is contained in:
Joseph Doherty
2026-05-16 19:47:17 -04:00
parent d30ded7e72
commit 6f4efdfa2e
6 changed files with 393 additions and 28 deletions

View File

@@ -37,7 +37,7 @@ public class ApiKeyValidatorTests
[Fact]
public async Task InvalidApiKey_Returns401()
{
_repository.GetApiKeyByValueAsync("bad-key").Returns((ApiKey?)null);
_repository.GetAllApiKeysAsync().Returns(new List<ApiKey>());
var result = await _validator.ValidateAsync("bad-key", "testMethod");
Assert.False(result.IsValid);
@@ -48,7 +48,7 @@ public class ApiKeyValidatorTests
public async Task DisabledApiKey_Returns401()
{
var key = new ApiKey("test", "valid-key") { Id = 1, IsEnabled = false };
_repository.GetApiKeyByValueAsync("valid-key").Returns(key);
_repository.GetAllApiKeysAsync().Returns(new List<ApiKey> { key });
var result = await _validator.ValidateAsync("valid-key", "testMethod");
Assert.False(result.IsValid);
@@ -59,7 +59,7 @@ public class ApiKeyValidatorTests
public async Task ValidKey_MethodNotFound_Returns400()
{
var key = new ApiKey("test", "valid-key") { Id = 1, IsEnabled = true };
_repository.GetApiKeyByValueAsync("valid-key").Returns(key);
_repository.GetAllApiKeysAsync().Returns(new List<ApiKey> { key });
_repository.GetMethodByNameAsync("nonExistent").Returns((ApiMethod?)null);
var result = await _validator.ValidateAsync("valid-key", "nonExistent");
@@ -73,7 +73,7 @@ public class ApiKeyValidatorTests
var key = new ApiKey("test", "valid-key") { Id = 1, IsEnabled = true };
var method = new ApiMethod("testMethod", "return 1;") { Id = 10 };
_repository.GetApiKeyByValueAsync("valid-key").Returns(key);
_repository.GetAllApiKeysAsync().Returns(new List<ApiKey> { key });
_repository.GetMethodByNameAsync("testMethod").Returns(method);
_repository.GetApprovedKeysForMethodAsync(10).Returns(new List<ApiKey>());
@@ -88,7 +88,7 @@ public class ApiKeyValidatorTests
var key = new ApiKey("test", "valid-key") { Id = 1, IsEnabled = true };
var method = new ApiMethod("testMethod", "return 1;") { Id = 10 };
_repository.GetApiKeyByValueAsync("valid-key").Returns(key);
_repository.GetAllApiKeysAsync().Returns(new List<ApiKey> { key });
_repository.GetMethodByNameAsync("testMethod").Returns(method);
_repository.GetApprovedKeysForMethodAsync(10).Returns(new List<ApiKey> { key });
@@ -98,4 +98,50 @@ public class ApiKeyValidatorTests
Assert.Equal(key, result.ApiKey);
Assert.Equal(method, result.Method);
}
// --- InboundAPI-003: API key must not be matched with a non-constant-time
// (timing-oracle) secret-equality lookup. ---
[Fact]
public async Task ValidateAsync_DoesNotUseSecretEqualityLookup()
{
// GetApiKeyByValueAsync translates to a SQL "WHERE KeyValue = @secret" early-exit
// comparison — a timing side-channel. The validator must not call it.
var key = new ApiKey("test", "valid-key") { Id = 1, IsEnabled = true };
var method = new ApiMethod("testMethod", "return 1;") { Id = 10 };
_repository.GetAllApiKeysAsync().Returns(new List<ApiKey> { key });
_repository.GetMethodByNameAsync("testMethod").Returns(method);
_repository.GetApprovedKeysForMethodAsync(10).Returns(new List<ApiKey> { key });
await _validator.ValidateAsync("valid-key", "testMethod");
await _repository.DidNotReceive()
.GetApiKeyByValueAsync(Arg.Any<string>(), Arg.Any<CancellationToken>());
}
[Fact]
public async Task ValidateAsync_WrongKey_ConstantTimePath_Returns401()
{
var key = new ApiKey("test", "valid-key") { Id = 1, IsEnabled = true };
_repository.GetAllApiKeysAsync().Returns(new List<ApiKey> { key });
var result = await _validator.ValidateAsync("wrong-key", "testMethod");
Assert.False(result.IsValid);
Assert.Equal(401, result.StatusCode);
}
[Fact]
public async Task ValidateAsync_KeyOfDifferentLength_Returns401()
{
// FixedTimeEquals over UTF-8 bytes must reject length mismatches without leaking.
var key = new ApiKey("test", "valid-key") { Id = 1, IsEnabled = true };
_repository.GetAllApiKeysAsync().Returns(new List<ApiKey> { key });
var result = await _validator.ValidateAsync("valid-key-with-extra", "testMethod");
Assert.False(result.IsValid);
Assert.Equal(401, result.StatusCode);
}
}

View File

@@ -132,4 +132,111 @@ public class InboundScriptExecutorTests
Assert.True(result.Success);
Assert.Contains("ScadaLink", result.ResultJson!);
}
// --- InboundAPI-001: concurrent lazy-compile must not corrupt the handler cache ---
[Fact]
public async Task ConcurrentLazyCompile_SameMethod_DoesNotCorruptCache()
{
// Many concurrent first-callers of an uncompiled method race the lazy-compile
// path. With an unsynchronized Dictionary this can throw or return a torn/null
// handler; all calls must succeed and produce the same result.
var method = new ApiMethod("concurrent", "return 7;") { Id = 1, TimeoutSeconds = 10 };
var tasks = Enumerable.Range(0, 64).Select(_ => Task.Run(() =>
_executor.ExecuteAsync(
method,
new Dictionary<string, object?>(),
_route,
TimeSpan.FromSeconds(10)))).ToArray();
var results = await Task.WhenAll(tasks);
Assert.All(results, r =>
{
Assert.True(r.Success, r.ErrorMessage);
Assert.Equal("7", r.ResultJson);
});
}
[Fact]
public async Task ConcurrentRegisterAndExecute_DoesNotThrow()
{
// RegisterHandler/RemoveHandler racing ExecuteAsync must not crash the process
// with an InvalidOperationException from concurrent Dictionary mutation.
var method = new ApiMethod("racy", "return 1;") { Id = 1, TimeoutSeconds = 10 };
var writers = Enumerable.Range(0, 32).Select(i => Task.Run(() =>
{
for (var n = 0; n < 50; n++)
{
_executor.RegisterHandler("racy", async ctx => { await Task.CompletedTask; return i; });
_executor.RemoveHandler("racy");
}
}));
var readers = Enumerable.Range(0, 32).Select(_ => Task.Run(async () =>
{
for (var n = 0; n < 50; n++)
{
await _executor.ExecuteAsync(
method, new Dictionary<string, object?>(), _route, TimeSpan.FromSeconds(10));
}
}));
// Should complete without an unhandled concurrency exception.
await Task.WhenAll(writers.Concat(readers));
}
// --- InboundAPI-005: compiled scripts must not bypass the script trust model ---
[Theory]
[InlineData("System.IO.File.Delete(\"/tmp/x\"); return null;")]
[InlineData("System.Diagnostics.Process.Start(\"/bin/sh\"); return null;")]
[InlineData("var t = System.Reflection.Assembly.GetExecutingAssembly(); return null;")]
[InlineData("new System.Threading.Thread(() => {}).Start(); return null;")]
[InlineData("var s = new System.Net.Sockets.Socket(System.Net.Sockets.AddressFamily.InterNetwork, System.Net.Sockets.SocketType.Stream, System.Net.Sockets.ProtocolType.Tcp); return null;")]
public void CompileAndRegister_ForbiddenApi_RejectsScript(string script)
{
var method = new ApiMethod("forbidden", script) { Id = 1, TimeoutSeconds = 10 };
var registered = _executor.CompileAndRegister(method);
Assert.False(registered);
}
[Fact]
public async Task ExecuteAsync_ForbiddenApiScript_DoesNotRunAndReturnsFailure()
{
// A fully-qualified forbidden API call must be rejected at compile/register time
// so the script never executes.
var marker = System.IO.Path.Combine(
System.IO.Path.GetTempPath(), $"scadalink-pwned-{Guid.NewGuid():N}");
System.IO.File.Delete(marker);
var method = new ApiMethod("evil", $"System.IO.File.WriteAllText(@\"{marker}\", \"x\"); return 1;")
{
Id = 1,
TimeoutSeconds = 10
};
var result = await _executor.ExecuteAsync(
method, new Dictionary<string, object?>(), _route, TimeSpan.FromSeconds(10));
Assert.False(result.Success);
Assert.False(System.IO.File.Exists(marker));
}
[Fact]
public void CompileAndRegister_PermittedScript_StillRegisters()
{
// The trust-model check must not reject legitimate scripts.
var method = new ApiMethod("ok", "var list = new List<int> { 1, 2, 3 }; return list.Sum();")
{
Id = 1,
TimeoutSeconds = 10
};
Assert.True(_executor.CompileAndRegister(method));
}
}