up
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
AOC Guard CI / aoc-guard (push) Has been cancelled
AOC Guard CI / aoc-verify (push) Has been cancelled
Concelier Attestation Tests / attestation-tests (push) Has been cancelled
Export Center CI / export-ci (push) Has been cancelled
Notify Smoke Test / Notify Unit Tests (push) Has been cancelled
Notify Smoke Test / Notifier Service Tests (push) Has been cancelled
Notify Smoke Test / Notification Smoke Test (push) Has been cancelled
Policy Lint & Smoke / policy-lint (push) Has been cancelled
Scanner Analyzers / Discover Analyzers (push) Has been cancelled
Scanner Analyzers / Build Analyzers (push) Has been cancelled
Scanner Analyzers / Test Language Analyzers (push) Has been cancelled
Scanner Analyzers / Validate Test Fixtures (push) Has been cancelled
Scanner Analyzers / Verify Deterministic Output (push) Has been cancelled
Signals CI & Image / signals-ci (push) Has been cancelled
Signals Reachability Scoring & Events / reachability-smoke (push) Has been cancelled
Signals Reachability Scoring & Events / sign-and-upload (push) Has been cancelled

This commit is contained in:
StellaOps Bot
2025-12-13 00:20:26 +02:00
parent e1f1bef4c1
commit 564df71bfb
2376 changed files with 334389 additions and 328032 deletions

View File

@@ -1,8 +1,8 @@
using System;
namespace StellaOps.Scheduler.WebService.EventWebhooks;
public interface IWebhookRateLimiter
{
bool TryAcquire(string key, int limit, TimeSpan window, out TimeSpan retryAfter);
}
using System;
namespace StellaOps.Scheduler.WebService.EventWebhooks;
public interface IWebhookRateLimiter
{
bool TryAcquire(string key, int limit, TimeSpan window, out TimeSpan retryAfter);
}

View File

