Repository navigation
feat: Shield attack detection and lockdown plugin #53
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
3e00d9c
b57e3e7
d7cb3af
b547619
6a01e4f
b26a623
b4621c9
c7469cf
014e4a1
ae921a1
1e69aca
f9cebd9
6c2546a
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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; | ||
|
|
||
| /// <summary> | ||
| /// Composition root for Shield services: validated options, shared state, | ||
| /// and detector strategies in evaluation order. | ||
| /// </summary> | ||
| public static class ShieldServices | ||
| { | ||
| /// <summary> | ||
| /// Registers Shield into the host DI container. | ||
| /// </summary> | ||
| public static IServiceCollection AddShield(this IServiceCollection services, IConfiguration configuration) | ||
| { | ||
| services.AddOptions<ShieldOptions>() | ||
| .Bind(configuration) | ||
| .ValidateDataAnnotations() | ||
| .ValidateOnStart(); | ||
|
|
||
| services.AddSingleton<IShieldState, ShieldState>(); | ||
| services.AddSingleton<IAttackDetector, AttackSignatureDetector>(); | ||
| services.AddSingleton<IAttackDetector, SuspiciousUserAgentDetector>(); | ||
| services.AddSingleton<IAttackDetector, OversizedRequestDetector>(); | ||
| services.AddSingleton<IAttackDetector, RateFloodDetector>(); | ||
| services.AddSingleton<IAttackDetector, AuthBruteForceDetector>(); | ||
| services.AddSingleton<IAttackDetector, EndpointScannerDetector>(); | ||
|
|
||
| return services; | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,29 @@ | ||
| using Microsoft.AspNetCore.Http; | ||
| using System.Net; | ||
|
|
||
| namespace Shield.Detection; | ||
|
|
||
| /// <summary> | ||
| /// 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 <c>X-Forwarded-For</c>, or the transport address. | ||
| /// </summary> | ||
| public static class ClientIdResolver | ||
| { | ||
| /// <summary> | ||
| /// Resolves the client id for the request. Never returns null or empty. | ||
| /// </summary> | ||
| 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(); | ||
| } | ||
|
Comment on lines
+18
to
+25
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Do not use the leftmost A reverse proxy appends the peer address to the existing
Select the address by trusted hop count from the right end of the header. Default the count to 1. Treat 0 as 1. If the header has fewer entries than the count, use Based on learnings: "do not blindly trust the leftmost or rightmost entry. Instead, use a configurable 'trusted proxy hops' count to pick the correct address counting from the right". 🔒️ Proposed fix
|
||
|
|
||
| return context.Connection.RemoteIpAddress?.ToString() ?? "unknown"; | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,62 @@ | ||
| namespace Shield.Detection; | ||
|
|
||
| /// <summary> | ||
| /// Normalized input for attack detectors: who is calling, what is targeted, | ||
| /// and when. | ||
| /// </summary> | ||
| public sealed record ShieldRequest( | ||
| string ClientId, | ||
| string Path, | ||
| string Query, | ||
| DateTimeOffset Timestamp, | ||
| string UserAgent = "", | ||
| long? ContentLength = null); | ||
|
|
||
| /// <summary> | ||
| /// What the host should do with a tripped signal. | ||
| /// </summary> | ||
| public enum ShieldAction | ||
| { | ||
| /// <summary> | ||
| /// Temporarily block the offending client; enough distinct offenders trigger global lockdown. | ||
| /// </summary> | ||
| Lockdown, | ||
|
|
||
| /// <summary> | ||
| /// Block only the offending request, without a global lockdown. | ||
| /// </summary> | ||
| BlockRequest, | ||
| } | ||
|
|
||
| /// <summary> | ||
| /// A detector verdict. Non-null means the detector tripped and the caller | ||
| /// should apply <see cref="Action"/> with <see cref="Reason"/>. | ||
| /// </summary> | ||
| public sealed record AttackSignal(string Sensor, string Reason, ShieldAction Action = ShieldAction.Lockdown); | ||
|
|
||
| /// <summary> | ||
| /// One attack-detection strategy. Implementations are pure policy: per-client | ||
| /// storage lives in <see cref="State.IShieldState"/> so HTTP and gRPC share it. | ||
| /// Detectors run in DI registration order; the first signal wins. | ||
| /// </summary> | ||
| public interface IAttackDetector | ||
| { | ||
| /// <summary> | ||
| /// Short sensor name used in incidents and diagnostics (e.g. "rate-flood"). | ||
| /// </summary> | ||
| string Sensor { get; } | ||
|
|
||
| /// <summary> | ||
| /// Evaluates the request. Returns a signal when the request trips the | ||
| /// detector, otherwise null. | ||
| /// </summary> | ||
| AttackSignal? Evaluate(ShieldRequest request, Options.ShieldOptions options); | ||
|
|
||
| /// <summary> | ||
| /// 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 <see cref="ShieldAction.Lockdown"/> signals are | ||
| /// honored here — the response is already sent. Default ignores responses. | ||
| /// </summary> | ||
| AttackSignal? ObserveResponse(ShieldRequest request, int statusCode, Options.ShieldOptions options) => null; | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,53 @@ | ||
| using Shield.State; | ||
|
|
||
| namespace Shield.Detection.Sensors; | ||
|
|
||
| /// <summary> | ||
| /// 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. | ||
| /// </summary> | ||
| 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", | ||
| "<script", "javascript:", | ||
| "/etc/passwd", "xp_cmdshell", | ||
| "' or '1'='1", "\" or \"1\"=\"1", | ||
| ]; | ||
|
|
||
| /// <summary> | ||
| /// Short sensor name used in incidents and diagnostics. | ||
| /// </summary> | ||
| public string Sensor => "attack-signature"; | ||
|
|
||
| /// <summary> | ||
| /// Evaluates the request against this sensor policy. | ||
| /// </summary> | ||
| public AttackSignal? Evaluate(ShieldRequest request, Options.ShieldOptions options) | ||
| { | ||
| if (!options.SignatureDetectionEnabled) | ||
| return null; | ||
|
|
||
| if (!ContainsSignature(request.Path + request.Query, options.ExtraSignatures)) | ||
| return null; | ||
|
|
||
| var hits = state.RecordViolation(request.ClientId, request.Timestamp, options.SignatureWindowSeconds, options.MaxTrackedClients); | ||
| return hits >= options.MaxSignatureHits | ||
| ? new AttackSignal(Sensor, $"attack signatures from {request.ClientId} ({hits} hits)") | ||
| : null; | ||
| } | ||
|
|
||
| internal static bool ContainsSignature(string target, string[]? extra = null) | ||
| { | ||
| if (Signatures.Any(signature => target.Contains(signature, StringComparison.OrdinalIgnoreCase))) | ||
| return true; | ||
|
|
||
| return extra?.Any(signature => | ||
| !string.IsNullOrWhiteSpace(signature) && | ||
| target.Contains(signature, StringComparison.OrdinalIgnoreCase)) == true; | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,37 @@ | ||
| using Microsoft.AspNetCore.Http; | ||
| using Shield.State; | ||
|
|
||
| namespace Shield.Detection.Sensors; | ||
|
|
||
| /// <summary> | ||
| /// 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 | ||
| /// client block applies to subsequent requests. | ||
| /// </summary> | ||
| public sealed class AuthBruteForceDetector(IShieldState state) : IAttackDetector | ||
| { | ||
| /// <summary> | ||
| /// Short sensor name used in incidents and diagnostics. | ||
| /// </summary> | ||
| public string Sensor => "auth-brute-force"; | ||
|
|
||
| /// <summary> | ||
| /// Evaluates the request against this sensor policy. | ||
| /// </summary> | ||
| public AttackSignal? Evaluate(ShieldRequest request, Options.ShieldOptions options) => null; | ||
|
|
||
| /// <summary> | ||
| /// Observes the completed response for this sensor policy. | ||
| /// </summary> | ||
| public AttackSignal? ObserveResponse(ShieldRequest request, int statusCode, Options.ShieldOptions options) | ||
| { | ||
| if (statusCode is not (StatusCodes.Status401Unauthorized or StatusCodes.Status403Forbidden)) | ||
| return null; | ||
|
|
||
| var count = state.RecordAuthFailure(request.ClientId, request.Timestamp, options.AuthFailureWindowSeconds, options.MaxTrackedClients); | ||
| return count >= options.MaxAuthFailures | ||
| ? new AttackSignal(Sensor, $"authentication failures from {request.ClientId} ({count} in {options.AuthFailureWindowSeconds}s)") | ||
| : null; | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,37 @@ | ||
| using Microsoft.AspNetCore.Http; | ||
| using Shield.State; | ||
|
|
||
| namespace Shield.Detection.Sensors; | ||
|
|
||
| /// <summary> | ||
| /// Trips when a single client collects too many 404s inside the window — the | ||
| /// directory-enumeration / vulnerability-probing shape | ||
| /// (<c>/.git/</c>, <c>/wp-admin/</c>, <c>/.env</c>). Observation happens after | ||
| /// the endpoint runs, so the client block applies to subsequent requests. | ||
| /// </summary> | ||
| public sealed class EndpointScannerDetector(IShieldState state) : IAttackDetector | ||
| { | ||
| /// <summary> | ||
| /// Short sensor name used in incidents and diagnostics. | ||
| /// </summary> | ||
| public string Sensor => "endpoint-scanner"; | ||
|
|
||
| /// <summary> | ||
| /// Evaluates the request against this sensor policy. | ||
| /// </summary> | ||
| public AttackSignal? Evaluate(ShieldRequest request, Options.ShieldOptions options) => null; | ||
|
|
||
| /// <summary> | ||
| /// Observes the completed response for this sensor policy. | ||
| /// </summary> | ||
| public AttackSignal? ObserveResponse(ShieldRequest request, int statusCode, Options.ShieldOptions options) | ||
| { | ||
| if (!options.ScannerDetectionEnabled || statusCode != StatusCodes.Status404NotFound) | ||
| return null; | ||
|
|
||
| var count = state.RecordProbe(request.ClientId, request.Timestamp, options.ScannerWindowSeconds, options.MaxTrackedClients); | ||
| return count >= options.MaxNotFoundProbes | ||
| ? new AttackSignal(Sensor, $"endpoint probing from {request.ClientId} ({count} missing paths in {options.ScannerWindowSeconds}s)") | ||
| : null; | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,34 @@ | ||
| namespace Shield.Detection.Sensors; | ||
|
|
||
| /// <summary> | ||
| /// Blocks (without a global lockdown) requests whose declared body or query | ||
| /// string exceeds the configured limits — the cheap-DoS / buffer-probe shape. | ||
| /// A single oversized request harms no one else, so it never trips a lockdown. | ||
| /// </summary> | ||
| public sealed class OversizedRequestDetector : IAttackDetector | ||
| { | ||
| /// <summary> | ||
| /// Short sensor name used in incidents and diagnostics. | ||
| /// </summary> | ||
| public string Sensor => "oversized-request"; | ||
|
|
||
| /// <summary> | ||
| /// Evaluates the request against this sensor policy. | ||
| /// </summary> | ||
| public AttackSignal? Evaluate(ShieldRequest request, Options.ShieldOptions options) | ||
| { | ||
| if (request.ContentLength is { } body && body > options.MaxBodyBytes) | ||
| return new AttackSignal( | ||
| Sensor, | ||
| $"oversized body from {request.ClientId} ({body} bytes)", | ||
| ShieldAction.BlockRequest); | ||
|
|
||
| if (request.Query.Length > options.MaxQueryLength) | ||
| return new AttackSignal( | ||
| Sensor, | ||
| $"oversized query from {request.ClientId} ({request.Query.Length} chars)", | ||
| ShieldAction.BlockRequest); | ||
|
|
||
| return null; | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,26 @@ | ||
| using Shield.State; | ||
|
|
||
| namespace Shield.Detection.Sensors; | ||
|
|
||
| /// <summary> | ||
| /// Trips when a single client exceeds the configured request rate inside the | ||
| /// sliding window. | ||
| /// </summary> | ||
| public sealed class RateFloodDetector(IShieldState state) : IAttackDetector | ||
| { | ||
| /// <summary> | ||
| /// Short sensor name used in incidents and diagnostics. | ||
| /// </summary> | ||
| public string Sensor => "rate-flood"; | ||
|
|
||
| /// <summary> | ||
| /// Evaluates the request against this sensor policy. | ||
| /// </summary> | ||
| public AttackSignal? Evaluate(ShieldRequest request, Options.ShieldOptions options) | ||
| { | ||
| var count = state.RecordRequest(request.ClientId, request.Timestamp, options.WindowSeconds, options.MaxTrackedClients); | ||
| return count > options.MaxRequestsPerWindow | ||
| ? new AttackSignal(Sensor, $"request flood from {request.ClientId} ({count} requests in {options.WindowSeconds}s)") | ||
| : null; | ||
| } | ||
| } |
Uh oh!
There was an error while loading. Please reload this page.