diff --git a/AuthKit.slnx b/AuthKit.slnx index 9b331ad..5636c95 100644 --- a/AuthKit.slnx +++ b/AuthKit.slnx @@ -12,6 +12,7 @@ + @@ -20,12 +21,13 @@ - + + 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/Composition/ShieldServices.cs b/src/Plugins/Solutions/Shield/Composition/ShieldServices.cs new file mode 100644 index 0000000..ccb3d2a --- /dev/null +++ b/src/Plugins/Solutions/Shield/Composition/ShieldServices.cs @@ -0,0 +1,36 @@ +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Shield.Detection; +using Shield.Detection.Sensors; +using Shield.Options; +using Shield.State; + +namespace Shield.Composition; + +/// +/// Composition root for Shield services: validated options, shared state, +/// and detector strategies in evaluation order. +/// +public static class ShieldServices +{ + /// + /// Registers Shield into the host DI container. + /// + public static IServiceCollection AddShield(this IServiceCollection services, IConfiguration configuration) + { + services.AddOptions() + .Bind(configuration) + .ValidateDataAnnotations() + .ValidateOnStart(); + + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + + return services; + } +} diff --git a/src/Plugins/Solutions/Shield/Detection/ClientIdResolver.cs b/src/Plugins/Solutions/Shield/Detection/ClientIdResolver.cs new file mode 100644 index 0000000..1f0ff59 --- /dev/null +++ b/src/Plugins/Solutions/Shield/Detection/ClientIdResolver.cs @@ -0,0 +1,29 @@ +using Microsoft.AspNetCore.Http; +using System.Net; + +namespace Shield.Detection; + +/// +/// Resolves the client identifier used for rate counting, lists, and incidents: +/// 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, int trustedProxyHops = 1) + { + if (trustForwardedHeader + && context.Request.Headers.TryGetValue("X-Forwarded-For", out var forwarded)) + { + 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 new file mode 100644 index 0000000..fca7085 --- /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 +{ + /// + /// Temporarily block the offending client; enough distinct offenders trigger global lockdown. + /// + 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 client block 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; +} 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", + "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"}).")); + + 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( + 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..b4cec3e --- /dev/null +++ b/src/Plugins/Solutions/Shield/Grpc/ShieldStatusService.cs @@ -0,0 +1,116 @@ +using Grpc.Core; +using System.Threading.Channels; +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 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)); + + await foreach (var incident in channel.Reader.ReadAllAsync(context.CancellationToken)) + 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; +} diff --git a/src/Plugins/Solutions/Shield/Health/ShieldHealth.cs b/src/Plugins/Solutions/Shield/Health/ShieldHealth.cs new file mode 100644 index 0000000..95b1bbe --- /dev/null +++ b/src/Plugins/Solutions/Shield/Health/ShieldHealth.cs @@ -0,0 +1,60 @@ +using AuthKit.Plugins.Abstractions.Models; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; +using Shield.Options; +using Shield.State; + +namespace Shield.Health; + +/// +/// Builds the structured health report from the lockdown snapshot. +/// Unhealthy while a lockdown is active, with reason and metrics attached. +/// +public static class ShieldHealth +{ + /// + /// Reports the current Shield health. + /// + public static IReadOnlyList Report( + IServiceProvider services, + CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + var state = services.GetService(); + var options = services.GetService>()?.Value; + var snapshot = state is null || options is null ? null : State.ShieldStatus.GetSnapshot(state, options); + + var data = new Dictionary + { + ["locked"] = snapshot?.IsLocked ?? false, + ["evaluated"] = snapshot?.TotalEvaluated ?? 0L, + ["blocked"] = snapshot?.TotalBlocked ?? 0L, + ["trips"] = snapshot?.TotalTrips ?? 0L, + ["auth_failures"] = snapshot?.TotalAuthFailures ?? 0L, + ["probes"] = snapshot?.TotalProbes ?? 0L, + }; + + if (snapshot is { IsLocked: true }) + { + data["locked_until"] = snapshot.LockedUntil?.ToString("O") ?? "?"; + data["reason"] = snapshot.Reason ?? "lockdown"; + return + [ + new( + PluginHealthStatus.Unhealthy, + $"Shield lockdown active ({snapshot.Reason}).", + data, + ["security", "critical"]) + ]; + } + + return + [ + new( + PluginHealthStatus.Healthy, + "Shield is armed, no lockdown active.", + data, + ["security", "readiness"]) + ]; + } +} diff --git a/src/Plugins/Solutions/Shield/Middleware/ShieldMiddleware.cs b/src/Plugins/Solutions/Shield/Middleware/ShieldMiddleware.cs new file mode 100644 index 0000000..571ed7b --- /dev/null +++ b/src/Plugins/Solutions/Shield/Middleware/ShieldMiddleware.cs @@ -0,0 +1,144 @@ +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Options; +using Shield.Detection; +using Shield.Options; +using Shield.State; + +namespace Shield.Middleware; + +/// +/// First-line HTTP defense. Orchestrates evaluation: allow/block lists, +/// bypass paths, manual and active lockdowns, then the registered +/// strategies in order. Blocked requests receive +/// 503 with a Retry-After hint and never reach routing, authentication, +/// or endpoints. +/// +public sealed class ShieldMiddleware(RequestDelegate next) +{ + /// + /// Evaluates the request against lists, lockdown state, and detectors. + /// + public async Task InvokeAsync( + HttpContext context, + IShieldState state, + IEnumerable detectors, + IOptions options) + { + var config = options.Value; + var clientId = ClientIdResolver.Resolve(context, config.TrustForwardedHeader, config.TrustedProxyHops); + + if (IsBypassed(context, config)) + { + await next(context); + return; + } + + state.CountEvaluation(); + + if (config.ForceLockdown) + { + state.CountBlock(); + await BlockAsync(context, TimeSpan.FromSeconds(config.LockdownSeconds), "manual kill-switch"); + return; + } + + if (state.IsLockdownActive(out var lockedUntil, out var reason)) + { + state.CountBlock(); + await BlockAsync(context, lockedUntil - DateTimeOffset.UtcNow, reason ?? "lockdown"); + 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(); + await BlockAsync(context, TimeSpan.FromSeconds(config.LockdownSeconds), "blocklisted client"); + return; + } + + var request = new ShieldRequest( + clientId, + context.Request.Path.Value ?? "/", + context.Request.QueryString.Value ?? "", + DateTimeOffset.UtcNow, + context.Request.Headers.UserAgent.ToString(), + context.Request.ContentLength); + foreach (var signal in detectors.Select(detector => detector.Evaluate(request, config))) + { + if (signal is null) + continue; + + await ApplySignalAsync(context, state, config, clientId, signal); + return; + } + + await next(context); + + // 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.RecordOffender( + clientId, + signal.Sensor, + signal.Reason, + TimeSpan.FromSeconds(config.LockdownSeconds), + config.MaxTrackedClients, + config.MinOffendingClientsForLockdown, + config.MaxIncidents); + } + } + + 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.RecordOffender(clientId, signal.Sensor, signal.Reason, duration, + config.MaxTrackedClients, config.MinOffendingClientsForLockdown, config.MaxIncidents); + state.CountBlock(); + return BlockAsync(context, duration, signal.Reason); + } + + state.RecordIncident(signal.Sensor, signal.Reason, "request-blocked", config.MaxIncidents); + state.CountBlock(); + return BlockAsync(context, duration, signal.Reason); + } + + private static bool IsListed(string clientId, string[] list) => + list.Any(entry => string.Equals(entry, clientId, StringComparison.Ordinal)); + + private static bool IsBypassed(HttpContext context, ShieldOptions config) => + config.BypassPaths.Any(path => + context.Request.Path.Equals(path, StringComparison.OrdinalIgnoreCase)); + + private static Task BlockAsync(HttpContext context, TimeSpan retryAfter, string reason) + { + var seconds = Math.Max(1, (int)Math.Ceiling(retryAfter.TotalSeconds)); + context.Response.StatusCode = StatusCodes.Status503ServiceUnavailable; + context.Response.Headers.RetryAfter = seconds.ToString(); + context.Response.Headers["X-Shield"] = "lockdown"; + return context.Response.WriteAsync($"Service unavailable: traffic blocked by Shield ({reason})."); + } +} diff --git a/src/Plugins/Solutions/Shield/Options/ShieldOptions.cs b/src/Plugins/Solutions/Shield/Options/ShieldOptions.cs new file mode 100644 index 0000000..a4aeff0 --- /dev/null +++ b/src/Plugins/Solutions/Shield/Options/ShieldOptions.cs @@ -0,0 +1,170 @@ +using System.ComponentModel.DataAnnotations; + +namespace Shield.Options; + +/// +/// Tunables for the Shield critical-security plugin. Bound from the plugin +/// configuration section (Plugins:authkit.shield) and validated at +/// startup via data annotations. +/// +public sealed class ShieldOptions +{ + /// + /// Maximum requests per client inside before + /// the client is temporarily blocked. Default 100. + /// + [Range(1, 1_000_000)] + public int MaxRequestsPerWindow { get; set; } = 100; + + /// + /// Sliding window size in seconds for rate counting. Default 60. + /// + [Range(1, 3600)] + public int WindowSeconds { get; set; } = 60; + + /// + /// 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 a temporary block. Default 3. + /// + [Range(1, 100)] + public int MaxSignatureHits { get; set; } = 3; + + /// Window for signature hits. Default one hour. + [Range(1, 86_400)] + public int SignatureWindowSeconds { get; set; } = 3600; + + /// Maximum client entries retained by each counter. + [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. + /// + public bool SignatureDetectionEnabled { get; set; } = true; + + /// + /// Failed authentications (401/403) per client inside + /// before a client block. Default 10. + /// + [Range(1, 10_000)] + public int MaxAuthFailures { get; set; } = 10; + + /// + /// Sliding window in seconds for authentication-failure counting. Default 300. + /// + [Range(1, 3600)] + public int AuthFailureWindowSeconds { get; set; } = 300; + + /// + /// Additional case-insensitive fragments merged with the built-in signature + /// list. Default empty. + /// + public string[] ExtraSignatures { get; set; } = []; + + /// + /// Enables 404-probing detection. Default true. + /// + public bool ScannerDetectionEnabled { get; set; } = true; + + /// + /// Missing paths per client inside + /// before a client block. Default 20. + /// + [Range(1, 10_000)] + public int MaxNotFoundProbes { get; set; } = 20; + + /// + /// Sliding window in seconds for 404-probe counting. Default 300. + /// + [Range(1, 3600)] + public int ScannerWindowSeconds { get; set; } = 300; + + /// + /// Enables scanner user-agent detection. Default true. + /// + public bool UserAgentDetectionEnabled { get; set; } = true; + + /// + /// Additional case-insensitive user-agent fragments treated as attack + /// tooling. Default empty. + /// + public string[] ExtraBlockedAgents { get; set; } = []; + + /// + /// Maximum declared request body in bytes before the request is blocked + /// (without a lockdown). Default 8 MiB. + /// + [Range(1024, 1_073_741_824)] + public long MaxBodyBytes { get; set; } = 8 * 1024 * 1024; + + /// + /// Maximum query-string length in chars before the request is blocked + /// (without a lockdown). Default 4096. + /// + [Range(256, 65_536)] + public int MaxQueryLength { get; set; } = 4096; + + /// + /// Manual kill-switch. When true, every request is blocked until the flag + /// is cleared. Default false. + /// + public bool ForceLockdown { get; set; } = false; + + /// + /// Exact paths (case-insensitive) that bypass evaluation, e.g. the status + /// endpoint so operators can observe an active lockdown. + /// Default /shield/status, /shield/incidents and /shield/release. + /// + public string[] BypassPaths { get; set; } = ["/shield/status", "/shield/incidents", "/shield/release"]; + + /// + /// Client identifiers that skip evaluation entirely (e.g. monitoring). + /// Compared verbatim against the resolved client id. Default empty. + /// + public string[] AllowedIps { get; set; } = []; + + /// + /// Client identifiers that are always blocked with 503 without tripping a + /// global lockdown. Compared verbatim against the resolved client id. + /// Default empty. + /// + public string[] BlockedIps { get; set; } = []; + + /// + /// 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. + /// + [Range(1, 1000)] + public int MaxIncidents { get; set; } = 100; + + /// + /// Enables POST /shield/release. Default false. + /// + public bool ReleaseEnabled { get; set; } = false; + + /// + /// Shared secret required in the X-Shield-Release header to unlock. + /// The endpoint answers 404 unless enabled with a non-empty token. + /// Provide via environment/secret store, never commit it. + /// + public string ReleaseToken { get; set; } = string.Empty; +} 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), + ]; +} diff --git a/src/Plugins/Solutions/Shield/Security/ReleaseGuard.cs b/src/Plugins/Solutions/Shield/Security/ReleaseGuard.cs new file mode 100644 index 0000000..cb5105e --- /dev/null +++ b/src/Plugins/Solutions/Shield/Security/ReleaseGuard.cs @@ -0,0 +1,50 @@ +using System.Security.Cryptography; +using System.Text; +using Shield.Options; + +namespace Shield.Security; + +/// +/// Token gate for the unlock surfaces (HTTP header, gRPC metadata). +/// Disabled or tokenless configuration never authorizes. +/// +public static class ReleaseGuard +{ + /// + /// HTTP header carrying the release token. + /// + public const string HttpHeader = "X-Shield-Release"; + + /// + /// gRPC metadata key carrying the release token (lowercase per HTTP/2). + /// + public const string MetadataKey = "x-shield-release"; + + /// + /// Whether the endpoint may exist at all: enabled with a non-empty token. + /// + public static bool IsEnabled(ShieldOptions config) => + config.ReleaseEnabled && !string.IsNullOrEmpty(config.ReleaseToken); + + /// Operator reads require the configured token even when release is disabled. + public static bool IsOperatorEnabled(ShieldOptions config) => + !string.IsNullOrEmpty(config.ReleaseToken); + + public static bool IsOperatorAuthorized(ShieldOptions config, string? provided) + { + if (!IsOperatorEnabled(config) || string.IsNullOrEmpty(provided)) + return false; + + return CryptographicOperations.FixedTimeEquals( + Encoding.UTF8.GetBytes(config.ReleaseToken), + Encoding.UTF8.GetBytes(provided)); + } + + /// + /// Constant-time token check. False when disabled. + /// + public static bool IsAuthorized(ShieldOptions config, string? provided) + { + return IsEnabled(config) && IsOperatorAuthorized(config, provided); + } +} 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..40f1a37 --- /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, 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( + id: "authkit.shield", + version: "1.0.0", + tags: ["security", "critical"], + dependsOn: [], + capabilities: ["security"], + name: "Shield", + displayName: "Shield", + 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://github.com/AuthKits/AuthKit.Server", + repositoryUrl: "https://github.com/AuthKits/AuthKit.Server" +)] +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/State/Counters/SlidingWindowCounter.cs b/src/Plugins/Solutions/Shield/State/Counters/SlidingWindowCounter.cs new file mode 100644 index 0000000..6108a04 --- /dev/null +++ b/src/Plugins/Solutions/Shield/State/Counters/SlidingWindowCounter.cs @@ -0,0 +1,82 @@ +namespace Shield.State.Counters; + +/// 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 LinkedList _recency = new(); + private int _recordsSincePrune; + + /// + /// 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 (++_recordsSincePrune >= PruneInterval) + { + _recordsSincePrune = 0; + PruneExpired(cutoff); + } + + if (!_buckets.TryGetValue(clientId, out var bucket)) + { + while (_buckets.Count >= maxTrackedClients) + Remove(_recency.First!.Value); + + bucket = new ClientBucket(_recency.AddLast(clientId)); + _buckets.Add(clientId, bucket); + } + else + { + _recency.Remove(bucket.RecencyNode); + _recency.AddLast(bucket.RecencyNode); + } + + while (bucket.Timestamps.Count > 0 && bucket.Timestamps.Peek() < cutoff) + bucket.Timestamps.Dequeue(); + bucket.Timestamps.Enqueue(now); + return bucket.Timestamps.Count; + } + } + + /// + /// Number of retained clients (including entries not yet pruned after expiry). + /// + public int ClientCount + { + get + { + lock (_sync) + 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 new file mode 100644 index 0000000..bb44fed --- /dev/null +++ b/src/Plugins/Solutions/Shield/State/IShieldState.cs @@ -0,0 +1,89 @@ +namespace Shield.State; + +/// +/// Thread-safe singleton holding per-client counters, the lockdown state +/// machine, metrics, and a bounded incident log shared by the HTTP middleware +/// and the gRPC interceptor. +/// +public interface IShieldState +{ + /// + /// Returns true while a lockdown is active, with its expiry and reason. + /// Expired lockdowns are cleared lazily. + /// + bool IsLockdownActive(out DateTimeOffset lockedUntil, out string? reason); + + /// + /// Enters lockdown for and records an incident. + /// Returns false when a lockdown is already active. + /// + 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. + /// + void ReleaseLockdown(int maxIncidents); + + /// + /// Records a request from and returns the number + /// of that client's requests inside the sliding window. + /// + 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/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; + } + } +} 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)); +} 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..ea935ea --- /dev/null +++ b/src/Plugins/Solutions/Shield/State/ShieldState.cs @@ -0,0 +1,235 @@ +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(); + private readonly Lock _clientSync = new(); + private readonly Dictionary _blockedClients = new(StringComparer.Ordinal); + private readonly LinkedList _blockedOrder = 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; + } + + 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. + /// + 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; + } + + 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/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; + } +} diff --git a/src/Plugins/Solutions/Shield/manifest.json b/src/Plugins/Solutions/Shield/manifest.json new file mode 100644 index 0000000..8cdba78 --- /dev/null +++ b/src/Plugins/Solutions/Shield/manifest.json @@ -0,0 +1,23 @@ +{ + "Id": "authkit.shield", + "Name": "Shield", + "DisplayName": "Shield", + "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://github.com/AuthKits/AuthKit.Server", + "RepositoryUrl": "https://github.com/AuthKits/AuthKit.Server", + "Tags": [ + "security", + "critical" + ], + "Priority": 0, + "IsEnabled": true, + "Capabilities": [ + "security" + ], + "MinHostVersion": null, + "DependsOn": [] +} 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"); + } +} 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")); + } +}