@@ -1,107 +1,107 @@
using System;
using System.Security.Cryptography;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using StellaOps.Scheduler.WebService.Options;
namespace StellaOps.Scheduler.WebService.EventWebhooks;
public interface IWebhookRequestAuthenticator
{
Task<WebhookAuthenticationResult> AuthenticateAsync(HttpContext context, ReadOnlyMemory<byte> body, SchedulerWebhookOptions options, CancellationToken cancellationToken);
}
internal sealed class WebhookRequestAuthenticator : IWebhookRequestAuthenticator
{
private readonly ILogger<WebhookRequestAuthenticator> _logger;
public WebhookRequestAuthenticator(
ILogger<WebhookRequestAuthenticator> logger)
{
_logger = logger;
}
public async Task<WebhookAuthenticationResult> AuthenticateAsync(HttpContext context, ReadOnlyMemory<byte> body, SchedulerWebhookOptions options, CancellationToken cancellationToken)
{
if (!options.Enabled)
{
return WebhookAuthenticationResult.Success();
}
if (options.RequireClientCertificate)
{
var certificate = context.Connection.ClientCertificate ?? await context.Connection.GetClientCertificateAsync(cancellationToken).ConfigureAwait(false);
if (certificate is null)
{
_logger.LogWarning("Webhook {Name} rejected request without client certificate.", options.Name);
return WebhookAuthenticationResult.Fail(StatusCodes.Status401Unauthorized, "Client certificate required.");
}
}
if (!string.IsNullOrWhiteSpace(options.HmacSecret))
{
var headerName = string.IsNullOrWhiteSpace(options.SignatureHeader) ? "X-Scheduler-Signature" : options.SignatureHeader;
if (!context.Request.Headers.TryGetValue(headerName, out var signatureValues))
{
_logger.LogWarning("Webhook {Name} rejected request missing signature header {Header}.", options.Name, headerName);
return WebhookAuthenticationResult.Fail(StatusCodes.Status401Unauthorized, "Missing signature header.");
}
var providedSignature = signatureValues.ToString();
if (string.IsNullOrWhiteSpace(providedSignature))
{
return WebhookAuthenticationResult.Fail(StatusCodes.Status401Unauthorized, "Signature header is empty.");
}
if (!VerifySignature(body.Span, options.HmacSecret!, providedSignature))
{
_logger.LogWarning("Webhook {Name} rejected request with invalid signature.", options.Name);
return WebhookAuthenticationResult.Fail(StatusCodes.Status401Unauthorized, "Invalid signature.");
}
}
return WebhookAuthenticationResult.Success();
}
private static bool VerifySignature(ReadOnlySpan<byte> payload, string secret, string providedSignature)
{
byte[] secretBytes;
try
{
secretBytes = Convert.FromBase64String(secret);
}
catch (FormatException)
{
try
{
secretBytes = Convert.FromHexString(secret);
}
catch (FormatException)
{
secretBytes = Encoding.UTF8.GetBytes(secret);
}
}
using var hmac = new HMACSHA256(secretBytes);
var hash = hmac.ComputeHash(payload.ToArray());
var computedSignature = "sha256=" + Convert.ToHexString(hash).ToLowerInvariant();
return CryptographicOperations.FixedTimeEquals(
Encoding.UTF8.GetBytes(computedSignature),
Encoding.UTF8.GetBytes(providedSignature.Trim()));
}
}
public readonly record struct WebhookAuthenticationResult(bool Succeeded, int StatusCode, string? Message)
{
public static WebhookAuthenticationResult Success() => new(true, StatusCodes.Status200OK, null);
public static WebhookAuthenticationResult Fail(int statusCode, string message) => new(false, statusCode, message);
public IResult ToResult()
=> Succeeded ? Results.Ok() : Results.Json(new { error = Message ?? "Unauthorized" }, statusCode: StatusCode);
}
using System;
using System.Security.Cryptography;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using StellaOps.Scheduler.WebService.Options;
namespace StellaOps.Scheduler.WebService.EventWebhooks;
public interface IWebhookRequestAuthenticator
{
Task<WebhookAuthenticationResult> AuthenticateAsync(HttpContext context, ReadOnlyMemory<byte> body, SchedulerWebhookOptions options, CancellationToken cancellationToken);
}
internal sealed class WebhookRequestAuthenticator : IWebhookRequestAuthenticator
{
private readonly ILogger<WebhookRequestAuthenticator> _logger;
public WebhookRequestAuthenticator(
ILogger<WebhookRequestAuthenticator> logger)
{
_logger = logger;
}
public async Task<WebhookAuthenticationResult> AuthenticateAsync(HttpContext context, ReadOnlyMemory<byte> body, SchedulerWebhookOptions options, CancellationToken cancellationToken)
{
if (!options.Enabled)
{
return WebhookAuthenticationResult.Success();
}
if (options.RequireClientCertificate)
{
var certificate = context.Connection.ClientCertificate ?? await context.Connection.GetClientCertificateAsync(cancellationToken).ConfigureAwait(false);
if (certificate is null)
{
_logger.LogWarning("Webhook {Name} rejected request without client certificate.", options.Name);
return WebhookAuthenticationResult.Fail(StatusCodes.Status401Unauthorized, "Client certificate required.");
}
}
if (!string.IsNullOrWhiteSpace(options.HmacSecret))
{
var headerName = string.IsNullOrWhiteSpace(options.SignatureHeader) ? "X-Scheduler-Signature" : options.SignatureHeader;
if (!context.Request.Headers.TryGetValue(headerName, out var signatureValues))
{
_logger.LogWarning("Webhook {Name} rejected request missing signature header {Header}.", options.Name, headerName);
return WebhookAuthenticationResult.Fail(StatusCodes.Status401Unauthorized, "Missing signature header.");
}
var providedSignature = signatureValues.ToString();
if (string.IsNullOrWhiteSpace(providedSignature))
{
return WebhookAuthenticationResult.Fail(StatusCodes.Status401Unauthorized, "Signature header is empty.");
}
if (!VerifySignature(body.Span, options.HmacSecret!, providedSignature))
{
_logger.LogWarning("Webhook {Name} rejected request with invalid signature.", options.Name);
return WebhookAuthenticationResult.Fail(StatusCodes.Status401Unauthorized, "Invalid signature.");
}
}
return WebhookAuthenticationResult.Success();
}
private static bool VerifySignature(ReadOnlySpan<byte> payload, string secret, string providedSignature)
{
byte[] secretBytes;
try
{
secretBytes = Convert.FromBase64String(secret);
}
catch (FormatException)
{
try
{
secretBytes = Convert.FromHexString(secret);
}
catch (FormatException)
{
secretBytes = Encoding.UTF8.GetBytes(secret);
}
}
using var hmac = new HMACSHA256(secretBytes);
var hash = hmac.ComputeHash(payload.ToArray());
var computedSignature = "sha256=" + Convert.ToHexString(hash).ToLowerInvariant();
return CryptographicOperations.FixedTimeEquals(
Encoding.UTF8.GetBytes(computedSignature),
Encoding.UTF8.GetBytes(providedSignature.Trim()));
}
}
public readonly record struct WebhookAuthenticationResult(bool Succeeded, int StatusCode, string? Message)
{
public static WebhookAuthenticationResult Success() => new(true, StatusCodes.Status200OK, null);
public static WebhookAuthenticationResult Fail(int statusCode, string message) => new(false, statusCode, message);
public IResult ToResult()
=> Succeeded ? Results.Ok() : Results.Json(new { error = Message ?? "Unauthorized" }, statusCode: StatusCode);
}

