fix(validation): close Theme 3 — 11 input-validation / unbounded-input findings

Each finding is a focused validation guard or upper bound at a trust boundary.
Highlights:
- Commons-015: EncryptionMetadata ctor now validates Algorithm (AES-256-GCM
  only), Kdf (PBKDF2-SHA256 only), Iterations ([100k, 10M]), non-null Salt/IV.
- Transport-004: new BundleUnlockRateLimiter (sliding-window, per-key,
  singleton) wired into BundleImporter.LoadAsync; over-budget callers see
  BundleUnlockRateLimitedException. Per-bundle 3-strike + per-window cap.
- ESG-022: ExternalSystemClient.InvokeHttpAsync allow-lists the documented
  GET/POST/PUT/PATCH/DELETE set (case-insensitive); unknown verbs throw.
- SEL-015: SiteEventLogger queue now bounded (10k cap, DropOldest); dropped
  events fault their Task and increment FailedWriteCount so the drop is
  observable instead of an unbounded memory growth.
- SEL-017: EventLogQueryService clamps caller-supplied PageSize to a new
  MaxQueryPageSize cap (default 500) so int.MaxValue can't OOM the host.
- SEL-020: LogEventAsync rejects severities outside {Info, Warning, Error}
  (matches SQLite BINARY-collation query filter).
- InboundAPI-020: ContentType "json" check now case-insensitive
  (application/JSON no longer slips through as not-json).
- InboundAPI-024: _knownBadMethods capped at 1000 entries (drops new entries
  once full); per-request DB lookup remains the correctness path.
- SR-025: HandleSetStaticAttribute validates the attribute name against the
  deployed config; unknown names now return Success=false instead of
  leaking orphan override rows into the SQLite store.
- TE-021: MoveTemplateAsync runs the sibling-name-collision check at the
  destination, mirroring TemplateFolderService.MoveFolderAsync.
- TE-022: LockEnforcer's once-locked-stays-locked rule now also covers
  LockedInDerived (was previously only IsLocked).

New regression tests across 8 test projects (EncryptionMetadata, rate
limiter, ESG client allow-list, SEL bounded channel / PageSize clamp /
severity validation, InboundAPI ContentType + bad-methods cap, SiteRT
unknown-attribute, TemplateEngine MoveTemplate + LockedInDerived).
Build clean; affected suites all green. README regenerated: 93 open (was 104).

Note: a separate manual re-run was needed for the SiteEventLogging hunk
because its initial subagent's source edits never landed on disk despite
reporting success (file-collision-style failure mode).
This commit is contained in:
Joseph Doherty
2026-05-28 06:58:25 -04:00
parent 344379a40a
commit 819f1b4665
35 changed files with 1457 additions and 73 deletions
@@ -67,7 +67,14 @@ public static class EndpointExtensions
JsonElement? body = null;
try
{
if (httpContext.Request.ContentLength > 0 || httpContext.Request.ContentType?.Contains("json") == true)
// InboundAPI-020: the content-type sniff must be case-insensitive — a
// request with `application/JSON` or `Application/Json` is still JSON
// and must enter the body-parsing path. The previous case-sensitive
// `Contains("json")` silently skipped JSON deserialization for any
// capitalised value, leaving `body = null` and surfacing required
// parameters as 400 "missing" even though the caller sent a valid body.
if (httpContext.Request.ContentLength > 0
|| httpContext.Request.ContentType?.Contains("json", StringComparison.OrdinalIgnoreCase) == true)
{
using var doc = await JsonDocument.ParseAsync(
httpContext.Request.Body, cancellationToken: httpContext.RequestAborted);
@@ -27,8 +27,38 @@ public class InboundScriptExecutor
// 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.
//
// InboundAPI-024: bound the cache so a spam attack of unique method names cannot
// grow it without bound. Once the cap is reached new bad-method records are
// dropped — the cache is just a fast-fail optimisation; the per-request DB
// lookup remains the correctness path.
private const int KnownBadMethodsCap = 1000;
private readonly ConcurrentDictionary<string, byte> _knownBadMethods = new();
/// <summary>
/// InboundAPI-024 diagnostic helper — returns the current size of the
/// known-bad-methods cache so tests can assert the cap is honoured. Internal
/// so the cache itself stays an implementation detail.
/// </summary>
internal int KnownBadMethodCount => _knownBadMethods.Count;
/// <summary>
/// InboundAPI-024: records <paramref name="methodName"/> in the known-bad-methods
/// cache only if the cache has not reached <see cref="KnownBadMethodsCap"/>.
/// Once full, new records are dropped (paying the cheap recompile next time
/// rather than leaking memory under a unique-name flood). Existing entries are
/// not touched — they remain capped fast-fail records until cleared on a
/// successful (re)compile in <see cref="CompileAndRegister"/>.
/// </summary>
private void TryRecordBadMethod(string methodName)
{
if (_knownBadMethods.ContainsKey(methodName))
return;
if (_knownBadMethods.Count >= KnownBadMethodsCap)
return;
_knownBadMethods.TryAdd(methodName, 0);
}
private readonly IServiceProvider _serviceProvider;
/// <summary>
@@ -73,8 +103,10 @@ public class InboundScriptExecutor
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;
// keep recompiling a broken script on every request. InboundAPI-024:
// routed through the capped TryRecordBadMethod helper so the cache
// cannot grow without bound under a flood of unique method names.
TryRecordBadMethod(method.Name);
return false;
}
@@ -230,7 +262,9 @@ public class InboundScriptExecutor
if (compiled == null)
{
// Cache the failure so the next request short-circuits above.
_knownBadMethods[method.Name] = 0;
// InboundAPI-024: routed through TryRecordBadMethod so the
// cache is bounded under a flood of unique method names.
TryRecordBadMethod(method.Name);
return new InboundScriptResult(false, null, "Script compilation failed for this method");
}
handler = _scriptHandlers.GetOrAdd(method.Name, compiled);