audit, advisories and doctors/setup work
This commit is contained in:
@@ -1,3 +1,4 @@
|
||||
using System.Net.Http.Headers;
|
||||
using System.Net.Http.Json;
|
||||
using System.Runtime.CompilerServices;
|
||||
using System.Text.Json;
|
||||
@@ -11,10 +12,21 @@ namespace StellaOps.Provcache;
|
||||
/// </summary>
|
||||
public sealed class HttpChunkFetcher : ILazyEvidenceFetcher, IDisposable
|
||||
{
|
||||
/// <summary>
|
||||
/// Named client for use with IHttpClientFactory.
|
||||
/// </summary>
|
||||
public const string HttpClientName = "provcache-lazy-fetch";
|
||||
|
||||
private static readonly string[] DefaultSchemes = ["https", "http"];
|
||||
|
||||
private readonly HttpClient _httpClient;
|
||||
private readonly bool _ownsClient;
|
||||
private readonly ILogger<HttpChunkFetcher> _logger;
|
||||
private readonly JsonSerializerOptions _jsonOptions;
|
||||
private readonly LazyFetchHttpOptions _options;
|
||||
private readonly IReadOnlyList<string> _allowedHosts;
|
||||
private readonly IReadOnlySet<string> _allowedSchemes;
|
||||
private readonly bool _allowAllHosts;
|
||||
|
||||
/// <inheritdoc />
|
||||
public string FetcherType => "http";
|
||||
@@ -23,9 +35,15 @@ public sealed class HttpChunkFetcher : ILazyEvidenceFetcher, IDisposable
|
||||
/// Creates an HTTP chunk fetcher with the specified base URL.
|
||||
/// </summary>
|
||||
/// <param name="baseUrl">The base URL of the Stella API.</param>
|
||||
/// <param name="httpClientFactory">HTTP client factory.</param>
|
||||
/// <param name="logger">Logger instance.</param>
|
||||
public HttpChunkFetcher(Uri baseUrl, ILogger<HttpChunkFetcher> logger)
|
||||
: this(CreateClient(baseUrl), ownsClient: true, logger)
|
||||
/// <param name="options">Lazy fetch HTTP options.</param>
|
||||
public HttpChunkFetcher(
|
||||
Uri baseUrl,
|
||||
IHttpClientFactory httpClientFactory,
|
||||
ILogger<HttpChunkFetcher> logger,
|
||||
LazyFetchHttpOptions? options = null)
|
||||
: this(CreateClient(httpClientFactory, baseUrl), ownsClient: false, logger, options)
|
||||
{
|
||||
}
|
||||
|
||||
@@ -35,25 +53,149 @@ public sealed class HttpChunkFetcher : ILazyEvidenceFetcher, IDisposable
|
||||
/// <param name="httpClient">The HTTP client to use.</param>
|
||||
/// <param name="ownsClient">Whether this fetcher owns the client lifecycle.</param>
|
||||
/// <param name="logger">Logger instance.</param>
|
||||
public HttpChunkFetcher(HttpClient httpClient, bool ownsClient, ILogger<HttpChunkFetcher> logger)
|
||||
/// <param name="options">Lazy fetch HTTP options.</param>
|
||||
public HttpChunkFetcher(
|
||||
HttpClient httpClient,
|
||||
bool ownsClient,
|
||||
ILogger<HttpChunkFetcher> logger,
|
||||
LazyFetchHttpOptions? options = null)
|
||||
{
|
||||
_httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient));
|
||||
_ownsClient = ownsClient;
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_options = options ?? new LazyFetchHttpOptions();
|
||||
_jsonOptions = new JsonSerializerOptions
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
PropertyNameCaseInsensitive = true
|
||||
};
|
||||
|
||||
var baseAddress = _httpClient.BaseAddress
|
||||
?? throw new InvalidOperationException("HttpChunkFetcher requires a BaseAddress on the HTTP client.");
|
||||
|
||||
_allowedSchemes = NormalizeSchemes(_options.AllowedSchemes);
|
||||
_allowedHosts = NormalizeHosts(_options.AllowedHosts, baseAddress.Host, out _allowAllHosts);
|
||||
|
||||
ValidateBaseAddress(baseAddress);
|
||||
ApplyClientConfiguration();
|
||||
}
|
||||
|
||||
private static HttpClient CreateClient(Uri baseUrl)
|
||||
private static HttpClient CreateClient(IHttpClientFactory httpClientFactory, Uri baseUrl)
|
||||
{
|
||||
var client = new HttpClient { BaseAddress = baseUrl };
|
||||
client.DefaultRequestHeaders.Add("Accept", "application/json");
|
||||
ArgumentNullException.ThrowIfNull(httpClientFactory);
|
||||
ArgumentNullException.ThrowIfNull(baseUrl);
|
||||
|
||||
var client = httpClientFactory.CreateClient(HttpClientName);
|
||||
client.BaseAddress = baseUrl;
|
||||
return client;
|
||||
}
|
||||
|
||||
private void ApplyClientConfiguration()
|
||||
{
|
||||
var timeout = _options.Timeout;
|
||||
if (timeout <= TimeSpan.Zero || timeout == Timeout.InfiniteTimeSpan)
|
||||
{
|
||||
throw new InvalidOperationException("Lazy fetch HTTP timeout must be a positive, non-infinite duration.");
|
||||
}
|
||||
|
||||
if (_httpClient.Timeout == Timeout.InfiniteTimeSpan || _httpClient.Timeout > timeout)
|
||||
{
|
||||
_httpClient.Timeout = timeout;
|
||||
}
|
||||
|
||||
if (!_httpClient.DefaultRequestHeaders.Accept.Any(header =>
|
||||
string.Equals(header.MediaType, "application/json", StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
_httpClient.DefaultRequestHeaders.Accept.Add(
|
||||
new MediaTypeWithQualityHeaderValue("application/json"));
|
||||
}
|
||||
}
|
||||
|
||||
private void ValidateBaseAddress(Uri baseAddress)
|
||||
{
|
||||
if (!baseAddress.IsAbsoluteUri)
|
||||
{
|
||||
throw new InvalidOperationException("Lazy fetch base URL must be absolute.");
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(baseAddress.UserInfo))
|
||||
{
|
||||
throw new InvalidOperationException("Lazy fetch base URL must not include user info.");
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(baseAddress.Host))
|
||||
{
|
||||
throw new InvalidOperationException("Lazy fetch base URL must include a host.");
|
||||
}
|
||||
|
||||
if (!_allowedSchemes.Contains(baseAddress.Scheme))
|
||||
{
|
||||
throw new InvalidOperationException($"Lazy fetch base URL scheme '{baseAddress.Scheme}' is not allowed.");
|
||||
}
|
||||
|
||||
if (!_allowAllHosts && !IsHostAllowed(baseAddress.Host, _allowedHosts))
|
||||
{
|
||||
throw new InvalidOperationException($"Lazy fetch base URL host '{baseAddress.Host}' is not allowlisted.");
|
||||
}
|
||||
}
|
||||
|
||||
private static IReadOnlySet<string> NormalizeSchemes(IList<string> schemes)
|
||||
{
|
||||
var normalized = schemes
|
||||
.Where(s => !string.IsNullOrWhiteSpace(s))
|
||||
.Select(s => s.Trim())
|
||||
.ToArray();
|
||||
|
||||
if (normalized.Length == 0)
|
||||
{
|
||||
normalized = DefaultSchemes;
|
||||
}
|
||||
|
||||
return new HashSet<string>(normalized, StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private static IReadOnlyList<string> NormalizeHosts(
|
||||
IList<string> hosts,
|
||||
string baseHost,
|
||||
out bool allowAllHosts)
|
||||
{
|
||||
var normalized = hosts
|
||||
.Where(h => !string.IsNullOrWhiteSpace(h))
|
||||
.Select(h => h.Trim())
|
||||
.ToList();
|
||||
|
||||
allowAllHosts = normalized.Any(h => string.Equals(h, "*", StringComparison.Ordinal));
|
||||
|
||||
if (!allowAllHosts && normalized.Count == 0)
|
||||
{
|
||||
normalized.Add(baseHost);
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
private static bool IsHostAllowed(string host, IReadOnlyList<string> allowedHosts)
|
||||
{
|
||||
foreach (var allowed in allowedHosts)
|
||||
{
|
||||
if (string.Equals(allowed, host, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (allowed.StartsWith("*.", StringComparison.Ordinal))
|
||||
{
|
||||
var suffix = allowed[1..];
|
||||
if (host.EndsWith(suffix, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<FetchedChunk?> FetchChunkAsync(
|
||||
string proofRoot,
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace StellaOps.Provcache;
|
||||
|
||||
/// <summary>
|
||||
/// Options for HTTP lazy evidence fetching.
|
||||
/// </summary>
|
||||
public sealed class LazyFetchHttpOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Configuration section name under Provcache.
|
||||
/// </summary>
|
||||
public const string SectionName = "LazyFetchHttp";
|
||||
|
||||
/// <summary>
|
||||
/// HTTP timeout for fetch requests.
|
||||
/// Default: 10 seconds.
|
||||
/// </summary>
|
||||
[Range(typeof(TimeSpan), "00:00:01", "00:05:00", ErrorMessage = "Timeout must be between 1 second and 5 minutes")]
|
||||
public TimeSpan Timeout { get; set; } = TimeSpan.FromSeconds(10);
|
||||
|
||||
/// <summary>
|
||||
/// Allowlisted hostnames for HTTP fetches.
|
||||
/// Supports exact match and "*.example.com" suffix entries.
|
||||
/// </summary>
|
||||
public IList<string> AllowedHosts { get; } = new List<string>();
|
||||
|
||||
/// <summary>
|
||||
/// Allowlisted schemes for HTTP fetches.
|
||||
/// When empty, defaults to http and https.
|
||||
/// </summary>
|
||||
public IList<string> AllowedSchemes { get; } = new List<string>();
|
||||
}
|
||||
Reference in New Issue
Block a user