View File

@@ -1,63 +1,63 @@
using System;
using System.Collections.Generic;
using Microsoft.Extensions.Caching.Memory;
namespace StellaOps.Scheduler.WebService.EventWebhooks;
internal sealed class InMemoryWebhookRateLimiter : IWebhookRateLimiter, IDisposable
{
private readonly MemoryCache _cache = new(new MemoryCacheOptions());
private readonly object _mutex = new();
public bool TryAcquire(string key, int limit, TimeSpan window, out TimeSpan retryAfter)
{
if (limit <= 0)
{
retryAfter = TimeSpan.Zero;
return true;
}
retryAfter = TimeSpan.Zero;
var now = DateTimeOffset.UtcNow;
lock (_mutex)
{
if (!_cache.TryGetValue(key, out Queue<DateTimeOffset>? hits))
{
hits = new Queue<DateTimeOffset>();
_cache.Set(key, hits, new MemoryCacheEntryOptions
{
SlidingExpiration = window.Add(window)
});
}
hits ??= new Queue<DateTimeOffset>();
while (hits.Count > 0 && now - hits.Peek() > window)
{
hits.Dequeue();
}
if (hits.Count >= limit)
{
var oldest = hits.Peek();
retryAfter = (oldest + window) - now;
if (retryAfter < TimeSpan.Zero)
{
retryAfter = TimeSpan.Zero;
}
return false;
}
hits.Enqueue(now);
return true;
}
}
public void Dispose()
{
_cache.Dispose();
}
}
using System;
using System.Collections.Generic;
using Microsoft.Extensions.Caching.Memory;
namespace StellaOps.Scheduler.WebService.EventWebhooks;
internal sealed class InMemoryWebhookRateLimiter : IWebhookRateLimiter, IDisposable
{
private readonly MemoryCache _cache = new(new MemoryCacheOptions());
private readonly object _mutex = new();
public bool TryAcquire(string key, int limit, TimeSpan window, out TimeSpan retryAfter)
{
if (limit <= 0)
{
retryAfter = TimeSpan.Zero;
return true;
}
retryAfter = TimeSpan.Zero;
var now = DateTimeOffset.UtcNow;
lock (_mutex)
{
if (!_cache.TryGetValue(key, out Queue<DateTimeOffset>? hits))
{
hits = new Queue<DateTimeOffset>();
_cache.Set(key, hits, new MemoryCacheEntryOptions
{
SlidingExpiration = window.Add(window)
});
}
hits ??= new Queue<DateTimeOffset>();
while (hits.Count > 0 && now - hits.Peek() > window)
{
hits.Dequeue();
}
if (hits.Count >= limit)
{
var oldest = hits.Peek();
retryAfter = (oldest + window) - now;
if (retryAfter < TimeSpan.Zero)
{
retryAfter = TimeSpan.Zero;
}
return false;
}
hits.Enqueue(now);
return true;
}
}
public void Dispose()
{
_cache.Dispose();
}
}