fix(inbound-api): resolve InboundAPI-009,010,011,013 — cache failed compiles, reject unknown body fields, close enumeration oracle, drop misnamed factory; InboundAPI-007,012 flagged

This commit is contained in:
Joseph Doherty
2026-05-16 22:24:03 -04:00
parent 8664cdf940
commit 858fe24add
7 changed files with 255 additions and 19 deletions

View File

@@ -13,6 +13,10 @@ public class ApiKeyValidator
{
private readonly IInboundApiRepository _repository;
// InboundAPI-011: the single message used for both "method not found" and
// "key not approved" so the two outcomes are indistinguishable to the caller.
private const string NotApprovedMessage = "API key not approved for this method";
public ApiKeyValidator(IInboundApiRepository repository)
{
_repository = repository;
@@ -45,10 +49,15 @@ public class ApiKeyValidator
return ApiKeyValidationResult.Unauthorized("Invalid or disabled API key");
}
// InboundAPI-011: "method not found" and "key not approved" must produce an
// indistinguishable response. Otherwise a caller holding any valid key could
// enumerate which method names exist by observing the status/message
// difference. Both cases return 403 with the identical message below, and the
// caller-supplied method name is never echoed back into the response.
var method = await _repository.GetMethodByNameAsync(methodName, cancellationToken);
if (method == null)
{
return ApiKeyValidationResult.NotFound($"Method '{methodName}' not found");
return ApiKeyValidationResult.Forbidden(NotApprovedMessage);
}
// Check if this key is approved for the method
@@ -57,7 +66,7 @@ public class ApiKeyValidator
if (!isApproved)
{
return ApiKeyValidationResult.Forbidden("API key not approved for this method");
return ApiKeyValidationResult.Forbidden(NotApprovedMessage);
}
return ApiKeyValidationResult.Valid(apiKey, method);
@@ -108,7 +117,4 @@ public class ApiKeyValidationResult
public static ApiKeyValidationResult Forbidden(string message) =>
new() { IsValid = false, StatusCode = 403, ErrorMessage = message };
public static ApiKeyValidationResult NotFound(string message) =>
new() { IsValid = false, StatusCode = 400, ErrorMessage = message };
}

View File

@@ -21,6 +21,13 @@ public class InboundScriptExecutor
// not safe for concurrent read/write, so a ConcurrentDictionary is used throughout.
private readonly ConcurrentDictionary<string, Func<InboundScriptContext, Task<object?>>> _scriptHandlers = new();
// InboundAPI-009: a script that fails to compile (or violates the trust model)
// is recorded here so it is compiled at most once. Without this, every subsequent
// request for a broken method re-runs the expensive Roslyn compilation — a CPU
// amplification vector since the inbound API has no rate limiting. The entry is
// cleared whenever the method is (re)compiled via CompileAndRegister.
private readonly ConcurrentDictionary<string, byte> _knownBadMethods = new();
private readonly IServiceProvider _serviceProvider;
public InboundScriptExecutor(ILogger<InboundScriptExecutor> logger, IServiceProvider serviceProvider)
@@ -50,7 +57,21 @@ public class InboundScriptExecutor
/// script is empty, fails Roslyn compilation, or violates the script trust model.
/// </summary>
public bool CompileAndRegister(ApiMethod method)
=> Compile(method) is { } handler && Register(method.Name, handler);
{
var handler = Compile(method);
if (handler == null)
{
// InboundAPI-009: record the failure so the lazy-compile path does not
// keep recompiling a broken script on every request.
_knownBadMethods[method.Name] = 0;
return false;
}
// The method definition was (re)compiled successfully — drop any stale
// failure record so a fixed script is no longer treated as bad.
_knownBadMethods.TryRemove(method.Name, out _);
return Register(method.Name, handler);
}
private bool Register(string methodName, Func<InboundScriptContext, Task<object?>> handler)
{
@@ -157,12 +178,21 @@ public class InboundScriptExecutor
if (!_scriptHandlers.TryGetValue(method.Name, out var handler))
{
// InboundAPI-009: a method already known to fail compilation must not
// be recompiled on every request — short-circuit before Roslyn runs.
if (_knownBadMethods.ContainsKey(method.Name))
return new InboundScriptResult(false, null, "Script compilation failed for this method");
// Lazy compile on first request (handles methods created after startup).
// Compile outside the cache so a failed compile is not stored, then add
// atomically so concurrent first-callers share a single handler instance.
var compiled = Compile(method);
if (compiled == null)
{
// Cache the failure so the next request short-circuits above.
_knownBadMethods[method.Name] = 0;
return new InboundScriptResult(false, null, "Script compilation failed for this method");
}
handler = _scriptHandlers.GetOrAdd(method.Name, compiled);
}

View File

@@ -61,6 +61,19 @@ public static class ParameterValidator
var result = new Dictionary<string, object?>();
var errors = new List<string>();
// InboundAPI-010: report top-level body fields that do not match any defined
// parameter, so a caller learns about a typo'd parameter name instead of
// having the field silently ignored.
var defined = new HashSet<string>(definitions.Select(d => d.Name), StringComparer.Ordinal);
var unexpected = body.Value.EnumerateObject()
.Select(p => p.Name)
.Where(name => !defined.Contains(name))
.ToList();
if (unexpected.Count > 0)
{
errors.Add($"Unexpected parameter(s): {string.Join(", ", unexpected)}");
}
foreach (var def in definitions)
{
if (body.Value.TryGetProperty(def.Name, out var prop))
@@ -89,6 +102,13 @@ public static class ParameterValidator
return ParameterValidationResult.Valid(result);
}
/// <summary>
/// Coerces a JSON element to the declared parameter type. InboundAPI-010: the
/// <c>Object</c> and <c>List</c> extended types are validated for JSON <em>shape</em>
/// only (object vs. array) — there is no field-level or element-level type
/// validation. A method script that needs a specific nested structure must
/// validate it itself; invalid nested data surfaces as a runtime script error.
/// </summary>
private static (object? value, string? error) CoerceValue(JsonElement element, string expectedType, string paramName)
{
return expectedType.ToLowerInvariant() switch