From 3e00d9c13b3b6473489691cded660a59033309c0 Mon Sep 17 00:00:00 2001 From: "Rian.be" Date: Fri, 30 Oct 2026 19:23:41 +0100 Subject: [PATCH 01/13] feat(shield): resolve clients and model attacks --- .../Shield/Detection/ClientIdResolver.cs | 27 ++++++++ .../Shield/Detection/IAttackDetector.cs | 62 +++++++++++++++++++ 2 files changed, 89 insertions(+) create mode 100644 src/Plugins/Solutions/Shield/Detection/ClientIdResolver.cs create mode 100644 src/Plugins/Solutions/Shield/Detection/IAttackDetector.cs diff --git a/src/Plugins/Solutions/Shield/Detection/ClientIdResolver.cs b/src/Plugins/Solutions/Shield/Detection/ClientIdResolver.cs new file mode 100644 index 0000000..a859052 --- /dev/null +++ b/src/Plugins/Solutions/Shield/Detection/ClientIdResolver.cs @@ -0,0 +1,27 @@ +using Microsoft.AspNetCore.Http; + +namespace Shield.Detection; + +/// +/// Resolves the client identifier used for rate counting, lists, and incidents: +/// the leftmost X-Forwarded-For entry when trusted, otherwise the +/// transport address. +/// +public static class ClientIdResolver +{ + /// + /// Resolves the client id for the request. Never returns null or empty. + /// + public static string Resolve(HttpContext context, bool trustForwardedHeader) + { + if (trustForwardedHeader + && context.Request.Headers.TryGetValue("X-Forwarded-For", out var forwarded)) + { + var first = forwarded.ToString().Split(',', 2)[0].Trim(); + if (!string.IsNullOrEmpty(first)) + return first; + } + + return context.Connection.RemoteIpAddress?.ToString() ?? "unknown"; + } +} diff --git a/src/Plugins/Solutions/Shield/Detection/IAttackDetector.cs b/src/Plugins/Solutions/Shield/Detection/IAttackDetector.cs new file mode 100644 index 0000000..16dad28 --- /dev/null +++ b/src/Plugins/Solutions/Shield/Detection/IAttackDetector.cs @@ -0,0 +1,62 @@ +namespace Shield.Detection; + +/// +/// Normalized input for attack detectors: who is calling, what is targeted, +/// and when. +/// +public sealed record ShieldRequest( + string ClientId, + string Path, + string Query, + DateTimeOffset Timestamp, + string UserAgent = "", + long? ContentLength = null); + +/// +/// What the host should do with a tripped signal. +/// +public enum ShieldAction +{ + /// + /// Block all traffic for the configured duration. + /// + Lockdown, + + /// + /// Block only the offending request, without a global lockdown. + /// + BlockRequest, +} + +/// +/// A detector verdict. Non-null means the detector tripped and the caller +/// should apply with . +/// +public sealed record AttackSignal(string Sensor, string Reason, ShieldAction Action = ShieldAction.Lockdown); + +/// +/// One attack-detection strategy. Implementations are pure policy: per-client +/// storage lives in so HTTP and gRPC share it. +/// Detectors run in DI registration order; the first signal wins. +/// +public interface IAttackDetector +{ + /// + /// Short sensor name used in incidents and diagnostics (e.g. "rate-flood"). + /// + string Sensor { get; } + + /// + /// Evaluates the request. Returns a signal when the request trips the + /// detector, otherwise null. + /// + AttackSignal? Evaluate(ShieldRequest request, Options.ShieldOptions options); + + /// + /// Observes the completed response (e.g. 401/403 counting). Returns a signal + /// when the observation trips the detector; the lockdown then applies to + /// subsequent requests. Only signals are + /// honored here — the response is already sent. Default ignores responses. + /// + AttackSignal? ObserveResponse(ShieldRequest request, int statusCode, Options.ShieldOptions options) => null; +} From b57e3e7ad1ce1eecd03e91d36c0a23dc6c512a0b Mon Sep 17 00:00:00 2001 From: "Rian.be" Date: Fri, 30 Oct 2026 19:23:46 +0100 Subject: [PATCH 02/13] feat(shield): detect floods and attack patterns --- .../Sensors/AttackSignatureDetector.cs | 53 +++++++++++++++++++ .../Sensors/AuthBruteForceDetector.cs | 37 +++++++++++++ .../Sensors/EndpointScannerDetector.cs | 37 +++++++++++++ .../Sensors/OversizedRequestDetector.cs | 34 ++++++++++++ .../Detection/Sensors/RateFloodDetector.cs | 26 +++++++++ .../Sensors/SuspiciousUserAgentDetector.cs | 41 ++++++++++++++ 6 files changed, 228 insertions(+) create mode 100644 src/Plugins/Solutions/Shield/Detection/Sensors/AttackSignatureDetector.cs create mode 100644 src/Plugins/Solutions/Shield/Detection/Sensors/AuthBruteForceDetector.cs create mode 100644 src/Plugins/Solutions/Shield/Detection/Sensors/EndpointScannerDetector.cs create mode 100644 src/Plugins/Solutions/Shield/Detection/Sensors/OversizedRequestDetector.cs create mode 100644 src/Plugins/Solutions/Shield/Detection/Sensors/RateFloodDetector.cs create mode 100644 src/Plugins/Solutions/Shield/Detection/Sensors/SuspiciousUserAgentDetector.cs diff --git a/src/Plugins/Solutions/Shield/Detection/Sensors/AttackSignatureDetector.cs b/src/Plugins/Solutions/Shield/Detection/Sensors/AttackSignatureDetector.cs new file mode 100644 index 0000000..82e3a8d --- /dev/null +++ b/src/Plugins/Solutions/Shield/Detection/Sensors/AttackSignatureDetector.cs @@ -0,0 +1,53 @@ +using Shield.State; + +namespace Shield.Detection.Sensors; + +/// +/// Trips when path or query contains known attack fragments (traversal, +/// SQL injection, XSS, suspicious system paths). Matching is case-insensitive +/// substring search over the raw (still-encoded) target. +/// +public sealed class AttackSignatureDetector(IShieldState state) : IAttackDetector +{ + // Lowercase fragments matched against path + query. + private static readonly string[] Signatures = + [ + "../", "..%2f", "..%5c", "%2e%2e", + "union select", "union+select", + " + int RecordRequest(string clientId, DateTimeOffset now, int windowSeconds, int maxTrackedClients); + + /// + /// Records an attack-signature hit from and + /// returns the client's hit count inside the configured window. + /// + int RecordViolation(string clientId, DateTimeOffset now, int windowSeconds, int maxTrackedClients); + + /// + /// Records a failed authentication (401/403) from + /// and returns the client's count inside the sliding window. + /// + int RecordAuthFailure(string clientId, DateTimeOffset now, int windowSeconds, int maxTrackedClients); + + /// + /// Records a probe (404) from and returns the + /// client's count inside the sliding window. + /// + int RecordProbe(string clientId, DateTimeOffset now, int windowSeconds, int maxTrackedClients); + + /// + /// Records a standalone incident without touching the lockdown state. + /// + void RecordIncident(string sensor, string reason, string action, int maxIncidents); + + /// + /// Increments the evaluated-requests metric. + /// + void CountEvaluation(); + + /// + /// Increments the blocked-requests metric. + /// + void CountBlock(); + + /// + /// Returns up to most recent incidents, newest first. + /// + IReadOnlyList GetIncidents(int take); + + /// + /// Subscribes to new incidents for live feeds. Dispose to unsubscribe. + /// Handlers run under the log lock: keep them fast and non-blocking. + /// + IDisposable SubscribeIncidents(Action handler); + + /// + /// Captures the current state for status endpoints and health checks. + /// Best-effort point-in-time view across independently locked parts. + /// + ShieldSnapshot GetSnapshot(); +} diff --git a/src/Plugins/Solutions/Shield/State/ShieldModels.cs b/src/Plugins/Solutions/Shield/State/ShieldModels.cs new file mode 100644 index 0000000..dc58d25 --- /dev/null +++ b/src/Plugins/Solutions/Shield/State/ShieldModels.cs @@ -0,0 +1,24 @@ +namespace Shield.State; + +/// +/// One recorded security event: lockdown entered/released. +/// +public sealed record ShieldIncident( + DateTimeOffset Timestamp, + string Sensor, + string Reason, + string Action); + +/// +/// Point-in-time view of the Shield lockdown state for status and health reporting. +/// +public sealed record ShieldSnapshot( + bool IsLocked, + DateTimeOffset? LockedUntil, + string? Reason, + int TrackedClients, + long TotalEvaluated, + long TotalBlocked, + long TotalTrips, + long TotalAuthFailures, + long TotalProbes); diff --git a/src/Plugins/Solutions/Shield/State/ShieldState.cs b/src/Plugins/Solutions/Shield/State/ShieldState.cs new file mode 100644 index 0000000..5b64749 --- /dev/null +++ b/src/Plugins/Solutions/Shield/State/ShieldState.cs @@ -0,0 +1,163 @@ +namespace Shield.State; + +using Shield.State.Counters; +using Shield.State.Incidents; +using Shield.State.Lockdown; +using Shield.State.Metrics; + +/// +/// Facade over the Shield state parts: lockdown gate, per-client counters, +/// metrics, and incident log. Each part owns its own synchronization; the +/// facade itself holds no locks. +/// +public sealed class ShieldState : IShieldState +{ + private const string RequestBucket = "requests"; + private const string ViolationBucket = "violations"; + private const string AuthFailureBucket = "auth-failures"; + private const string ProbeBucket = "probes"; + + private readonly Lock _eventsSync = new(); + private readonly Dictionary _events = new(StringComparer.Ordinal); + private readonly LockdownGate _lockdown = new(); + private readonly IncidentLog _incidents = new(); + private readonly ShieldMetrics _metrics = new(); + + /// + /// + /// Asks the lockdown gate whether a lockdown is active. + /// + public bool IsLockdownActive(out DateTimeOffset lockedUntil, out string? reason) => + _lockdown.TryGetActive(out lockedUntil, out reason); + + /// + /// + /// Enters the lockdown gate and records the trip. + /// + public bool TryEnterLockdown(string sensor, string reason, TimeSpan duration, int maxIncidents) + { + if (!_lockdown.TryEnter(reason, duration)) + return false; + + _metrics.CountTrip(); + RecordIncident(sensor, reason, "lockdown-entered", maxIncidents); + return true; + } + + /// + /// + /// Releases the lockdown gate and records the release. + /// + public void ReleaseLockdown(int maxIncidents) + { + if (!_lockdown.Release()) + return; + + RecordIncident("operator", "manual release", "lockdown-released", maxIncidents); + } + + /// + /// + /// Appends to the incident log. + /// + public void RecordIncident(string sensor, string reason, string action, int maxIncidents) => + _incidents.Add(new ShieldIncident(DateTimeOffset.UtcNow, sensor, reason, action), maxIncidents); + + /// + /// + /// Counts the request in the request window. + /// + public int RecordRequest(string clientId, DateTimeOffset now, int windowSeconds, int maxTrackedClients) => + Event(RequestBucket).Record(clientId, now, windowSeconds, maxTrackedClients); + + /// + /// + /// Counts the hit in the violation counter. + /// + public int RecordViolation(string clientId, DateTimeOffset now, int windowSeconds, int maxTrackedClients) => + Event(ViolationBucket).Record(clientId, now, windowSeconds, maxTrackedClients); + + /// + /// + /// Counts the failure in the auth-failure window. + /// + public int RecordAuthFailure(string clientId, DateTimeOffset now, int windowSeconds, int maxTrackedClients) + { + _metrics.CountAuthFailure(); + return Event(AuthFailureBucket).Record(clientId, now, windowSeconds, maxTrackedClients); + } + + /// + /// + /// Counts the probe in the probe window. + /// + public int RecordProbe(string clientId, DateTimeOffset now, int windowSeconds, int maxTrackedClients) + { + _metrics.CountProbe(); + return Event(ProbeBucket).Record(clientId, now, windowSeconds, maxTrackedClients); + } + + /// + /// + /// Bumps the evaluated-requests metric. + /// + public void CountEvaluation() => _metrics.CountEvaluation(); + + /// + /// + /// Bumps the blocked-requests metric. + /// + public void CountBlock() => _metrics.CountBlock(); + + /// + /// + /// Reads the incident log. + /// + public IReadOnlyList GetIncidents(int take) => _incidents.Take(take); + + /// + /// + /// Subscribes to the incident log. + /// + public IDisposable SubscribeIncidents(Action handler) => _incidents.Subscribe(handler); + + /// + /// + /// Composes a snapshot from the state parts. + /// + public ShieldSnapshot GetSnapshot() + { + var locked = _lockdown.TryGetActive(out var lockedUntil, out var reason); + var (evaluated, blocked, trips, authFailures, probes) = _metrics.Read(); + return new ShieldSnapshot( + locked, + locked ? lockedUntil : null, + locked ? reason : null, + EventClientCount(RequestBucket), + evaluated, + blocked, + trips, + authFailures, + probes); + } + + private SlidingWindowCounter Event(string bucket) + { + lock (_eventsSync) + { + if (!_events.TryGetValue(bucket, out var counter)) + { + counter = new SlidingWindowCounter(); + _events[bucket] = counter; + } + + return counter; + } + } + + private int EventClientCount(string bucket) + { + lock (_eventsSync) + return _events.TryGetValue(bucket, out var counter) ? counter.ClientCount : 0; + } +} diff --git a/src/Plugins/Solutions/Shield/State/ShieldStatus.cs b/src/Plugins/Solutions/Shield/State/ShieldStatus.cs new file mode 100644 index 0000000..ead24ea --- /dev/null +++ b/src/Plugins/Solutions/Shield/State/ShieldStatus.cs @@ -0,0 +1,15 @@ +using Shield.Options; + +namespace Shield.State; + +/// Combines the runtime gate with the configured manual kill-switch. +public static class ShieldStatus +{ + public static ShieldSnapshot GetSnapshot(IShieldState state, ShieldOptions options) + { + var snapshot = state.GetSnapshot(); + return options.ForceLockdown + ? snapshot with { IsLocked = true, LockedUntil = null, Reason = "manual kill-switch" } + : snapshot; + } +} From b4621c906705cd8fbda284bb378e1af61b47ff93 Mon Sep 17 00:00:00 2001 From: "Rian.be" Date: Fri, 30 Oct 2026 19:39:02 +0100 Subject: [PATCH 07/13] feat(shield): register plugin and metadata --- src/Plugins/Solutions/Shield/Shield.csproj | 31 ++++++++++ src/Plugins/Solutions/Shield/ShieldPlugin.cs | 65 ++++++++++++++++++++ src/Plugins/Solutions/Shield/manifest.json | 23 +++++++ 3 files changed, 119 insertions(+) create mode 100644 src/Plugins/Solutions/Shield/Shield.csproj create mode 100644 src/Plugins/Solutions/Shield/ShieldPlugin.cs create mode 100644 src/Plugins/Solutions/Shield/manifest.json diff --git a/src/Plugins/Solutions/Shield/Shield.csproj b/src/Plugins/Solutions/Shield/Shield.csproj new file mode 100644 index 0000000..1b3fd22 --- /dev/null +++ b/src/Plugins/Solutions/Shield/Shield.csproj @@ -0,0 +1,31 @@ + + + net10.0 + preview + enable + enable + true + + + + + + + + + + all + + + + + + + + PreserveNewest + + + + + + diff --git a/src/Plugins/Solutions/Shield/ShieldPlugin.cs b/src/Plugins/Solutions/Shield/ShieldPlugin.cs new file mode 100644 index 0000000..e5b0add --- /dev/null +++ b/src/Plugins/Solutions/Shield/ShieldPlugin.cs @@ -0,0 +1,65 @@ +using AuthKit.Plugins.Abstractions; +using AuthKit.Plugins.Abstractions.Contracts; +using AuthKit.Plugins.Abstractions.Contracts.Plugins; +using AuthKit.Plugins.Abstractions.Models; +using AuthKit.Plugins.Abstractions.Pipeline; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Routing; +using Microsoft.Extensions.DependencyInjection; +using Shield.Composition; +using Shield.Endpoints; +using Shield.Grpc; +using Shield.Health; +using IAuthKitPlugin = AuthKit.Plugins.Abstractions.Contracts.PluginContract.IAuthKitPlugin; + +namespace Shield; + +/// +/// Critical-security plugin: detects attack traffic through ordered detector +/// strategies and blocks all HTTP and gRPC traffic for a configurable duration +/// once tripped. Thin composition root — services, pipeline declarations, +/// endpoints, and health each live in their own unit. +/// +[PluginMetadata( + id: "authkit.shield", + version: "1.0.0", + tags: ["security", "critical"], + dependsOn: [], + capabilities: ["security"], + name: "Shield", + displayName: "Shield", + description: "Attack detection with full-traffic lockdown for HTTP and gRPC.", + author: "AuthKit Contributors", + license: "MIT", + licenseUrl: "https://opensource.org/licenses/MIT", + homepage: "https://example.org/shield", + repositoryUrl: "https://example.org/shield.git" +)] +public sealed class ShieldPlugin : IAuthKitPlugin +{ + /// + /// Registers Shield options, state, and detectors. + /// + public void ConfigureServices(IServiceCollection services, AuthKitPluginContext context) => + services.AddShield(context.Configuration); + + /// + /// Declares the HTTP kill-switch and the gRPC interceptor. + /// + public IReadOnlyList Middlewares => Pipeline.ShieldPipeline.Declarations; + + /// + /// Maps the operator HTTP and gRPC endpoints. + /// + public void MapEndpoints(IEndpointRouteBuilder endpoints) + { + ShieldEndpoints.Map(endpoints); + endpoints.MapGrpcService(); + } + + /// + /// Reports unhealthy while a lockdown is active. + /// + public Task> CheckHealthAsync(IServiceProvider services, CancellationToken cancellationToken = default) => + Task.FromResult(ShieldHealth.Report(services, cancellationToken)); +} diff --git a/src/Plugins/Solutions/Shield/manifest.json b/src/Plugins/Solutions/Shield/manifest.json new file mode 100644 index 0000000..7855be3 --- /dev/null +++ b/src/Plugins/Solutions/Shield/manifest.json @@ -0,0 +1,23 @@ +{ + "Id": "authkit.shield", + "Name": "Shield", + "DisplayName": "Shield", + "Description": "Attack detection with full-traffic lockdown for HTTP and gRPC.", + "Version": "1.0.0", + "Author": "AuthKit Contributors", + "License": "MIT", + "LicenseUrl": "https://opensource.org/licenses/MIT", + "Homepage": "https://example.org/shield", + "RepositoryUrl": "https://example.org/shield.git", + "Tags": [ + "security", + "critical" + ], + "Priority": 0, + "IsEnabled": true, + "Capabilities": [ + "security" + ], + "MinHostVersion": null, + "DependsOn": [] +} From c7469cfab0bfd6c9426c8a25696c2bdbffcdae29 Mon Sep 17 00:00:00 2001 From: "Rian.be" Date: Fri, 30 Oct 2026 19:41:16 +0100 Subject: [PATCH 08/13] feat(shield): bound counters and retain incidents Evict expired or oldest client buckets at capacity to keep in-memory attack tracking bounded under high-cardinality traffic. --- .../State/Counters/SlidingWindowCounter.cs | 61 +++++++++++++++++ .../Shield/State/Incidents/IncidentLog.cs | 66 +++++++++++++++++++ 2 files changed, 127 insertions(+) create mode 100644 src/Plugins/Solutions/Shield/State/Counters/SlidingWindowCounter.cs create mode 100644 src/Plugins/Solutions/Shield/State/Incidents/IncidentLog.cs diff --git a/src/Plugins/Solutions/Shield/State/Counters/SlidingWindowCounter.cs b/src/Plugins/Solutions/Shield/State/Counters/SlidingWindowCounter.cs new file mode 100644 index 0000000..9b21a6b --- /dev/null +++ b/src/Plugins/Solutions/Shield/State/Counters/SlidingWindowCounter.cs @@ -0,0 +1,61 @@ +namespace Shield.State.Counters; + +/// +/// Per-client sliding-window event counter (request rate, auth failures). +/// Thread-safe. +/// +internal sealed class SlidingWindowCounter +{ + private readonly Lock _sync = new(); + private readonly Dictionary> _buckets = new(StringComparer.Ordinal); + + /// + /// Records an event and returns the client's count inside the window. + /// + public int Record(string clientId, DateTimeOffset now, int windowSeconds, int maxTrackedClients) + { + lock (_sync) + { + var cutoff = now.AddSeconds(-windowSeconds); + if (!_buckets.ContainsKey(clientId) && _buckets.Count >= maxTrackedClients) + { + foreach (var (id, events) in _buckets.ToArray()) + { + while (events.Count > 0 && events.Peek() < cutoff) + events.Dequeue(); + if (events.Count == 0) + _buckets.Remove(id); + } + + while (_buckets.Count >= maxTrackedClients) + { + var oldest = _buckets.MinBy(entry => entry.Value.Peek().UtcTicks).Key; + _buckets.Remove(oldest); + } + } + + if (!_buckets.TryGetValue(clientId, out var timestamps)) + { + timestamps = new Queue(); + _buckets[clientId] = timestamps; + } + + while (timestamps.Count > 0 && timestamps.Peek() < cutoff) + timestamps.Dequeue(); + timestamps.Enqueue(now); + return timestamps.Count; + } + } + + /// + /// Number of retained clients (including entries not yet pruned after expiry). + /// + public int ClientCount + { + get + { + lock (_sync) + return _buckets.Count; + } + } +} diff --git a/src/Plugins/Solutions/Shield/State/Incidents/IncidentLog.cs b/src/Plugins/Solutions/Shield/State/Incidents/IncidentLog.cs new file mode 100644 index 0000000..32bd810 --- /dev/null +++ b/src/Plugins/Solutions/Shield/State/Incidents/IncidentLog.cs @@ -0,0 +1,66 @@ +namespace Shield.State.Incidents; + +/// +/// Bounded incident ring buffer, newest first. Thread-safe. +/// +public sealed class IncidentLog +{ + private readonly Lock _sync = new(); + private readonly LinkedList _incidents = new(); + + /// + /// Fired (under lock, keep handlers fast and non-blocking) for every + /// added incident. Powers live feeds like gRPC streaming. + /// + public event Action? Added; + + /// + /// Prepends an incident, trimming to . + /// + public void Add(ShieldIncident incident, int maxIncidents) + { + lock (_sync) + { + _incidents.AddFirst(incident); + while (_incidents.Count > Math.Max(1, maxIncidents)) + _incidents.RemoveLast(); + + Added?.Invoke(incident); + } + } + + /// + /// Subscribes to new incidents. Dispose to unsubscribe. + /// + public IDisposable Subscribe(Action handler) + { + lock (_sync) + Added += handler; + + return new Subscription(this, handler); + } + + /// + /// Returns up to incidents, newest first. + /// + public IReadOnlyList Take(int take) + { + lock (_sync) + return _incidents.Take(Math.Max(1, take)).ToList(); + } + + private sealed class Subscription(IncidentLog log, Action handler) : IDisposable + { + private bool _disposed; + + public void Dispose() + { + if (_disposed) + return; + + _disposed = true; + lock (log._sync) + log.Added -= handler; + } + } +} From 014e4a1c74b234dfb367a0d15c086fe867ee225f Mon Sep 17 00:00:00 2001 From: "Rian.be" Date: Fri, 30 Oct 2026 19:45:06 +0100 Subject: [PATCH 09/13] feat(shield): manage lockdown and metrics --- .../Shield/State/Lockdown/LockdownGate.cs | 65 +++++++++++++++++++ .../Shield/State/Metrics/ShieldMetrics.cs | 26 ++++++++ 2 files changed, 91 insertions(+) create mode 100644 src/Plugins/Solutions/Shield/State/Lockdown/LockdownGate.cs create mode 100644 src/Plugins/Solutions/Shield/State/Metrics/ShieldMetrics.cs diff --git a/src/Plugins/Solutions/Shield/State/Lockdown/LockdownGate.cs b/src/Plugins/Solutions/Shield/State/Lockdown/LockdownGate.cs new file mode 100644 index 0000000..efcf171 --- /dev/null +++ b/src/Plugins/Solutions/Shield/State/Lockdown/LockdownGate.cs @@ -0,0 +1,65 @@ +namespace Shield.State.Lockdown; + +/// +/// The lockdown state machine: expiry, reason, trip counting. Thread-safe. +/// +internal sealed class LockdownGate +{ + private readonly Lock _sync = new(); + private DateTimeOffset? _lockedUntil; + private string? _reason; + + /// + /// Returns true while a lockdown is active. Expired lockdowns are cleared lazily. + /// + public bool TryGetActive(out DateTimeOffset lockedUntil, out string? reason) + { + lock (_sync) + { + if (_lockedUntil is { } until && until > DateTimeOffset.UtcNow) + { + lockedUntil = until; + reason = _reason; + return true; + } + + _lockedUntil = null; + _reason = null; + lockedUntil = default; + reason = null; + return false; + } + } + + /// + /// Enters lockdown. Returns false when one is already active. + /// + public bool TryEnter(string reason, TimeSpan duration) + { + lock (_sync) + { + if (_lockedUntil is { } until && until > DateTimeOffset.UtcNow) + return false; + + _lockedUntil = DateTimeOffset.UtcNow.Add(duration); + _reason = reason; + return true; + } + } + + /// + /// Clears the lockdown. Returns false when none was active. + /// + public bool Release() + { + lock (_sync) + { + if (_lockedUntil is null) + return false; + + _lockedUntil = null; + _reason = null; + return true; + } + } +} diff --git a/src/Plugins/Solutions/Shield/State/Metrics/ShieldMetrics.cs b/src/Plugins/Solutions/Shield/State/Metrics/ShieldMetrics.cs new file mode 100644 index 0000000..f67d88a --- /dev/null +++ b/src/Plugins/Solutions/Shield/State/Metrics/ShieldMetrics.cs @@ -0,0 +1,26 @@ +namespace Shield.State.Metrics; + +/// +/// Monotonic plugin metrics. Lock-free via . +/// +internal sealed class ShieldMetrics +{ + private long _evaluated; + private long _blocked; + private long _trips; + private long _authFailures; + private long _probes; + + public void CountEvaluation() => Interlocked.Increment(ref _evaluated); + public void CountBlock() => Interlocked.Increment(ref _blocked); + public void CountTrip() => Interlocked.Increment(ref _trips); + public void CountAuthFailure() => Interlocked.Increment(ref _authFailures); + public void CountProbe() => Interlocked.Increment(ref _probes); + + public (long Evaluated, long Blocked, long Trips, long AuthFailures, long Probes) Read() => ( + Interlocked.Read(ref _evaluated), + Interlocked.Read(ref _blocked), + Interlocked.Read(ref _trips), + Interlocked.Read(ref _authFailures), + Interlocked.Read(ref _probes)); +} From ae921a1052efd2b3d87bac2296dbef7ca46df6d9 Mon Sep 17 00:00:00 2001 From: "Rian.be" Date: Fri, 30 Oct 2026 19:47:54 +0100 Subject: [PATCH 10/13] feat(shield): enforce gRPC lockdown Fail gRPC calls while lockdown is active and keep the operator service reachable. Require the operator token for status and incident methods. --- .../Shield/Grpc/ShieldInterceptor.cs | 67 ++++++++++ .../Shield/Grpc/ShieldStatusService.cs | 115 ++++++++++++++++++ .../Solutions/Shield/Grpc/protos/shield.proto | 60 +++++++++ 3 files changed, 242 insertions(+) create mode 100644 src/Plugins/Solutions/Shield/Grpc/ShieldInterceptor.cs create mode 100644 src/Plugins/Solutions/Shield/Grpc/ShieldStatusService.cs create mode 100644 src/Plugins/Solutions/Shield/Grpc/protos/shield.proto diff --git a/src/Plugins/Solutions/Shield/Grpc/ShieldInterceptor.cs b/src/Plugins/Solutions/Shield/Grpc/ShieldInterceptor.cs new file mode 100644 index 0000000..dfcd609 --- /dev/null +++ b/src/Plugins/Solutions/Shield/Grpc/ShieldInterceptor.cs @@ -0,0 +1,67 @@ +using Grpc.Core; +using Grpc.Core.Interceptors; +using Microsoft.Extensions.Options; +using Shield.Options; +using Shield.State; + +namespace Shield.Grpc; + +/// +/// gRPC counterpart of the HTTP kill-switch: fails every call with +/// Unavailable while a lockdown is active or forced. +/// The plugin's own operator service (ShieldService) is exempt, mirroring +/// the HTTP bypass paths, so status stays observable during a lockdown. +/// +public sealed class ShieldInterceptor(IShieldState state, IOptions options) : Interceptor +{ + private const string OperatorServicePrefix = "/authkit.shield.ShieldService/"; + + private void ThrowIfLocked(ServerCallContext context) + { + if (context.Method.StartsWith(OperatorServicePrefix, StringComparison.Ordinal)) + return; + if (options.Value.ForceLockdown) + throw new RpcException(new Status(StatusCode.Unavailable, "Traffic blocked by Shield (manual kill-switch).")); + + if (state.IsLockdownActive(out _, out var reason)) + throw new RpcException(new Status(StatusCode.Unavailable, $"Traffic blocked by Shield ({reason ?? "lockdown"}).")); + } + + public override Task UnaryServerHandler( + TRequest request, + ServerCallContext context, + UnaryServerMethod continuation) + { + ThrowIfLocked(context); + return continuation(request, context); + } + + public override Task ClientStreamingServerHandler( + IAsyncStreamReader requestStream, + ServerCallContext context, + ClientStreamingServerMethod continuation) + { + ThrowIfLocked(context); + return continuation(requestStream, context); + } + + public override Task ServerStreamingServerHandler( + TRequest request, + IServerStreamWriter responseStream, + ServerCallContext context, + ServerStreamingServerMethod continuation) + { + ThrowIfLocked(context); + return continuation(request, responseStream, context); + } + + public override Task DuplexStreamingServerHandler( + IAsyncStreamReader requestStream, + IServerStreamWriter responseStream, + ServerCallContext context, + DuplexStreamingServerMethod continuation) + { + ThrowIfLocked(context); + return continuation(requestStream, responseStream, context); + } +} diff --git a/src/Plugins/Solutions/Shield/Grpc/ShieldStatusService.cs b/src/Plugins/Solutions/Shield/Grpc/ShieldStatusService.cs new file mode 100644 index 0000000..e525262 --- /dev/null +++ b/src/Plugins/Solutions/Shield/Grpc/ShieldStatusService.cs @@ -0,0 +1,115 @@ +using Grpc.Core; +using Microsoft.Extensions.Options; +using Shield.Options; +using Shield.Security; +using Shield.State; + +namespace Shield.Grpc; + +/// +/// gRPC operator surface for Shield: status and incidents. Served by the host +/// like any plugin-owned gRPC service, so it shows up in the service catalog +/// and grpc-ui. +/// +public sealed class ShieldStatusService(IShieldState state, IOptions options) : ShieldService.ShieldServiceBase +{ + /// + /// Returns the current lockdown state and metrics. + /// + public override Task GetStatus(GetStatusRequest request, ServerCallContext context) + { + AuthorizeRead(context); + var snapshot = State.ShieldStatus.GetSnapshot(state, options.Value); + return Task.FromResult(new ShieldStatus + { + Locked = snapshot.IsLocked, + LockedUntil = snapshot.LockedUntil?.ToString("O") ?? string.Empty, + Reason = snapshot.Reason ?? string.Empty, + TrackedClients = snapshot.TrackedClients, + Evaluated = snapshot.TotalEvaluated, + Blocked = snapshot.TotalBlocked, + Trips = snapshot.TotalTrips, + AuthFailures = snapshot.TotalAuthFailures, + Probes = snapshot.TotalProbes, + }); + } + + /// + /// Returns recent incidents, newest first. + /// + public override Task ListIncidents(ListIncidentsRequest request, ServerCallContext context) + { + AuthorizeRead(context); + var take = request.Take is > 0 ? request.Take : options.Value.MaxIncidents; + var response = new ListIncidentsResponse(); + response.Incidents.AddRange(state.GetIncidents(take).Select(Map)); + return Task.FromResult(response); + } + + /// + /// Streams the incident backlog, then live incidents until cancelled. + /// + public override async Task WatchIncidents( + WatchIncidentsRequest request, + IServerStreamWriter responseStream, + ServerCallContext context) + { + AuthorizeRead(context); + // Subscribe first so nothing is missed, then replay the backlog. + // An incident landing in between may be delivered twice; consumers + // dedupe by timestamp + sensor + reason. + var pending = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + using var subscription = state.SubscribeIncidents(incident => pending.TrySetResult(incident)); + + var take = request.Take is > 0 ? request.Take : options.Value.MaxIncidents; + foreach (var incident in state.GetIncidents(take).Reverse()) + await responseStream.WriteAsync(Map(incident)); + + while (!context.CancellationToken.IsCancellationRequested) + { + var incident = await pending.Task.WaitAsync(context.CancellationToken); + pending = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + await responseStream.WriteAsync(Map(incident)); + } + } + + /// + /// Unlocks an active lockdown on a valid release token. + /// + public override Task ReleaseLockdown(ReleaseLockdownRequest request, ServerCallContext context) + { + var config = options.Value; + if (!ReleaseGuard.IsEnabled(config)) + throw new RpcException(new Status(StatusCode.NotFound, "Unknown method.")); + + var token = context.RequestHeaders.FirstOrDefault(entry => + string.Equals(entry.Key, ReleaseGuard.MetadataKey, StringComparison.OrdinalIgnoreCase))?.Value; + if (!ReleaseGuard.IsAuthorized(config, token)) + throw new RpcException(new Status(StatusCode.PermissionDenied, "Invalid release token.")); + + if (config.ForceLockdown) + throw new RpcException(new Status(StatusCode.FailedPrecondition, "Clear ForceLockdown in configuration before releasing lockdown.")); + + state.ReleaseLockdown(config.MaxIncidents); + return Task.FromResult(new ReleaseLockdownResponse { Released = true }); + } + + private static ShieldIncident Map(State.ShieldIncident incident) => new() + { + Timestamp = incident.Timestamp.ToString("O"), + Sensor = incident.Sensor, + Reason = incident.Reason, + Action = incident.Action, + }; + + private void AuthorizeRead(ServerCallContext context) + { + if (!ReleaseGuard.IsOperatorEnabled(options.Value)) + throw new RpcException(new Status(StatusCode.NotFound, "Unknown method.")); + + var token = context.RequestHeaders.FirstOrDefault(entry => + string.Equals(entry.Key, ReleaseGuard.MetadataKey, StringComparison.OrdinalIgnoreCase))?.Value; + if (!ReleaseGuard.IsOperatorAuthorized(options.Value, token)) + throw new RpcException(new Status(StatusCode.PermissionDenied, "Invalid operator token.")); + } +} diff --git a/src/Plugins/Solutions/Shield/Grpc/protos/shield.proto b/src/Plugins/Solutions/Shield/Grpc/protos/shield.proto new file mode 100644 index 0000000..962d1bc --- /dev/null +++ b/src/Plugins/Solutions/Shield/Grpc/protos/shield.proto @@ -0,0 +1,60 @@ +syntax = "proto3"; + +option csharp_namespace = "Shield"; + +package authkit.shield; + +// Operator surface for the Shield critical-security plugin. +service ShieldService { + // Returns the current lockdown state and metrics. + rpc GetStatus (GetStatusRequest) returns (ShieldStatus); + // Returns recent incidents, newest first. + rpc ListIncidents (ListIncidentsRequest) returns (ListIncidentsResponse); + // Streams incidents live: recent backlog first, then new ones as they happen. + // May redeliver the boundary incident; consumers dedupe by timestamp. + rpc WatchIncidents (WatchIncidentsRequest) returns (stream ShieldIncident); + // Unlocks an active lockdown. Token travels in the x-shield-release metadata. + rpc ReleaseLockdown (ReleaseLockdownRequest) returns (ReleaseLockdownResponse); +} + +message GetStatusRequest { +} + +message ShieldStatus { + bool locked = 1; + string locked_until = 2; + string reason = 3; + int32 tracked_clients = 4; + int64 evaluated = 5; + int64 blocked = 6; + int64 trips = 7; + int64 auth_failures = 8; + int64 probes = 9; +} + +message ListIncidentsRequest { + int32 take = 1; +} + +message ListIncidentsResponse { + repeated ShieldIncident incidents = 1; +} + +message ShieldIncident { + string timestamp = 1; + string sensor = 2; + string reason = 3; + string action = 4; +} + +message WatchIncidentsRequest { + int32 take = 1; +} + +message ReleaseLockdownRequest { + string release_token = 1; +} + +message ReleaseLockdownResponse { + bool released = 1; +} From 1e69aca305abd2b3ca2d255d4415ebf72f2b3afe Mon Sep 17 00:00:00 2001 From: "Rian.be" Date: Fri, 30 Oct 2026 19:51:13 +0100 Subject: [PATCH 11/13] feat(shield): declare HTTP and gRPC pipeline hooks --- .../Shield/Pipeline/ShieldPipeline.cs | 24 +++++++++++++++++++ 1 file changed, 24 insertions(+) create mode 100644 src/Plugins/Solutions/Shield/Pipeline/ShieldPipeline.cs diff --git a/src/Plugins/Solutions/Shield/Pipeline/ShieldPipeline.cs b/src/Plugins/Solutions/Shield/Pipeline/ShieldPipeline.cs new file mode 100644 index 0000000..caa38d0 --- /dev/null +++ b/src/Plugins/Solutions/Shield/Pipeline/ShieldPipeline.cs @@ -0,0 +1,24 @@ +using AuthKit.Plugins.Abstractions.Pipeline; +using Shield.Grpc; +using Shield.Middleware; + +namespace Shield.Pipeline; + +/// +/// The pipeline footprint of Shield: HTTP kill-switch first in line plus the +/// gRPC interceptor. The host owns activation and ordering; this only declares +/// what runs where. +/// +public static class ShieldPipeline +{ + /// + /// Declarative middleware entries contributed by the plugin. + /// + public static IReadOnlyList Declarations => + [ + new(typeof(ShieldMiddleware), PipelinePosition.BeforeRouting, + Order: -1000, Name: "shield-kill-switch"), + new(typeof(ShieldInterceptor), PipelinePosition.BeforeRouting, + Order: -1000, Name: "shield-grpc", Transport: AuthKitTransport.Grpc), + ]; +} From f9cebd9a99bbf3932f5731a43ae6a1baac3dcca1 Mon Sep 17 00:00:00 2001 From: "Rian.be" Date: Fri, 30 Oct 2026 19:53:48 +0100 Subject: [PATCH 12/13] test(shield): verify host plugin loading --- AuthKit.slnx | 3 ++- .../AuthKit.Host.IntegrationTests.csproj | 14 +++++++++++++- .../PluginHealthEndpointIntegrationTests.cs | 6 ++++-- .../ShieldHostLoadTests.cs | 18 ++++++++++++++++++ 4 files changed, 37 insertions(+), 4 deletions(-) create mode 100644 tests/Host.IntegrationTests/ShieldHostLoadTests.cs diff --git a/AuthKit.slnx b/AuthKit.slnx index 9b331ad..c4cb18a 100644 --- a/AuthKit.slnx +++ b/AuthKit.slnx @@ -12,6 +12,7 @@ + @@ -20,7 +21,7 @@ - + diff --git a/tests/Host.IntegrationTests/AuthKit.Host.IntegrationTests.csproj b/tests/Host.IntegrationTests/AuthKit.Host.IntegrationTests.csproj index 722d4bc..66d51b8 100644 --- a/tests/Host.IntegrationTests/AuthKit.Host.IntegrationTests.csproj +++ b/tests/Host.IntegrationTests/AuthKit.Host.IntegrationTests.csproj @@ -29,6 +29,7 @@ + @@ -48,4 +49,15 @@ - \ No newline at end of file + + + <_ShieldStageFiles Include="$(MSBuildProjectDirectory)/../../src/Plugins/Solutions/Shield/bin/$(Configuration)/net10.0/*.dll" /> + <_ShieldStageFiles Include="$(MSBuildProjectDirectory)/../../src/Plugins/Solutions/Shield/bin/$(Configuration)/net10.0/Shield.pdb" /> + <_ShieldProtoFiles Include="$(MSBuildProjectDirectory)/../../src/Plugins/Solutions/Shield/bin/$(Configuration)/net10.0/Grpc/protos/**/*.proto" /> + + + + + + + diff --git a/tests/Host.IntegrationTests/PluginHealthEndpointIntegrationTests.cs b/tests/Host.IntegrationTests/PluginHealthEndpointIntegrationTests.cs index a627055..d028042 100644 --- a/tests/Host.IntegrationTests/PluginHealthEndpointIntegrationTests.cs +++ b/tests/Host.IntegrationTests/PluginHealthEndpointIntegrationTests.cs @@ -39,9 +39,11 @@ public async Task Health_ExecutesPluginHealthChecksThroughTheRealHost() Assert.Equal("Healthy", root.GetProperty("jwtKeyStore").GetString()); var plugins = root.GetProperty("plugins"); - Assert.Single(plugins.EnumerateObject()); + Assert.Equal(2, plugins.EnumerateObject().Count()); Assert.True(plugins.TryGetProperty("DevTokens", out var devTokens)); Assert.True(devTokens.GetArrayLength() >= 1); Assert.InRange(devTokens[0].GetProperty("status").GetInt32(), 0, 2); + Assert.True(plugins.TryGetProperty("Shield", out var shield)); + Assert.Equal(0, shield[0].GetProperty("status").GetInt32()); } -} \ No newline at end of file +} diff --git a/tests/Host.IntegrationTests/ShieldHostLoadTests.cs b/tests/Host.IntegrationTests/ShieldHostLoadTests.cs new file mode 100644 index 0000000..930f2e5 --- /dev/null +++ b/tests/Host.IntegrationTests/ShieldHostLoadTests.cs @@ -0,0 +1,18 @@ +using Host.Plugins.Loading; +using Microsoft.Extensions.DependencyInjection; +using Xunit; + +namespace AuthKit.Host.IntegrationTests; + +public sealed class ShieldHostLoadTests(AuthKitWebApplicationFactory factory) : IClassFixture +{ + private readonly AuthKitWebApplicationFactory _factory = factory; + + [Fact] + public void Shield_LoadsThroughTheRealHost() + { + _ = _factory.CreateClient(); + var plugins = _factory.Services.GetRequiredService>(); + Assert.Single(plugins, plugin => plugin.Plugin.Id == "authkit.shield"); + } +} From 6c2546a300aa69b0e21d7ec95e4babe4e2b6823b Mon Sep 17 00:00:00 2001 From: "Rian.be" Date: Fri, 30 Oct 2026 20:22:07 +0100 Subject: [PATCH 13/13] fix(shield): address PR review findings Prevent one anonymous client from locking down the service by blocking that client first and requiring distinct offenders for a global trip. Validate trusted proxy hops, retain streamed incidents, and make counter eviction constant time. Include Shield in Docker publish and cover the security behavior with tests. --- AuthKit.slnx | 1 + Dockerfile | 6 ++ .../Shield/Detection/ClientIdResolver.cs | 14 +-- .../Shield/Detection/IAttackDetector.cs | 4 +- .../Sensors/AuthBruteForceDetector.cs | 2 +- .../Sensors/EndpointScannerDetector.cs | 2 +- .../Shield/Endpoints/ShieldEndpoints.cs | 4 +- .../Shield/Grpc/ShieldInterceptor.cs | 9 ++ .../Shield/Grpc/ShieldStatusService.cs | 15 +-- .../Shield/Middleware/ShieldMiddleware.cs | 34 +++++-- .../Solutions/Shield/Options/ShieldOptions.cs | 24 +++-- src/Plugins/Solutions/Shield/ShieldPlugin.cs | 10 +- .../State/Counters/SlidingWindowCounter.cs | 71 +++++++++----- .../Solutions/Shield/State/IShieldState.cs | 7 ++ .../Solutions/Shield/State/ShieldState.cs | 72 ++++++++++++++ src/Plugins/Solutions/Shield/manifest.json | 6 +- tests/Plugins/Shield/Shield.Tests.csproj | 18 ++++ tests/Plugins/Shield/ShieldReviewTests.cs | 96 +++++++++++++++++++ 18 files changed, 326 insertions(+), 69 deletions(-) create mode 100644 tests/Plugins/Shield/Shield.Tests.csproj create mode 100644 tests/Plugins/Shield/ShieldReviewTests.cs diff --git a/AuthKit.slnx b/AuthKit.slnx index c4cb18a..5636c95 100644 --- a/AuthKit.slnx +++ b/AuthKit.slnx @@ -28,5 +28,6 @@ + diff --git a/Dockerfile b/Dockerfile index d655095..58912d7 100644 --- a/Dockerfile +++ b/Dockerfile @@ -17,6 +17,7 @@ COPY ["src/Core/Core.csproj", "src/Core/"] COPY ["src/Plugins/Abstractions/AuthKit.Plugins.Abstractions.csproj", "src/Plugins/Abstractions/"] COPY ["src/Plugins/Solutions/DevTokens/DevTokens.csproj", "src/Plugins/Solutions/DevTokens/"] COPY ["src/Plugins/Solutions/DevTools/DevTools.csproj", "src/Plugins/Solutions/DevTools/"] +COPY ["src/Plugins/Solutions/Shield/Shield.csproj", "src/Plugins/Solutions/Shield/"] COPY ["src/Plugins/Integrations/AuthKit.Plugins.Integrations.csproj", "src/Plugins/Integrations/"] COPY ["src/Plugins/Solutions/ExamplePlugin/ExamplePlugin.csproj", "src/Plugins/Solutions/ExamplePlugin/"] @@ -24,6 +25,7 @@ COPY ["src/Plugins/Solutions/ExamplePlugin/ExamplePlugin.csproj", "src/Plugins/S COPY ["tests/Host/AuthKit.Host.Tests.csproj", "tests/Host/"] COPY ["tests/Host.IntegrationTests/AuthKit.Host.IntegrationTests.csproj", "tests/Host.IntegrationTests/"] COPY ["tests/Plugins/Abstractions/AuthKit.Plugins.Abstractions.Tests.csproj", "tests/Plugins/Abstractions/"] +COPY ["tests/Plugins/Shield/Shield.Tests.csproj", "tests/Plugins/Shield/"] COPY ["tools/AuthKit.PluginContractValidator/AuthKit.PluginContractValidator.csproj", "tools/AuthKit.PluginContractValidator/"] COPY ["tools/AuthKit.ManifestGenerator/CLI/AuthKit.ManifestGenerator.CLI.csproj", "tools/AuthKit.ManifestGenerator/CLI/"] COPY ["tools/AuthKit.ManifestGenerator/Core/AuthKit.ManifestGenerator.Core.csproj", "tools/AuthKit.ManifestGenerator/Core/"] @@ -61,6 +63,10 @@ RUN if [ -f src/Plugins/Solutions/DevTools/manifest.json ]; then cp src/Plugins/ RUN dotnet publish "src/Plugins/Solutions/ExamplePlugin/ExamplePlugin.csproj" -c Release -o /app/publish/plugins/ExamplePlugin RUN if [ -f src/Plugins/Solutions/ExamplePlugin/manifest.json ]; then cp src/Plugins/Solutions/ExamplePlugin/manifest.json /app/publish/plugins/ExamplePlugin/manifest.json; else echo "ExamplePlugin manifest.json not in context, skipping"; fi +RUN dotnet publish "src/Plugins/Solutions/Shield/Shield.csproj" -c Release -o /app/publish/plugins/Shield +RUN dotnet /tmp/manifest-gen/AuthKit.ManifestGenerator.CLI.dll --input /app/publish/plugins/Shield/Shield.dll --output /app/publish/plugins/Shield/manifest.json +RUN if [ -f src/Plugins/Solutions/Shield/manifest.json ]; then cp src/Plugins/Solutions/Shield/manifest.json /app/publish/plugins/Shield/manifest.json; fi + FROM base AS final WORKDIR /app COPY --from=publish /app/publish . diff --git a/src/Plugins/Solutions/Shield/Detection/ClientIdResolver.cs b/src/Plugins/Solutions/Shield/Detection/ClientIdResolver.cs index a859052..1f0ff59 100644 --- a/src/Plugins/Solutions/Shield/Detection/ClientIdResolver.cs +++ b/src/Plugins/Solutions/Shield/Detection/ClientIdResolver.cs @@ -1,25 +1,27 @@ using Microsoft.AspNetCore.Http; +using System.Net; namespace Shield.Detection; /// /// Resolves the client identifier used for rate counting, lists, and incidents: -/// the leftmost X-Forwarded-For entry when trusted, otherwise the -/// transport address. +/// the address before the configured number of trusted proxy hops, counted +/// from the right of X-Forwarded-For, or the transport address. /// public static class ClientIdResolver { /// /// Resolves the client id for the request. Never returns null or empty. /// - public static string Resolve(HttpContext context, bool trustForwardedHeader) + public static string Resolve(HttpContext context, bool trustForwardedHeader, int trustedProxyHops = 1) { if (trustForwardedHeader && context.Request.Headers.TryGetValue("X-Forwarded-For", out var forwarded)) { - var first = forwarded.ToString().Split(',', 2)[0].Trim(); - if (!string.IsNullOrEmpty(first)) - return first; + var entries = forwarded.ToString().Split(',', StringSplitOptions.TrimEntries); + var hops = Math.Max(1, trustedProxyHops); + if (entries.Length >= hops && IPAddress.TryParse(entries[^hops], out var address)) + return address.ToString(); } return context.Connection.RemoteIpAddress?.ToString() ?? "unknown"; diff --git a/src/Plugins/Solutions/Shield/Detection/IAttackDetector.cs b/src/Plugins/Solutions/Shield/Detection/IAttackDetector.cs index 16dad28..fca7085 100644 --- a/src/Plugins/Solutions/Shield/Detection/IAttackDetector.cs +++ b/src/Plugins/Solutions/Shield/Detection/IAttackDetector.cs @@ -18,7 +18,7 @@ public sealed record ShieldRequest( public enum ShieldAction { /// - /// Block all traffic for the configured duration. + /// Temporarily block the offending client; enough distinct offenders trigger global lockdown. /// Lockdown, @@ -54,7 +54,7 @@ public interface IAttackDetector /// /// Observes the completed response (e.g. 401/403 counting). Returns a signal - /// when the observation trips the detector; the lockdown then applies to + /// when the observation trips the detector; the client block then applies to /// subsequent requests. Only signals are /// honored here — the response is already sent. Default ignores responses. /// diff --git a/src/Plugins/Solutions/Shield/Detection/Sensors/AuthBruteForceDetector.cs b/src/Plugins/Solutions/Shield/Detection/Sensors/AuthBruteForceDetector.cs index f6b314c..9043fee 100644 --- a/src/Plugins/Solutions/Shield/Detection/Sensors/AuthBruteForceDetector.cs +++ b/src/Plugins/Solutions/Shield/Detection/Sensors/AuthBruteForceDetector.cs @@ -7,7 +7,7 @@ namespace Shield.Detection.Sensors; /// Trips when a single client accumulates too many failed authentications /// (401/403 responses) inside the window — the password-spraying / credential /// stuffing shape. Observation happens after the endpoint runs, so the -/// lockdown applies to subsequent requests. +/// client block applies to subsequent requests. /// public sealed class AuthBruteForceDetector(IShieldState state) : IAttackDetector { diff --git a/src/Plugins/Solutions/Shield/Detection/Sensors/EndpointScannerDetector.cs b/src/Plugins/Solutions/Shield/Detection/Sensors/EndpointScannerDetector.cs index e42f937..082d0b4 100644 --- a/src/Plugins/Solutions/Shield/Detection/Sensors/EndpointScannerDetector.cs +++ b/src/Plugins/Solutions/Shield/Detection/Sensors/EndpointScannerDetector.cs @@ -7,7 +7,7 @@ namespace Shield.Detection.Sensors; /// Trips when a single client collects too many 404s inside the window — the /// directory-enumeration / vulnerability-probing shape /// (/.git/, /wp-admin/, /.env). Observation happens after -/// the endpoint runs, so the lockdown applies to subsequent requests. +/// the endpoint runs, so the client block applies to subsequent requests. /// public sealed class EndpointScannerDetector(IShieldState state) : IAttackDetector { diff --git a/src/Plugins/Solutions/Shield/Endpoints/ShieldEndpoints.cs b/src/Plugins/Solutions/Shield/Endpoints/ShieldEndpoints.cs index 0ad5797..6c5378f 100644 --- a/src/Plugins/Solutions/Shield/Endpoints/ShieldEndpoints.cs +++ b/src/Plugins/Solutions/Shield/Endpoints/ShieldEndpoints.cs @@ -29,7 +29,7 @@ public static void Map(IEndpointRouteBuilder endpoints) return denied; var snapshot = State.ShieldStatus.GetSnapshot(state, options.Value); - return (IResult)Results.Ok(new + return Results.Ok(new { locked = snapshot.IsLocked, lockedUntil = snapshot.LockedUntil, @@ -50,7 +50,7 @@ public static void Map(IEndpointRouteBuilder endpoints) return denied; var incidents = state.GetIncidents(take ?? options.Value.MaxIncidents); - return (IResult)Results.Ok(incidents.Select(i => new + return Results.Ok(incidents.Select(i => new { timestamp = i.Timestamp, sensor = i.Sensor, diff --git a/src/Plugins/Solutions/Shield/Grpc/ShieldInterceptor.cs b/src/Plugins/Solutions/Shield/Grpc/ShieldInterceptor.cs index dfcd609..c794120 100644 --- a/src/Plugins/Solutions/Shield/Grpc/ShieldInterceptor.cs +++ b/src/Plugins/Solutions/Shield/Grpc/ShieldInterceptor.cs @@ -1,6 +1,7 @@ using Grpc.Core; using Grpc.Core.Interceptors; using Microsoft.Extensions.Options; +using Shield.Detection; using Shield.Options; using Shield.State; @@ -25,6 +26,14 @@ private void ThrowIfLocked(ServerCallContext context) if (state.IsLockdownActive(out _, out var reason)) throw new RpcException(new Status(StatusCode.Unavailable, $"Traffic blocked by Shield ({reason ?? "lockdown"}).")); + + var config = options.Value; + var clientId = ClientIdResolver.Resolve(context.GetHttpContext(), config.TrustForwardedHeader, config.TrustedProxyHops); + if (config.AllowedIps.Contains(clientId, StringComparer.Ordinal)) + return; + if (config.BlockedIps.Contains(clientId, StringComparer.Ordinal) || + state.IsClientBlocked(clientId, out _, out reason)) + throw new RpcException(new Status(StatusCode.Unavailable, $"Traffic blocked by Shield ({reason ?? "client blocked"}).")); } public override Task UnaryServerHandler( diff --git a/src/Plugins/Solutions/Shield/Grpc/ShieldStatusService.cs b/src/Plugins/Solutions/Shield/Grpc/ShieldStatusService.cs index e525262..b4cec3e 100644 --- a/src/Plugins/Solutions/Shield/Grpc/ShieldStatusService.cs +++ b/src/Plugins/Solutions/Shield/Grpc/ShieldStatusService.cs @@ -1,4 +1,5 @@ using Grpc.Core; +using System.Threading.Channels; using Microsoft.Extensions.Options; using Shield.Options; using Shield.Security; @@ -58,19 +59,19 @@ public override async Task WatchIncidents( // Subscribe first so nothing is missed, then replay the backlog. // An incident landing in between may be delivered twice; consumers // dedupe by timestamp + sensor + reason. - var pending = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); - using var subscription = state.SubscribeIncidents(incident => pending.TrySetResult(incident)); + var channel = Channel.CreateUnbounded(new UnboundedChannelOptions + { + SingleReader = true, + SingleWriter = false, + }); + using var subscription = state.SubscribeIncidents(incident => channel.Writer.TryWrite(incident)); var take = request.Take is > 0 ? request.Take : options.Value.MaxIncidents; foreach (var incident in state.GetIncidents(take).Reverse()) await responseStream.WriteAsync(Map(incident)); - while (!context.CancellationToken.IsCancellationRequested) - { - var incident = await pending.Task.WaitAsync(context.CancellationToken); - pending = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + await foreach (var incident in channel.Reader.ReadAllAsync(context.CancellationToken)) await responseStream.WriteAsync(Map(incident)); - } } /// diff --git a/src/Plugins/Solutions/Shield/Middleware/ShieldMiddleware.cs b/src/Plugins/Solutions/Shield/Middleware/ShieldMiddleware.cs index 419f719..571ed7b 100644 --- a/src/Plugins/Solutions/Shield/Middleware/ShieldMiddleware.cs +++ b/src/Plugins/Solutions/Shield/Middleware/ShieldMiddleware.cs @@ -25,9 +25,9 @@ public async Task InvokeAsync( IOptions options) { var config = options.Value; - var clientId = ClientIdResolver.Resolve(context, config.TrustForwardedHeader); + var clientId = ClientIdResolver.Resolve(context, config.TrustForwardedHeader, config.TrustedProxyHops); - if (IsListed(clientId, config.AllowedIps) || IsBypassed(context, config)) + if (IsBypassed(context, config)) { await next(context); return; @@ -49,6 +49,19 @@ public async Task InvokeAsync( return; } + if (IsListed(clientId, config.AllowedIps)) + { + await next(context); + return; + } + + if (state.IsClientBlocked(clientId, out var blockedUntil, out var clientReason)) + { + state.CountBlock(); + await BlockAsync(context, blockedUntil - DateTimeOffset.UtcNow, clientReason ?? "client blocked"); + return; + } + if (IsListed(clientId, config.BlockedIps)) { state.CountBlock(); @@ -63,30 +76,31 @@ public async Task InvokeAsync( DateTimeOffset.UtcNow, context.Request.Headers.UserAgent.ToString(), context.Request.ContentLength); - foreach (var detector in detectors) + foreach (var signal in detectors.Select(detector => detector.Evaluate(request, config))) { - var signal = detector.Evaluate(request, config); if (signal is null) continue; - await ApplySignalAsync(context, state, config, signal); + await ApplySignalAsync(context, state, config, clientId, signal); return; } await next(context); - // Post-response observation (e.g. 401/403 counting). The response is - // already sent, so only lockdowns apply — to subsequent requests. + // A post-response signal blocks this client on subsequent requests. foreach (var detector in detectors) { var signal = detector.ObserveResponse(request, context.Response.StatusCode, config); if (signal is null || signal.Action != ShieldAction.Lockdown) continue; - state.TryEnterLockdown( + state.RecordOffender( + clientId, signal.Sensor, signal.Reason, TimeSpan.FromSeconds(config.LockdownSeconds), + config.MaxTrackedClients, + config.MinOffendingClientsForLockdown, config.MaxIncidents); } } @@ -95,12 +109,14 @@ private static Task ApplySignalAsync( HttpContext context, IShieldState state, ShieldOptions config, + string clientId, AttackSignal signal) { var duration = TimeSpan.FromSeconds(config.LockdownSeconds); if (signal.Action == ShieldAction.Lockdown) { - state.TryEnterLockdown(signal.Sensor, signal.Reason, duration, config.MaxIncidents); + state.RecordOffender(clientId, signal.Sensor, signal.Reason, duration, + config.MaxTrackedClients, config.MinOffendingClientsForLockdown, config.MaxIncidents); state.CountBlock(); return BlockAsync(context, duration, signal.Reason); } diff --git a/src/Plugins/Solutions/Shield/Options/ShieldOptions.cs b/src/Plugins/Solutions/Shield/Options/ShieldOptions.cs index d4f8d1f..a4aeff0 100644 --- a/src/Plugins/Solutions/Shield/Options/ShieldOptions.cs +++ b/src/Plugins/Solutions/Shield/Options/ShieldOptions.cs @@ -11,7 +11,7 @@ public sealed class ShieldOptions { /// /// Maximum requests per client inside before - /// the client trips the lockdown. Default 100. + /// the client is temporarily blocked. Default 100. /// [Range(1, 1_000_000)] public int MaxRequestsPerWindow { get; set; } = 100; @@ -23,13 +23,13 @@ public sealed class ShieldOptions public int WindowSeconds { get; set; } = 60; /// - /// How long an automatic lockdown lasts once tripped. Default 300s. + /// Duration of client blocks and automatic global lockdown. Default 300s. /// [Range(1, 86_400)] public int LockdownSeconds { get; set; } = 300; /// - /// Signature hits from a single client before it trips the lockdown. Default 3. + /// Signature hits from a single client before a temporary block. Default 3. /// [Range(1, 100)] public int MaxSignatureHits { get; set; } = 3; @@ -42,6 +42,10 @@ public sealed class ShieldOptions [Range(1, 1_000_000)] public int MaxTrackedClients { get; set; } = 10_000; + /// Distinct blocked clients required for an automatic global lockdown. + [Range(2, 1_000_000)] + public int MinOffendingClientsForLockdown { get; set; } = 20; + /// /// Enables path/query attack-signature detection. Default true. /// @@ -49,7 +53,7 @@ public sealed class ShieldOptions /// /// Failed authentications (401/403) per client inside - /// before lockdown. Default 10. + /// before a client block. Default 10. /// [Range(1, 10_000)] public int MaxAuthFailures { get; set; } = 10; @@ -73,7 +77,7 @@ public sealed class ShieldOptions /// /// Missing paths per client inside - /// before lockdown. Default 20. + /// before a client block. Default 20. /// [Range(1, 10_000)] public int MaxNotFoundProbes { get; set; } = 20; @@ -136,12 +140,16 @@ public sealed class ShieldOptions public string[] BlockedIps { get; set; } = []; /// - /// Trust the leftmost X-Forwarded-For entry for client identification. - /// Enable only behind a trusted reverse proxy; clients can spoof this - /// header otherwise. Default false. + /// Use X-Forwarded-For for client identification. Select the entry + /// positions from the right. Enable only + /// when direct access to the host is blocked by trusted proxies. /// public bool TrustForwardedHeader { get; set; } = false; + /// Number of trusted proxies that append to X-Forwarded-For. + [Range(1, 16)] + public int TrustedProxyHops { get; set; } = 1; + /// /// Maximum retained incidents in the in-memory ring buffer. Default 100. /// diff --git a/src/Plugins/Solutions/Shield/ShieldPlugin.cs b/src/Plugins/Solutions/Shield/ShieldPlugin.cs index e5b0add..40f1a37 100644 --- a/src/Plugins/Solutions/Shield/ShieldPlugin.cs +++ b/src/Plugins/Solutions/Shield/ShieldPlugin.cs @@ -16,8 +16,8 @@ namespace Shield; /// /// Critical-security plugin: detects attack traffic through ordered detector -/// strategies and blocks all HTTP and gRPC traffic for a configurable duration -/// once tripped. Thin composition root — services, pipeline declarations, +/// strategies, blocks offending clients, and can block all HTTP and gRPC +/// traffic after distributed signals. Thin composition root — services, pipeline declarations, /// endpoints, and health each live in their own unit. /// [PluginMetadata( @@ -28,12 +28,12 @@ namespace Shield; capabilities: ["security"], name: "Shield", displayName: "Shield", - description: "Attack detection with full-traffic lockdown for HTTP and gRPC.", + description: "Attack detection with client blocking and full-traffic lockdown for HTTP and gRPC.", author: "AuthKit Contributors", license: "MIT", licenseUrl: "https://opensource.org/licenses/MIT", - homepage: "https://example.org/shield", - repositoryUrl: "https://example.org/shield.git" + homepage: "https://github.com/AuthKits/AuthKit.Server", + repositoryUrl: "https://github.com/AuthKits/AuthKit.Server" )] public sealed class ShieldPlugin : IAuthKitPlugin { diff --git a/src/Plugins/Solutions/Shield/State/Counters/SlidingWindowCounter.cs b/src/Plugins/Solutions/Shield/State/Counters/SlidingWindowCounter.cs index 9b21a6b..6108a04 100644 --- a/src/Plugins/Solutions/Shield/State/Counters/SlidingWindowCounter.cs +++ b/src/Plugins/Solutions/Shield/State/Counters/SlidingWindowCounter.cs @@ -1,13 +1,13 @@ namespace Shield.State.Counters; -/// -/// Per-client sliding-window event counter (request rate, auth failures). -/// Thread-safe. -/// +/// Bounded per-client sliding-window counter with LRU eviction. internal sealed class SlidingWindowCounter { + private const int PruneInterval = 1024; private readonly Lock _sync = new(); - private readonly Dictionary> _buckets = new(StringComparer.Ordinal); + private readonly Dictionary _buckets = new(StringComparer.Ordinal); + private readonly LinkedList _recency = new(); + private int _recordsSincePrune; /// /// Records an event and returns the client's count inside the window. @@ -17,33 +17,30 @@ public int Record(string clientId, DateTimeOffset now, int windowSeconds, int ma lock (_sync) { var cutoff = now.AddSeconds(-windowSeconds); - if (!_buckets.ContainsKey(clientId) && _buckets.Count >= maxTrackedClients) + if (++_recordsSincePrune >= PruneInterval) { - foreach (var (id, events) in _buckets.ToArray()) - { - while (events.Count > 0 && events.Peek() < cutoff) - events.Dequeue(); - if (events.Count == 0) - _buckets.Remove(id); - } + _recordsSincePrune = 0; + PruneExpired(cutoff); + } + if (!_buckets.TryGetValue(clientId, out var bucket)) + { while (_buckets.Count >= maxTrackedClients) - { - var oldest = _buckets.MinBy(entry => entry.Value.Peek().UtcTicks).Key; - _buckets.Remove(oldest); - } - } + Remove(_recency.First!.Value); - if (!_buckets.TryGetValue(clientId, out var timestamps)) + bucket = new ClientBucket(_recency.AddLast(clientId)); + _buckets.Add(clientId, bucket); + } + else { - timestamps = new Queue(); - _buckets[clientId] = timestamps; + _recency.Remove(bucket.RecencyNode); + _recency.AddLast(bucket.RecencyNode); } - while (timestamps.Count > 0 && timestamps.Peek() < cutoff) - timestamps.Dequeue(); - timestamps.Enqueue(now); - return timestamps.Count; + while (bucket.Timestamps.Count > 0 && bucket.Timestamps.Peek() < cutoff) + bucket.Timestamps.Dequeue(); + bucket.Timestamps.Enqueue(now); + return bucket.Timestamps.Count; } } @@ -58,4 +55,28 @@ public int ClientCount return _buckets.Count; } } + + private void PruneExpired(DateTimeOffset cutoff) + { + foreach (var (clientId, bucket) in _buckets.ToArray()) + { + while (bucket.Timestamps.Count > 0 && bucket.Timestamps.Peek() < cutoff) + bucket.Timestamps.Dequeue(); + if (bucket.Timestamps.Count == 0) + Remove(clientId); + } + } + + private void Remove(string clientId) + { + var bucket = _buckets[clientId]; + _recency.Remove(bucket.RecencyNode); + _buckets.Remove(clientId); + } + + private sealed class ClientBucket(LinkedListNode recencyNode) + { + public LinkedListNode RecencyNode { get; } = recencyNode; + public Queue Timestamps { get; } = new(); + } } diff --git a/src/Plugins/Solutions/Shield/State/IShieldState.cs b/src/Plugins/Solutions/Shield/State/IShieldState.cs index fbcc696..bb44fed 100644 --- a/src/Plugins/Solutions/Shield/State/IShieldState.cs +++ b/src/Plugins/Solutions/Shield/State/IShieldState.cs @@ -19,6 +19,13 @@ public interface IShieldState /// bool TryEnterLockdown(string sensor, string reason, TimeSpan duration, int maxIncidents); + /// Checks whether one client has an active temporary block. + bool IsClientBlocked(string clientId, out DateTimeOffset blockedUntil, out string? reason); + + /// Blocks an offending client and enters global lockdown only after enough distinct offenders. + void RecordOffender(string clientId, string sensor, string reason, TimeSpan duration, + int maxTrackedClients, int minOffendingClientsForLockdown, int maxIncidents); + /// /// Clears an active lockdown immediately and records an incident. /// diff --git a/src/Plugins/Solutions/Shield/State/ShieldState.cs b/src/Plugins/Solutions/Shield/State/ShieldState.cs index 5b64749..ea935ea 100644 --- a/src/Plugins/Solutions/Shield/State/ShieldState.cs +++ b/src/Plugins/Solutions/Shield/State/ShieldState.cs @@ -22,6 +22,9 @@ public sealed class ShieldState : IShieldState private readonly LockdownGate _lockdown = new(); private readonly IncidentLog _incidents = new(); private readonly ShieldMetrics _metrics = new(); + private readonly Lock _clientSync = new(); + private readonly Dictionary _blockedClients = new(StringComparer.Ordinal); + private readonly LinkedList _blockedOrder = new(); /// /// @@ -44,6 +47,55 @@ public bool TryEnterLockdown(string sensor, string reason, TimeSpan duration, in return true; } + public bool IsClientBlocked(string clientId, out DateTimeOffset blockedUntil, out string? reason) + { + lock (_clientSync) + { + PruneBlockedClients(DateTimeOffset.UtcNow); + if (_blockedClients.TryGetValue(clientId, out var block)) + { + blockedUntil = block.Until; + reason = block.Reason; + return true; + } + } + + blockedUntil = default; + reason = null; + return false; + } + + public void RecordOffender(string clientId, string sensor, string reason, TimeSpan duration, + int maxTrackedClients, int minOffendingClientsForLockdown, int maxIncidents) + { + bool globalThresholdReached; + lock (_clientSync) + { + var now = DateTimeOffset.UtcNow; + PruneBlockedClients(now); + if (_blockedClients.TryGetValue(clientId, out var existing)) + { + _blockedOrder.Remove(existing.Node); + _blockedOrder.AddLast(existing.Node); + existing.Until = now.Add(duration); + existing.Reason = reason; + } + else + { + while (_blockedClients.Count >= maxTrackedClients) + RemoveBlockedClient(_blockedOrder.First!.Value); + + _blockedClients.Add(clientId, new ClientBlock(now.Add(duration), reason, _blockedOrder.AddLast(clientId))); + } + + globalThresholdReached = _blockedClients.Count >= minOffendingClientsForLockdown; + } + + RecordIncident(sensor, reason, "client-blocked", maxIncidents); + if (globalThresholdReached) + TryEnterLockdown("distributed-attack", "multiple distinct offending clients", duration, maxIncidents); + } + /// /// /// Releases the lockdown gate and records the release. @@ -160,4 +212,24 @@ private int EventClientCount(string bucket) lock (_eventsSync) return _events.TryGetValue(bucket, out var counter) ? counter.ClientCount : 0; } + + private void PruneBlockedClients(DateTimeOffset now) + { + while (_blockedOrder.First is { } first && _blockedClients[first.Value].Until <= now) + RemoveBlockedClient(first.Value); + } + + private void RemoveBlockedClient(string clientId) + { + var block = _blockedClients[clientId]; + _blockedOrder.Remove(block.Node); + _blockedClients.Remove(clientId); + } + + private sealed class ClientBlock(DateTimeOffset until, string reason, LinkedListNode node) + { + public DateTimeOffset Until { get; set; } = until; + public string Reason { get; set; } = reason; + public LinkedListNode Node { get; } = node; + } } diff --git a/src/Plugins/Solutions/Shield/manifest.json b/src/Plugins/Solutions/Shield/manifest.json index 7855be3..8cdba78 100644 --- a/src/Plugins/Solutions/Shield/manifest.json +++ b/src/Plugins/Solutions/Shield/manifest.json @@ -2,13 +2,13 @@ "Id": "authkit.shield", "Name": "Shield", "DisplayName": "Shield", - "Description": "Attack detection with full-traffic lockdown for HTTP and gRPC.", + "Description": "Attack detection with client blocking and full-traffic lockdown for HTTP and gRPC.", "Version": "1.0.0", "Author": "AuthKit Contributors", "License": "MIT", "LicenseUrl": "https://opensource.org/licenses/MIT", - "Homepage": "https://example.org/shield", - "RepositoryUrl": "https://example.org/shield.git", + "Homepage": "https://github.com/AuthKits/AuthKit.Server", + "RepositoryUrl": "https://github.com/AuthKits/AuthKit.Server", "Tags": [ "security", "critical" diff --git a/tests/Plugins/Shield/Shield.Tests.csproj b/tests/Plugins/Shield/Shield.Tests.csproj new file mode 100644 index 0000000..d5ddf6f --- /dev/null +++ b/tests/Plugins/Shield/Shield.Tests.csproj @@ -0,0 +1,18 @@ + + + net10.0 + enable + enable + false + + + + + + + + + + + + diff --git a/tests/Plugins/Shield/ShieldReviewTests.cs b/tests/Plugins/Shield/ShieldReviewTests.cs new file mode 100644 index 0000000..e286b5f --- /dev/null +++ b/tests/Plugins/Shield/ShieldReviewTests.cs @@ -0,0 +1,96 @@ +using System.Net; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.TestHost; +using Microsoft.Extensions.Configuration; +using Shield.Composition; +using Shield.Detection; +using Shield.State; +using Xunit; + +namespace Shield.Tests; + +public sealed class ShieldReviewTests +{ + [Fact] + public void ClientIdResolver_IgnoresClientSuppliedLeftmostAddress() + { + var context = new DefaultHttpContext(); + context.Connection.RemoteIpAddress = IPAddress.Parse("192.0.2.10"); + context.Request.Headers["X-Forwarded-For"] = "198.51.100.1, 203.0.113.5, 192.0.2.1"; + + Assert.Equal("192.0.2.1", ClientIdResolver.Resolve(context, true, 1)); + Assert.Equal("203.0.113.5", ClientIdResolver.Resolve(context, true, 2)); + Assert.Equal("192.0.2.10", ClientIdResolver.Resolve(context, true, 4)); + Assert.Equal("192.0.2.10", ClientIdResolver.Resolve(context, false)); + } + + [Fact] + public void Counter_EvictsLeastRecentlyUsedClient() + { + var state = new ShieldState(); + var now = DateTimeOffset.UtcNow; + Assert.Equal(1, state.RecordRequest("busy", now, 60, 2)); + Assert.Equal(1, state.RecordRequest("quiet", now.AddSeconds(1), 60, 2)); + Assert.Equal(2, state.RecordRequest("busy", now.AddSeconds(2), 60, 2)); + + state.RecordRequest("new", now.AddSeconds(3), 60, 2); + + Assert.Equal(3, state.RecordRequest("busy", now.AddSeconds(4), 60, 2)); + Assert.Equal(1, state.RecordRequest("quiet", now.AddSeconds(5), 60, 2)); + } + + [Fact] + public void OneOffenderBlocksOnlyThatClient_SeveralOffendersTripGlobalLockdown() + { + var state = new ShieldState(); + var duration = TimeSpan.FromMinutes(1); + + state.RecordOffender("first", "signature", "attack", duration, 10, 2, 10); + Assert.True(state.IsClientBlocked("first", out _, out _)); + Assert.False(state.IsClientBlocked("second", out _, out _)); + Assert.False(state.IsLockdownActive(out _, out _)); + + state.RecordOffender("first", "signature", "attack", duration, 10, 2, 10); + Assert.False(state.IsLockdownActive(out _, out _)); + + state.RecordOffender("second", "signature", "attack", duration, 10, 2, 10); + Assert.True(state.IsLockdownActive(out _, out _)); + } + + [Fact] + public async Task HttpMiddleware_BlocksOnlyOffenderUntilDistinctThreshold() + { + var builder = WebApplication.CreateBuilder(); + builder.WebHost.UseTestServer(); + builder.Configuration.AddInMemoryCollection(new Dictionary + { + [nameof(Shield.Options.ShieldOptions.TrustForwardedHeader)] = "true", + [nameof(Shield.Options.ShieldOptions.MinOffendingClientsForLockdown)] = "2", + }); + builder.Services.AddShield(builder.Configuration); + + await using var app = builder.Build(); + app.UseMiddleware(); + app.MapGet("/ok", () => "ok"); + await app.StartAsync(); + var client = app.GetTestClient(); + + async Task Send(string address, bool suspicious = false) + { + using var request = new HttpRequestMessage(HttpMethod.Get, "/ok"); + request.Headers.Add("X-Forwarded-For", $"198.51.100.1, {address}"); + if (suspicious) + request.Headers.UserAgent.ParseAdd("sqlmap"); + using var response = await client.SendAsync(request); + return response.StatusCode; + } + + Assert.Equal(HttpStatusCode.ServiceUnavailable, await Send("203.0.113.1", suspicious: true)); + Assert.Equal(HttpStatusCode.OK, await Send("203.0.113.2")); + Assert.Equal(HttpStatusCode.ServiceUnavailable, await Send("203.0.113.1")); + Assert.Equal(HttpStatusCode.ServiceUnavailable, await Send("203.0.113.2", suspicious: true)); + Assert.Equal(HttpStatusCode.ServiceUnavailable, await Send("203.0.113.3")); + } +}