eabf270d71
Resolve all 622 issues flagged by the enhanced CommentChecker: add missing <returns> tags (incl. the standard phrasing on non-generic Task methods), add missing <summary> tags, and replace misused/redundant <inheritdoc/> on members that override or implement nothing with real documentation. Documentation-only — no behavior change; solution builds clean.
159 lines
6.6 KiB
C#
159 lines
6.6 KiB
C#
using System.Collections.Concurrent;
|
|
|
|
namespace ZB.MOM.WW.ScadaBridge.Transport.Import;
|
|
|
|
/// <summary>
|
|
/// Transport-004: in-memory sliding-window rate limiter for bundle-unlock passphrase
|
|
/// attempts, keyed by client IP. The design doc (§11) declares a per-IP-per-hour cap
|
|
/// (default 10) as a brute-force defence against a stolen bundle; this class is the
|
|
/// minimal server-side implementation.
|
|
/// <para>
|
|
/// Algorithm: each key (an IP string, or any opaque caller identifier) holds a queue
|
|
/// of attempt timestamps. <see cref="TryRegisterAttempt"/> first prunes entries older
|
|
/// than the configured window, then either appends the current timestamp and returns
|
|
/// <c>true</c> if the count is still under the threshold, or refuses to append and
|
|
/// returns <c>false</c> if appending would cross it. The trailing-hour count is the
|
|
/// queue length post-prune.
|
|
/// </para>
|
|
/// <para>
|
|
/// Storage is a process-local <see cref="ConcurrentDictionary{TKey,TValue}"/>. The
|
|
/// counters do not survive a host restart — that is by design: a restart resets the
|
|
/// brute-force window in favour of legitimate operators after an outage. Persisting
|
|
/// the counters would require a multi-node consensus story the simple in-memory
|
|
/// design avoids.
|
|
/// </para>
|
|
/// <para>
|
|
/// Thread-safety: the per-key queue is protected by a per-key lock taken inside the
|
|
/// dictionary value; the dictionary itself is concurrent. The class is safe to call
|
|
/// from multiple threads / circuits without external coordination.
|
|
/// </para>
|
|
/// </summary>
|
|
public sealed class BundleUnlockRateLimiter
|
|
{
|
|
/// <summary>
|
|
/// Default trailing window. The design doc's "per-IP-per-hour" wording fixes this
|
|
/// at 60 minutes; a constructor overload accepts a different window for tests.
|
|
/// </summary>
|
|
public static readonly TimeSpan DefaultWindow = TimeSpan.FromHours(1);
|
|
|
|
private readonly ConcurrentDictionary<string, AttemptBucket> _buckets = new(StringComparer.OrdinalIgnoreCase);
|
|
private readonly TimeProvider _timeProvider;
|
|
private readonly TimeSpan _window;
|
|
|
|
/// <summary>
|
|
/// Initializes a new <see cref="BundleUnlockRateLimiter"/> using the documented
|
|
/// 1-hour trailing window and the system clock. Suitable for production DI.
|
|
/// </summary>
|
|
public BundleUnlockRateLimiter() : this(TimeProvider.System, DefaultWindow)
|
|
{
|
|
}
|
|
|
|
/// <summary>
|
|
/// Initializes a new <see cref="BundleUnlockRateLimiter"/> with an injected clock
|
|
/// (for deterministic tests) and a custom trailing window.
|
|
/// </summary>
|
|
/// <param name="timeProvider">Clock used for both timestamping new attempts and pruning expired ones.</param>
|
|
/// <param name="window">Trailing window over which attempts are counted (typically 1 hour).</param>
|
|
public BundleUnlockRateLimiter(TimeProvider timeProvider, TimeSpan window)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(timeProvider);
|
|
if (window <= TimeSpan.Zero)
|
|
{
|
|
throw new ArgumentOutOfRangeException(nameof(window), "Window must be positive.");
|
|
}
|
|
|
|
_timeProvider = timeProvider;
|
|
_window = window;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Attempts to register a new passphrase try against the configured per-key
|
|
/// limit. Returns <c>true</c> when the attempt is permitted (and recorded);
|
|
/// returns <c>false</c> when the key has exhausted its budget for the trailing
|
|
/// window — the caller should reject the unlock request with a 429-equivalent.
|
|
/// </summary>
|
|
/// <param name="clientKey">
|
|
/// Opaque caller identifier — typically the remote IP, but any stable per-source
|
|
/// string is acceptable (the limiter does not interpret it). Trimmed for matching.
|
|
/// </param>
|
|
/// <param name="maxAttemptsPerWindow">
|
|
/// The trailing-window cap (e.g. <c>TransportOptions.MaxUnlockAttemptsPerIpPerHour</c>,
|
|
/// default 10). Must be at least 1.
|
|
/// </param>
|
|
/// <returns>
|
|
/// <c>true</c> if the attempt was registered (within budget); <c>false</c> if the
|
|
/// caller has already used <paramref name="maxAttemptsPerWindow"/> within the
|
|
/// trailing window.
|
|
/// </returns>
|
|
public bool TryRegisterAttempt(string clientKey, int maxAttemptsPerWindow)
|
|
{
|
|
ArgumentException.ThrowIfNullOrWhiteSpace(clientKey);
|
|
if (maxAttemptsPerWindow < 1)
|
|
{
|
|
throw new ArgumentOutOfRangeException(
|
|
nameof(maxAttemptsPerWindow), "Limit must be at least 1.");
|
|
}
|
|
|
|
var bucket = _buckets.GetOrAdd(clientKey.Trim(), _ => new AttemptBucket());
|
|
var now = _timeProvider.GetUtcNow();
|
|
var cutoff = now - _window;
|
|
|
|
lock (bucket)
|
|
{
|
|
// Prune expired entries first so a caller that paused longer than the
|
|
// window starts the next round at zero — not penalised by stale rows.
|
|
while (bucket.Timestamps.Count > 0 && bucket.Timestamps.Peek() <= cutoff)
|
|
{
|
|
bucket.Timestamps.Dequeue();
|
|
}
|
|
|
|
if (bucket.Timestamps.Count >= maxAttemptsPerWindow)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
bucket.Timestamps.Enqueue(now);
|
|
return true;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Returns the number of recorded attempts for <paramref name="clientKey"/> still
|
|
/// within the trailing window. Primarily for tests / diagnostics; not part of the
|
|
/// hot-path.
|
|
/// </summary>
|
|
/// <param name="clientKey">Opaque caller identifier — typically the remote IP.</param>
|
|
/// <returns>The number of recorded attempts for the key that still fall within the trailing window.</returns>
|
|
public int GetAttemptCount(string clientKey)
|
|
{
|
|
ArgumentException.ThrowIfNullOrWhiteSpace(clientKey);
|
|
if (!_buckets.TryGetValue(clientKey.Trim(), out var bucket))
|
|
{
|
|
return 0;
|
|
}
|
|
|
|
var cutoff = _timeProvider.GetUtcNow() - _window;
|
|
lock (bucket)
|
|
{
|
|
while (bucket.Timestamps.Count > 0 && bucket.Timestamps.Peek() <= cutoff)
|
|
{
|
|
bucket.Timestamps.Dequeue();
|
|
}
|
|
|
|
return bucket.Timestamps.Count;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Per-key queue of attempt timestamps. A class (rather than a bare
|
|
/// <see cref="Queue{T}"/>) so the dictionary value identity is stable across
|
|
/// concurrent <see cref="ConcurrentDictionary{TKey,TValue}.GetOrAdd(TKey,Func{TKey,TValue})"/>
|
|
/// races — letting the per-bucket lock guard the queue mutations.
|
|
/// </summary>
|
|
private sealed class AttemptBucket
|
|
{
|
|
/// <summary>Ordered queue of attempt timestamps within the current trailing window.</summary>
|
|
public Queue<DateTimeOffset> Timestamps { get; } = new();
|
|
}
|
|
}
|