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:
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user