From d2fe143ffa07d8d7ea0ffd37186ed5fa22af5bf9 Mon Sep 17 00:00:00 2001 From: Donald Gray Date: Thu, 28 May 2026 17:06:17 +0100 Subject: [PATCH 1/3] Support X-Forwarded-Host and X-Forwarded-Path for IIIF URL generation Search API can be served via CloudFront custom domains that rewrite the host and path. EndpointHelpers.Resolve reads both headers once, validates the host against AllowedCustomHosts, extracts the effective job id from X-Forwarded-Path (by stripping the route prefix), and returns a ResolvedRequest(EffectiveId, SelfUrl, BaseUrl). All endpoint call sites collapse to a single Resolve call. TextAugmentedRequest gains an optional UrlId so the handler can use the forwarded id for service descriptor URLs while still loading artefacts by the canonical storage id. X-Forwarded-Proto is handled by ForwardedHeadersMiddleware; trust is restricted via KnownNetworks/KnownProxies config keys so on-prem deployments can pin trusted proxy IPs/CIDRs. Co-Authored-By: Claude Sonnet 4.6 --- Dockerfile.Builder | 2 + instructions/alternative-paths.md | 106 +++++++++++ .../Configuration/SearchApiOptions.cs | 8 + .../ServiceCollectionExtensions.cs | 57 +++++- .../Annotations/AnnotationEndpoints.cs | 12 +- .../Autocomplete/AutocompleteEndpoints.cs | 8 +- .../Features/EndpointHelpers.cs | 79 ++++++++- .../Features/Figures/FiguresEndpoints.cs | 4 +- .../Features/Pdf/PdfEndpoints.cs | 9 +- .../Features/Search/SearchEndpoints.cs | 8 +- .../TextAugmented/TextAugmentedEndpoints.cs | 8 +- .../TextAugmented/TextAugmentedQuery.cs | 14 +- src/TextServices.Search.Api/Program.cs | 2 + .../SearchApi/EndpointHelpersTests.cs | 165 ++++++++++++++++++ 14 files changed, 444 insertions(+), 38 deletions(-) create mode 100644 instructions/alternative-paths.md create mode 100644 src/TextServices.Tests/SearchApi/EndpointHelpersTests.cs diff --git a/Dockerfile.Builder b/Dockerfile.Builder index 2766e9b..ac464a0 100644 --- a/Dockerfile.Builder +++ b/Dockerfile.Builder @@ -14,6 +14,8 @@ RUN dotnet publish TextServices.Builder.Api/TextServices.Builder.Api.csproj \ -c Release -o /app/publish --no-restore FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS runtime + +RUN apt-get update && apt-get install -y libgssapi-krb5-2 libkrb5-3 krb5-user WORKDIR /app COPY --from=build /app/publish . EXPOSE 8080 diff --git a/instructions/alternative-paths.md b/instructions/alternative-paths.md new file mode 100644 index 0000000..4e16ea8 --- /dev/null +++ b/instructions/alternative-paths.md @@ -0,0 +1,106 @@ +## Path Rewrites + +By paths generated for the search api are: + +* `/search/v2/{**id}?q={term}` +* `/search/v1/{**id}?q={term}` +* `/autocomplete/v2/{**id}?q={term}` +* `/autocomplete/v1/{**id}?q={term}` +* `/annotations/lines/v1/{n}/{**id}` +* `/annotations/words/v1/{n}/{**id}` +* `/text-augmented/v3/{**id}` +* `/proxy/image?uri={uri}` +* `/text/v1/{**id}` +* `/pdf/v1/{**id}` +* `/identified/figures/{**id}` + +These are rendered onto generated Manifest using `{protocol}://{host}/{above-path}` + +### Canonical Paths + +This is the "as is" processing. + +Currently we have a `SearchBaseUrl` (say `https://search.default`). When generating a Manifest this is used to construct every `id`, so the above list of paths are appended to `SearchBaseUrl`. + +The important thing is the `{**id}` is _always_ the job-id, it can contain any number of slashes and is replaced in it's entirety. + +### Requirement + +We need to be able to have some degree of control over what paths are rendered when returning. To do so we will support X-Forwarded-Host and X-Forwarded-Path + +We need to be a way to be able to translate incoming requests, so that they reflect in the outgoing request - without adding any sort of rule-based _stuff_. + +### Solution + +One solution to this is `X-Forwarded-Proto`, `X-Forwarded-Host` (standard HTTP headers) and `X-Forwarded-Path` (non-standard). These will all be added by proxy (e.g. CloudFront), if there are rewrite rules in place. + +* `X-Forwarded-Proto` - configured via standard middleware. Ensure that the HttpContext has appropriate protocol. +* `X-Forwarded-Host` - will be used for the host if it is part of known whitelist. Added by proxy. +* `X-Forwarded-Path` - will be used if it is accompanied by a whitelisted `X-Forwarded-Host`. Added by proxy. + * This isn't perfect adds an degree of safety. You can only rewrite path + host if we expect the host. + * If we want to rewrite a path for canonical host it would need to be whitelisted, which feels like a safe trade-off + +### Examples + +Below examples work through requirements. Assume we're requesting the text-augmented adjunct, this looks at resulting value for `/autocomplete/v1` path. For all of these examples: +* Canonical hostname is `search.default` +* JobId is `2/cc/123` +* The actual http request that hits the search API is `https://search.default/text-augmented/v3/2/cc/123` + +| Incoming (maybe via proxy) | X-Forwarded-Host | X-Forwarded-Path | Autocomplete `id` | Notes | +| ------------------------------------------------- | ---------------- | -------------------------- | ----------------------------------------------- | -------------------------------------------------------------------------------------------------------------- | +| https://search.default/text-augmented/v3/2/cc/123 | | | https://search.default/autocomplete/v1/2/cc/123 | Default, no proxy | +| https://unknown.host/text-augmented/v3/2/cc/123 | unknown.host | | https://search.default/autocomplete/v1/2/cc/123 | x-forwarded-host but unknown | +| https://unknown.host/text-augmented/v3/2/cc/123 | | text-augmented/v3/2/cc/123 | https://search.default/autocomplete/v1/2/cc/123 | x-forwarded-path but no accompanying x-forwarded-host | +| https://unknown.host/text-augmented/v3/2/cc/123 | unknown.host | text-augmented/v3/2/cc/123 | https://search.default/autocomplete/v1/2/cc/123 | x-forwarded-path but accompanying x-forwarded-host is unknown | +| https://known.host/text-augmented/v3/2/cc/123 | known.host | | https://known.host/autocomplete/v1/2/cc/123 | x-forwarded-host is whitelisted | +| https://known.host/text-augmented/v3/cc/123 | known.host | text-augmented/v3/cc/123 | https://known.host/autocomplete/v1/cc/123 | x-forwarded-host is whitelisted and x-forwarded-path is set (crucially it is NOT the `id`) | +| https://known.host/text-augmented/v3/cc/123 | known.host | | https://known.host/autocomplete/v1/2/cc/123 | x-forwarded-host is whitelisted. x-forwarded-path not set so `id` is used. This would be a misconfigured proxy | + +> [!NOTE] +> Some points to now from above: +> * The above outlines how `id` path is constructed for autocomplete path on generated Manifest but the same process would apply for any generated `id` +> * The `X-Forwarded-Path` may contain a query parameter (e.g. for search results), this should be removed from ids. + +#### Implementation + +The rough implementation would be to use the `X-Forwarded-Path` to determine the `{**id}` element to use in generated paths. + +To do so (assuming `X-Forwarded-Path` is provided and valid) we will remove the current root (minus `{**id}`) from the start of the `X-Forwarded-Path`, this will yield the usable `id` for path generation. + +### Implementation + +All forwarded-header logic is centralised in `EndpointHelpers.Resolve` (`Features/EndpointHelpers.cs`), which reads `X-Forwarded-Host` and `X-Forwarded-Path` once and returns a `ResolvedRequest` record: + +```csharp +internal record ResolvedRequest(string EffectiveId, string SelfUrl, string BaseUrl); +``` + +* `EffectiveId` — the job id to use in generated URLs (extracted from `X-Forwarded-Path` when the host is whitelisted; otherwise the original route id). +* `SelfUrl` — absolute URL for the current endpoint, already incorporating the effective id and optional query term. +* `BaseUrl` — scheme + authority only; used by `TextAugmentedEndpoints` as the base for all cross-endpoint service URLs. + +Every endpoint calls `Resolve` once: + +```csharp +var resolved = EndpointHelpers.Resolve(options.Value, ctx, "search/v1/", id, q); +// resolved.SelfUrl → passed to the handler as the response @id +// resolved.BaseUrl → used by TextAugmented to build service descriptor URLs +// resolved.EffectiveId → passed to TextAugmentedRequest as UrlId (see below) +``` + +`X-Forwarded-Proto` is handled separately by `ForwardedHeadersMiddleware` (configured in `ServiceCollectionExtensions.ConfigureForwardedHeaders`), which sets `Request.Scheme`. Trusted sources are restricted via `KnownNetworks` / `KnownProxies` config keys. + +#### TextAugmented specifics + +`TextAugmentedHandler` builds cross-endpoint URLs using both a storage id (to load artefacts) and a URL id (to generate service descriptors). These differ when `X-Forwarded-Path` rewrites the id. `TextAugmentedRequest` carries both: + +```csharp +record TextAugmentedRequest(string Id, string SelfUrl, string SearchBaseUrl, string? UrlId = null) +``` + +The handler uses `UrlId ?? Id` for URL generation and `Id` for all storage lookups. The endpoint passes `resolved.EffectiveId` as `UrlId`. + +#### Allowlist configuration + +Permitted custom hosts are configured under `TextServices:AllowedCustomHosts` in `appsettings.json`. An empty array (the default) means both `X-Forwarded-Host` and `X-Forwarded-Path` are always ignored. \ No newline at end of file diff --git a/src/TextServices.Search.Api/Configuration/SearchApiOptions.cs b/src/TextServices.Search.Api/Configuration/SearchApiOptions.cs index de14da1..1291721 100644 --- a/src/TextServices.Search.Api/Configuration/SearchApiOptions.cs +++ b/src/TextServices.Search.Api/Configuration/SearchApiOptions.cs @@ -57,4 +57,12 @@ public class SearchApiOptions /// /// public bool AllowFileImageProxy { get; set; } = false; + + /// + /// Hostnames accepted from the X-Forwarded-Host request header (e.g. custom CloudFront distributions). + /// When a request carries X-Forwarded-Host and its value matches an entry here, that host + /// replaces the canonical host in generated IIIF URLs. An empty array (the default) means + /// X-Forwarded-Host is never honoured. + /// + public string[] AllowedCustomHosts { get; set; } = []; } diff --git a/src/TextServices.Search.Api/Configuration/ServiceCollectionExtensions.cs b/src/TextServices.Search.Api/Configuration/ServiceCollectionExtensions.cs index 41cb740..da74253 100644 --- a/src/TextServices.Search.Api/Configuration/ServiceCollectionExtensions.cs +++ b/src/TextServices.Search.Api/Configuration/ServiceCollectionExtensions.cs @@ -1,4 +1,7 @@ -using TextServices.Pdf; +using Microsoft.AspNetCore.HttpOverrides; +using Serilog; +using Serilog.Extensions.Logging; +using TextServices.Pdf; using TextServices.Search.Api.Features.Pdf; namespace TextServices.Search.Api.Configuration; @@ -20,4 +23,56 @@ public static IServiceCollection AddPdfServices(this IServiceCollection services return services; } + + /// + /// Configures host to use x-forwarded-proto to set httpContext.Request.Scheme + /// "KnownNetworks" (CIDR ranges) and/or "KnownProxies" (individual IPs) configuration keys restrict which + /// upstream sources are trusted. If neither is present, headers are accepted from all sources (with a warning). + /// + public static IServiceCollection ConfigureForwardedHeaders(this IServiceCollection services, + IConfiguration configuration) + { + var knownNetworks = configuration.GetValue("KnownNetworks"); + var knownProxies = configuration.GetValue("KnownProxies"); + + var logger = new SerilogLoggerFactory(Log.Logger).CreateLogger("ServiceCollection"); + + return services.Configure(opts => + { + opts.ForwardedHeaders = ForwardedHeaders.XForwardedProto; + + var networks = knownNetworks.SplitSeparatedString(",").ToList(); + var proxies = knownProxies.SplitSeparatedString(",").ToList(); + + if (networks.Count == 0 && proxies.Count == 0) + { + logger.LogWarning("Forwarded header values accepted from all networks and proxies"); + opts.KnownIPNetworks.Clear(); + opts.KnownProxies.Clear(); + } + else + { + if (networks.Count > 0) + { + logger.LogInformation("Forwarded header values accepted from networks: {KnownNetworks}", knownNetworks); + foreach (var network in networks) + { + opts.KnownIPNetworks.Add(System.Net.IPNetwork.Parse(network)); + } + } + + if (proxies.Count > 0) + { + logger.LogInformation("Forwarded header values accepted from proxies: {KnownProxies}", knownProxies); + foreach (var proxy in proxies) + { + opts.KnownProxies.Add(System.Net.IPAddress.Parse(proxy)); + } + } + } + }); + } + + private static IEnumerable SplitSeparatedString(this string? str, string separator) + => str?.Trim().Split(separator, StringSplitOptions.RemoveEmptyEntries) ?? Enumerable.Empty(); } diff --git a/src/TextServices.Search.Api/Features/Annotations/AnnotationEndpoints.cs b/src/TextServices.Search.Api/Features/Annotations/AnnotationEndpoints.cs index 069b681..cc20fae 100644 --- a/src/TextServices.Search.Api/Features/Annotations/AnnotationEndpoints.cs +++ b/src/TextServices.Search.Api/Features/Annotations/AnnotationEndpoints.cs @@ -14,8 +14,8 @@ internal static IEndpointRouteBuilder MapAnnotationEndpoints(this IEndpointRoute IOptions options, HttpContext ctx) => { - var selfUrl = EndpointHelpers.BuildSelfUrl(options.Value, ctx, $"annotations/manifest/v1/{id}", null); - var result = await sender.Send(new ManifestAnnotationsRequest(id, selfUrl)); + var resolved = EndpointHelpers.Resolve(options.Value, ctx, "annotations/manifest/v1/", id); + var result = await sender.Send(new ManifestAnnotationsRequest(id, resolved.SelfUrl)); if (result == null) return Results.NotFound(); return Results.Json(result, contentType: "application/ld+json"); }); @@ -26,8 +26,8 @@ internal static IEndpointRouteBuilder MapAnnotationEndpoints(this IEndpointRoute IOptions options, HttpContext ctx) => { - var selfUrl = EndpointHelpers.BuildSelfUrl(options.Value, ctx, $"annotations/lines/v1/{n}/{id}", null); - var result = await sender.Send(new LineAnnotationsRequest(id, n, selfUrl)); + var resolved = EndpointHelpers.Resolve(options.Value, ctx, $"annotations/lines/v1/{n}/", id); + var result = await sender.Send(new LineAnnotationsRequest(id, n, resolved.SelfUrl)); if (result == null) return Results.NotFound(); return Results.Json(result, contentType: "application/ld+json"); }); @@ -38,8 +38,8 @@ internal static IEndpointRouteBuilder MapAnnotationEndpoints(this IEndpointRoute IOptions options, HttpContext ctx) => { - var selfUrl = EndpointHelpers.BuildSelfUrl(options.Value, ctx, $"annotations/words/v1/{n}/{id}", null); - var result = await sender.Send(new WordAnnotationsRequest(id, n, selfUrl)); + var resolved = EndpointHelpers.Resolve(options.Value, ctx, $"annotations/words/v1/{n}/", id); + var result = await sender.Send(new WordAnnotationsRequest(id, n, resolved.SelfUrl)); if (result == null) return Results.NotFound(); return Results.Json(result, contentType: "application/ld+json"); }); diff --git a/src/TextServices.Search.Api/Features/Autocomplete/AutocompleteEndpoints.cs b/src/TextServices.Search.Api/Features/Autocomplete/AutocompleteEndpoints.cs index d1211dd..30b2ed9 100644 --- a/src/TextServices.Search.Api/Features/Autocomplete/AutocompleteEndpoints.cs +++ b/src/TextServices.Search.Api/Features/Autocomplete/AutocompleteEndpoints.cs @@ -14,8 +14,8 @@ internal static IEndpointRouteBuilder MapAutocompleteEndpoints(this IEndpointRou IOptions options, HttpContext ctx) => { - var selfUrl = EndpointHelpers.BuildSelfUrl(options.Value, ctx, $"autocomplete/v1/{id}", q); - var result = await sender.Send(new AutocompleteRequest(id, q ?? string.Empty, selfUrl)); + var resolved = EndpointHelpers.Resolve(options.Value, ctx, "autocomplete/v1/", id, q); + var result = await sender.Send(new AutocompleteRequest(id, q ?? string.Empty, resolved.SelfUrl)); if (result == null) return Results.NotFound(); return Results.Json(result, contentType: "application/ld+json"); }); @@ -26,8 +26,8 @@ internal static IEndpointRouteBuilder MapAutocompleteEndpoints(this IEndpointRou IOptions options, HttpContext ctx) => { - var selfUrl = EndpointHelpers.BuildSelfUrl(options.Value, ctx, $"autocomplete/v2/{id}", q); - var result = await sender.Send(new AutocompleteV2Request(id, q ?? string.Empty, selfUrl)); + var resolved = EndpointHelpers.Resolve(options.Value, ctx, "autocomplete/v2/", id, q); + var result = await sender.Send(new AutocompleteV2Request(id, q ?? string.Empty, resolved.SelfUrl)); if (result == null) return Results.NotFound(); return Results.Json(result, contentType: "application/ld+json"); }); diff --git a/src/TextServices.Search.Api/Features/EndpointHelpers.cs b/src/TextServices.Search.Api/Features/EndpointHelpers.cs index 927f9f8..13da0ea 100644 --- a/src/TextServices.Search.Api/Features/EndpointHelpers.cs +++ b/src/TextServices.Search.Api/Features/EndpointHelpers.cs @@ -2,16 +2,46 @@ namespace TextServices.Search.Api.Features; +/// +/// Holds the resolved URL components for a single endpoint request, accounting for +/// X-Forwarded-Host and X-Forwarded-Path proxy headers. +/// +/// +/// Job id to use in generated IIIF URLs. Extracted from X-Forwarded-Path when a +/// whitelisted host is present; otherwise equals the original route id. +/// +/// Absolute URL for this endpoint response (base + route prefix + effective id + query). +/// Scheme + authority only (no path). Used by TextAugmented to build cross-endpoint service URLs. +internal record ResolvedRequest(string EffectiveId, string SelfUrl, string BaseUrl); + internal static class EndpointHelpers { - internal static string BuildSelfUrl(SearchApiOptions opts, HttpContext ctx, string path, string? q) + /// + /// Resolves the effective id, self URL, and base URL for an endpoint in a single pass over + /// the forwarded headers. + /// + /// Current HTTP context object + /// + /// The path segment before {**id}, e.g. "search/v1/" or + /// "annotations/lines/v1/3/". Used to strip the prefix from + /// X-Forwarded-Path to extract the forwarded id. + /// + /// The job id from the route parameter. + /// Optional query term, appended as ?q=… on the self URL. + /// object + internal static ResolvedRequest Resolve(SearchApiOptions opts, HttpContext ctx, string routePrefix, + string originalId, string? q = null) { - var baseUrl = string.IsNullOrEmpty(opts.BaseUrl) - ? $"{ctx.Request.Scheme}://{ctx.Request.Host}" - : opts.BaseUrl.TrimEnd('/'); + var forwardedHost = ctx.Request.Headers["X-Forwarded-Host"].FirstOrDefault(); + var forwardedPath = ctx.Request.Headers["X-Forwarded-Path"].FirstOrDefault(); + + var baseUrl = ResolveBaseUrl(opts, ctx, forwardedHost); + var effectiveId = ResolveId(forwardedHost, forwardedPath, opts.AllowedCustomHosts, routePrefix, originalId); - var url = $"{baseUrl}/{path}"; - return string.IsNullOrWhiteSpace(q) ? url : $"{url}?q={Uri.EscapeDataString(q)}"; + var url = $"{baseUrl}/{routePrefix.TrimEnd('/')}/{effectiveId}"; + var selfUrl = string.IsNullOrWhiteSpace(q) ? url : $"{url}?q={Uri.EscapeDataString(q)}"; + + return new ResolvedRequest(effectiveId, selfUrl, baseUrl); } internal static string[]? GetIgnoredParams(HttpContext ctx) @@ -23,4 +53,41 @@ internal static string BuildSelfUrl(SearchApiOptions opts, HttpContext ctx, stri .ToArray(); return ignored.Length > 0 ? ignored : null; } + + private static string ResolveBaseUrl(SearchApiOptions opts, HttpContext ctx, string? forwardedHost) + { + if (!string.IsNullOrEmpty(opts.BaseUrl)) + { + var baseUri = new Uri(opts.BaseUrl); + var host = IsAllowedCustomHost(forwardedHost, opts.AllowedCustomHosts) + ? forwardedHost! + : baseUri.Authority; + return $"{baseUri.Scheme}://{host}"; + } + + var effectiveHost = IsAllowedCustomHost(forwardedHost, opts.AllowedCustomHosts) + ? forwardedHost! + : ctx.Request.Host.ToString(); + return $"{ctx.Request.Scheme}://{effectiveHost}"; + } + + private static string ResolveId( + string? forwardedHost, string? forwardedPath, string[] allowlist, string routePrefix, string originalId) + { + if (!IsAllowedCustomHost(forwardedHost, allowlist)) return originalId; + if (string.IsNullOrEmpty(forwardedPath)) return originalId; + + var pathOnly = forwardedPath.Split('?')[0]; + var normalised = pathOnly.TrimStart('/'); + var prefix = routePrefix.TrimStart('/'); + + if (!normalised.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)) return originalId; + + var extracted = normalised[prefix.Length..].TrimStart('/'); + return string.IsNullOrEmpty(extracted) ? originalId : extracted; + } + + private static bool IsAllowedCustomHost(string? host, string[] allowlist) + => !string.IsNullOrEmpty(host) + && allowlist.Contains(host, StringComparer.OrdinalIgnoreCase); } diff --git a/src/TextServices.Search.Api/Features/Figures/FiguresEndpoints.cs b/src/TextServices.Search.Api/Features/Figures/FiguresEndpoints.cs index 8373509..fcf84ed 100644 --- a/src/TextServices.Search.Api/Features/Figures/FiguresEndpoints.cs +++ b/src/TextServices.Search.Api/Features/Figures/FiguresEndpoints.cs @@ -14,8 +14,8 @@ internal static IEndpointRouteBuilder MapFiguresEndpoints(this IEndpointRouteBui IOptions options, HttpContext ctx) => { - var selfUrl = EndpointHelpers.BuildSelfUrl(options.Value, ctx, $"identified/figures/{id}", null); - var result = await sender.Send(new FiguresRequest(id, selfUrl)); + var resolved = EndpointHelpers.Resolve(options.Value, ctx, "identified/figures/", id); + var result = await sender.Send(new FiguresRequest(id, resolved.SelfUrl)); if (result == null) return Results.NotFound(); return Results.Json(result, contentType: "application/ld+json"); }); diff --git a/src/TextServices.Search.Api/Features/Pdf/PdfEndpoints.cs b/src/TextServices.Search.Api/Features/Pdf/PdfEndpoints.cs index b07404e..00df646 100644 --- a/src/TextServices.Search.Api/Features/Pdf/PdfEndpoints.cs +++ b/src/TextServices.Search.Api/Features/Pdf/PdfEndpoints.cs @@ -23,15 +23,12 @@ internal static IEndpointRouteBuilder MapPdfEndpoints(this IEndpointRouteBuilder HttpContext ctx) => { id = StripPdfExtension(id); + var resolved = EndpointHelpers.Resolve(options.Value, ctx, "pdf/v1/", id); var result = await sender.Send(new PdfTriggerRequest(id)); return result switch { - PdfTriggerResult.AlreadyExists => Results.Ok(new - { - location = EndpointHelpers.BuildSelfUrl(options.Value, ctx, $"pdf/v1/{id}", null) - }), - PdfTriggerResult.Queued => Results.Accepted( - EndpointHelpers.BuildSelfUrl(options.Value, ctx, $"pdf/v1/{id}", null)), + PdfTriggerResult.AlreadyExists => Results.Ok(new { location = resolved.SelfUrl }), + PdfTriggerResult.Queued => Results.Accepted(resolved.SelfUrl), PdfTriggerResult.ServiceBusy => new ServiceBusyResult(), _ => Results.NotFound(), }; diff --git a/src/TextServices.Search.Api/Features/Search/SearchEndpoints.cs b/src/TextServices.Search.Api/Features/Search/SearchEndpoints.cs index dcf5a45..388cb3a 100644 --- a/src/TextServices.Search.Api/Features/Search/SearchEndpoints.cs +++ b/src/TextServices.Search.Api/Features/Search/SearchEndpoints.cs @@ -14,8 +14,8 @@ internal static IEndpointRouteBuilder MapSearchEndpoints(this IEndpointRouteBuil IOptions options, HttpContext ctx) => { - var selfUrl = EndpointHelpers.BuildSelfUrl(options.Value, ctx, $"search/v1/{id}", q); - var result = await sender.Send(new SearchRequest(id, q ?? string.Empty, selfUrl)); + var resolved = EndpointHelpers.Resolve(options.Value, ctx, "search/v1/", id, q); + var result = await sender.Send(new SearchRequest(id, q ?? string.Empty, resolved.SelfUrl)); if (result == null) return Results.NotFound(); result.Ignored = EndpointHelpers.GetIgnoredParams(ctx); return Results.Json(result, contentType: "application/ld+json"); @@ -27,8 +27,8 @@ internal static IEndpointRouteBuilder MapSearchEndpoints(this IEndpointRouteBuil IOptions options, HttpContext ctx) => { - var selfUrl = EndpointHelpers.BuildSelfUrl(options.Value, ctx, $"search/v2/{id}", q); - var result = await sender.Send(new SearchV2Request(id, q ?? string.Empty, selfUrl)); + var resolved = EndpointHelpers.Resolve(options.Value, ctx, "search/v2/", id, q); + var result = await sender.Send(new SearchV2Request(id, q ?? string.Empty, resolved.SelfUrl)); if (result == null) return Results.NotFound(); result.Ignored = EndpointHelpers.GetIgnoredParams(ctx); return Results.Json(result, contentType: "application/ld+json"); diff --git a/src/TextServices.Search.Api/Features/TextAugmented/TextAugmentedEndpoints.cs b/src/TextServices.Search.Api/Features/TextAugmented/TextAugmentedEndpoints.cs index 1a1de52..d008cb2 100644 --- a/src/TextServices.Search.Api/Features/TextAugmented/TextAugmentedEndpoints.cs +++ b/src/TextServices.Search.Api/Features/TextAugmented/TextAugmentedEndpoints.cs @@ -14,12 +14,8 @@ internal static IEndpointRouteBuilder MapTextAugmentedEndpoints(this IEndpointRo IOptions options, HttpContext ctx) => { - var selfUrl = EndpointHelpers.BuildSelfUrl(options.Value, ctx, $"text-augmented/v3/{id}", null); - var searchBase = string.IsNullOrEmpty(options.Value.BaseUrl) - ? $"{ctx.Request.Scheme}://{ctx.Request.Host}" - : options.Value.BaseUrl.TrimEnd('/'); - - var result = await sender.Send(new TextAugmentedRequest(id, selfUrl, searchBase)); + var resolved = EndpointHelpers.Resolve(options.Value, ctx, "text-augmented/v3/", id); + var result = await sender.Send(new TextAugmentedRequest(id, resolved.SelfUrl, resolved.BaseUrl, UrlId: resolved.EffectiveId)); if (result == null) return Results.NotFound(); return Results.Json(result, contentType: "application/ld+json"); }); diff --git a/src/TextServices.Search.Api/Features/TextAugmented/TextAugmentedQuery.cs b/src/TextServices.Search.Api/Features/TextAugmented/TextAugmentedQuery.cs index ecbdfd9..c295965 100644 --- a/src/TextServices.Search.Api/Features/TextAugmented/TextAugmentedQuery.cs +++ b/src/TextServices.Search.Api/Features/TextAugmented/TextAugmentedQuery.cs @@ -12,7 +12,13 @@ namespace TextServices.Search.Api.Features.TextAugmented; /// type "SearchService1" / "AutoCompleteService1" (IIIF Search 1, legacy) /// No @context is emitted inside service blocks — it belongs only at document level. /// -public record TextAugmentedRequest(string Id, string SelfUrl, string SearchBaseUrl) +/// Storage key used to load artefacts from the text store. +/// +/// Id to use when generating IIIF service URLs. Differs from when the +/// request arrived via a proxy that rewrites the path (X-Forwarded-Path). Defaults to +/// when null. +/// +public record TextAugmentedRequest(string Id, string SelfUrl, string SearchBaseUrl, string? UrlId = null) : IRequest; public class TextAugmentedHandler(ITextStore textStore, ITextCache textCache) @@ -35,12 +41,14 @@ public class TextAugmentedHandler(ITextStore textStore, ITextCache textCache) // Build service descriptors using Presentation 3 id/type conventions. // v2 is listed first; v1 follows for backward-compatible clients. + + // TODO - de-duplicate when adding services var base_ = request.SearchBaseUrl; - var id = request.Id; + var id = request.UrlId ?? request.Id; var searchServiceV2 = new JsonObject { - ["id"] = $"{base_}/search/v2/{id}", + ["id"] = $"{base_}/search/v2/{id}", // TODO - can we build these from a central place? ["type"] = "SearchService2", ["service"] = new JsonArray(new JsonObject { diff --git a/src/TextServices.Search.Api/Program.cs b/src/TextServices.Search.Api/Program.cs index a339c4e..5546457 100644 --- a/src/TextServices.Search.Api/Program.cs +++ b/src/TextServices.Search.Api/Program.cs @@ -56,6 +56,7 @@ // ---- Configuration ---------------------------------------------------------- builder.Services.Configure(builder.Configuration.GetSection("TextServices")); +builder.Services.ConfigureForwardedHeaders(builder.Configuration); // ---- Storage ---------------------------------------------------------------- @@ -96,6 +97,7 @@ if (app.Environment.IsDevelopment()) app.MapOpenApi(); +app.UseForwardedHeaders(); app.UseMiddleware(); app.UseSerilogRequestLogging(opts => opts.GetLevel = (ctx, _, _) => diff --git a/src/TextServices.Tests/SearchApi/EndpointHelpersTests.cs b/src/TextServices.Tests/SearchApi/EndpointHelpersTests.cs new file mode 100644 index 0000000..d590b39 --- /dev/null +++ b/src/TextServices.Tests/SearchApi/EndpointHelpersTests.cs @@ -0,0 +1,165 @@ +using Microsoft.AspNetCore.Http; +using Shouldly; +using TextServices.Search.Api.Configuration; +using TextServices.Search.Api.Features; + +namespace TextServices.Tests.SearchApi; + +public class EndpointHelpersTests +{ + // ------------------------------------------------------------------------- + // Resolve — no forwarding (baseline) + // ------------------------------------------------------------------------- + + [Fact] + public void Resolve_NoForwardedHeaders_UsesBaseUrlAndOriginalId() + { + var opts = Opts(baseUrl: "https://canonical.search", allowedHosts: ["known.host"]); + var result = EndpointHelpers.Resolve(opts, Context(), "search/v1/", "2/cc/123"); + result.EffectiveId.ShouldBe("2/cc/123"); + result.SelfUrl.ShouldBe("https://canonical.search/search/v1/2/cc/123"); + result.BaseUrl.ShouldBe("https://canonical.search"); + } + + [Fact] + public void Resolve_NoBaseUrl_UsesRequestSchemeAndHost() + { + var opts = Opts(); + var result = EndpointHelpers.Resolve(opts, Context(scheme: "https", host: "request.host"), "search/v1/", "a/b"); + result.SelfUrl.ShouldBe("https://request.host/search/v1/a/b"); + result.BaseUrl.ShouldBe("https://request.host"); + } + + [Fact] + public void Resolve_QueryParam_AppendedToSelfUrl() + { + var opts = Opts(baseUrl: "https://canonical.search"); + var result = EndpointHelpers.Resolve(opts, Context(), "search/v1/", "a/b", "hello world"); + result.SelfUrl.ShouldBe("https://canonical.search/search/v1/a/b?q=hello%20world"); + } + + // ------------------------------------------------------------------------- + // Resolve — X-Forwarded-Host only + // ------------------------------------------------------------------------- + + [Fact] + public void Resolve_KnownForwardedHost_ReplacesHostKeepsOriginalId() + { + var opts = Opts(baseUrl: "https://canonical.search", allowedHosts: ["known.host"]); + var result = EndpointHelpers.Resolve(opts, Context(forwardedHost: "known.host"), "search/v1/", "2/cc/123"); + result.EffectiveId.ShouldBe("2/cc/123"); + result.SelfUrl.ShouldBe("https://known.host/search/v1/2/cc/123"); + result.BaseUrl.ShouldBe("https://known.host"); + } + + [Fact] + public void Resolve_UnknownForwardedHost_Ignored() + { + var opts = Opts(baseUrl: "https://canonical.search", allowedHosts: ["known.host"]); + var result = EndpointHelpers.Resolve(opts, Context(forwardedHost: "evil.com"), "search/v1/", "2/cc/123"); + result.SelfUrl.ShouldBe("https://canonical.search/search/v1/2/cc/123"); + result.BaseUrl.ShouldBe("https://canonical.search"); + } + + [Fact] + public void Resolve_EmptyAllowlist_ForwardedHostIgnored() + { + var opts = Opts(baseUrl: "https://canonical.search"); + var result = EndpointHelpers.Resolve(opts, Context(forwardedHost: "known.host"), "search/v1/", "2/cc/123"); + result.BaseUrl.ShouldBe("https://canonical.search"); + } + + // ------------------------------------------------------------------------- + // Resolve — X-Forwarded-Host + X-Forwarded-Path (id extraction) + // ------------------------------------------------------------------------- + + [Fact] + public void Resolve_KnownHostAndPath_ExtractsIdFromPath() + { + var opts = Opts(baseUrl: "https://canonical.search", allowedHosts: ["known.host"]); + var result = EndpointHelpers.Resolve(opts, Context(forwardedHost: "known.host", forwardedPath: "search/v1/cc/123"), "search/v1/", "2/cc/123"); + result.EffectiveId.ShouldBe("cc/123"); + result.SelfUrl.ShouldBe("https://known.host/search/v1/cc/123"); + } + + [Fact] + public void Resolve_ForwardedPathWithLeadingSlash_Handled() + { + var opts = Opts(baseUrl: "https://canonical.search", allowedHosts: ["known.host"]); + var result = EndpointHelpers.Resolve(opts, Context(forwardedHost: "known.host", forwardedPath: "/search/v1/cc/123"), "search/v1/", "2/cc/123"); + result.EffectiveId.ShouldBe("cc/123"); + } + + [Fact] + public void Resolve_ForwardedPathWithQueryString_QueryStripped() + { + var opts = Opts(baseUrl: "https://canonical.search", allowedHosts: ["known.host"]); + var result = EndpointHelpers.Resolve(opts, Context(forwardedHost: "known.host", forwardedPath: "search/v1/cc/123?q=test"), "search/v1/", "2/cc/123"); + result.EffectiveId.ShouldBe("cc/123"); + result.SelfUrl.ShouldNotContain("test"); + } + + [Fact] + public void Resolve_ForwardedPathPrefixMismatch_ReturnsOriginalId() + { + var opts = Opts(baseUrl: "https://canonical.search", allowedHosts: ["known.host"]); + var result = EndpointHelpers.Resolve(opts, Context(forwardedHost: "known.host", forwardedPath: "autocomplete/v1/cc/123"), "search/v1/", "2/cc/123"); + result.EffectiveId.ShouldBe("2/cc/123"); + } + + [Fact] + public void Resolve_ForwardedPathButUnknownHost_PathIgnored() + { + var opts = Opts(baseUrl: "https://canonical.search", allowedHosts: ["known.host"]); + var result = EndpointHelpers.Resolve(opts, Context(forwardedHost: "unknown.host", forwardedPath: "search/v1/cc/123"), "search/v1/", "2/cc/123"); + result.EffectiveId.ShouldBe("2/cc/123"); + result.BaseUrl.ShouldBe("https://canonical.search"); + } + + [Fact] + public void Resolve_ForwardedPathWithNoHost_PathIgnored() + { + var opts = Opts(baseUrl: "https://canonical.search", allowedHosts: ["known.host"]); + var result = EndpointHelpers.Resolve(opts, Context(forwardedPath: "search/v1/cc/123"), "search/v1/", "2/cc/123"); + result.EffectiveId.ShouldBe("2/cc/123"); + } + + [Fact] + public void Resolve_KnownHostCaseInsensitive() + { + var opts = Opts(baseUrl: "https://canonical.search", allowedHosts: ["Known.Host"]); + var result = EndpointHelpers.Resolve(opts, Context(forwardedHost: "known.host", forwardedPath: "search/v1/cc/123"), "search/v1/", "2/cc/123"); + result.EffectiveId.ShouldBe("cc/123"); + } + + [Fact] + public void Resolve_TextAugmentedRoute_ExtractsId() + { + var opts = Opts(baseUrl: "https://canonical.search", allowedHosts: ["known.host"]); + var result = EndpointHelpers.Resolve(opts, Context(forwardedHost: "known.host", forwardedPath: "text-augmented/v3/cc/123"), "text-augmented/v3/", "2/cc/123"); + result.EffectiveId.ShouldBe("cc/123"); + result.SelfUrl.ShouldBe("https://known.host/text-augmented/v3/cc/123"); + result.BaseUrl.ShouldBe("https://known.host"); + } + + // ------------------------------------------------------------------------- + // Helpers + // ------------------------------------------------------------------------- + + private static SearchApiOptions Opts(string baseUrl = "", string[]? allowedHosts = null) => + new() { BaseUrl = baseUrl, AllowedCustomHosts = allowedHosts ?? [] }; + + private static HttpContext Context( + string scheme = "http", + string host = "localhost", + string? forwardedHost = null, + string? forwardedPath = null) + { + var ctx = new DefaultHttpContext(); + ctx.Request.Scheme = scheme; + ctx.Request.Host = new HostString(host); + if (forwardedHost != null) ctx.Request.Headers["X-Forwarded-Host"] = forwardedHost; + if (forwardedPath != null) ctx.Request.Headers["X-Forwarded-Path"] = forwardedPath; + return ctx; + } +} From fcd86f8ced6bfa748a163636cb71df527793dc37 Mon Sep 17 00:00:00 2001 From: Donald Gray Date: Thu, 28 May 2026 17:17:55 +0100 Subject: [PATCH 2/3] Fix query string appearing in search annotation IDs The ?q= param was being embedded mid-path in annotation and context IDs because SelfUrl (which includes the query) was used as the base for both the response id and child resource IDs. Adds ResourceUrl (path only, no query) to ResolvedRequest and threads it through SearchRequest, SearchV2Request, and SearchHandlerBase so annotation and context IDs are built from the clean path. Co-Authored-By: Claude Sonnet 4.6 --- .../Features/EndpointHelpers.cs | 5 +- .../Features/Search/SearchEndpoints.cs | 4 +- .../Features/Search/SearchHandlerBase.cs | 10 ++-- .../Features/Search/SearchQuery.cs | 10 ++-- .../Features/Search/SearchV2Query.cs | 16 +++--- .../SearchApi/CapabilityGatingTests.cs | 4 +- .../SearchApi/SearchHandlerTests.cs | 33 +++++++++--- .../SearchApi/SearchV2HandlerTests.cs | 52 ++++++++++++++++--- 8 files changed, 94 insertions(+), 40 deletions(-) diff --git a/src/TextServices.Search.Api/Features/EndpointHelpers.cs b/src/TextServices.Search.Api/Features/EndpointHelpers.cs index 13da0ea..5e97e58 100644 --- a/src/TextServices.Search.Api/Features/EndpointHelpers.cs +++ b/src/TextServices.Search.Api/Features/EndpointHelpers.cs @@ -11,8 +11,9 @@ namespace TextServices.Search.Api.Features; /// whitelisted host is present; otherwise equals the original route id. /// /// Absolute URL for this endpoint response (base + route prefix + effective id + query). +/// Absolute URL without query string. Used as the base for child resource IDs (e.g. annotations). /// Scheme + authority only (no path). Used by TextAugmented to build cross-endpoint service URLs. -internal record ResolvedRequest(string EffectiveId, string SelfUrl, string BaseUrl); +internal record ResolvedRequest(string EffectiveId, string SelfUrl, string ResourceUrl, string BaseUrl); internal static class EndpointHelpers { @@ -41,7 +42,7 @@ internal static ResolvedRequest Resolve(SearchApiOptions opts, HttpContext ctx, var url = $"{baseUrl}/{routePrefix.TrimEnd('/')}/{effectiveId}"; var selfUrl = string.IsNullOrWhiteSpace(q) ? url : $"{url}?q={Uri.EscapeDataString(q)}"; - return new ResolvedRequest(effectiveId, selfUrl, baseUrl); + return new ResolvedRequest(effectiveId, selfUrl, url, baseUrl); } internal static string[]? GetIgnoredParams(HttpContext ctx) diff --git a/src/TextServices.Search.Api/Features/Search/SearchEndpoints.cs b/src/TextServices.Search.Api/Features/Search/SearchEndpoints.cs index 388cb3a..c6b480b 100644 --- a/src/TextServices.Search.Api/Features/Search/SearchEndpoints.cs +++ b/src/TextServices.Search.Api/Features/Search/SearchEndpoints.cs @@ -15,7 +15,7 @@ internal static IEndpointRouteBuilder MapSearchEndpoints(this IEndpointRouteBuil HttpContext ctx) => { var resolved = EndpointHelpers.Resolve(options.Value, ctx, "search/v1/", id, q); - var result = await sender.Send(new SearchRequest(id, q ?? string.Empty, resolved.SelfUrl)); + var result = await sender.Send(new SearchRequest(id, q ?? string.Empty, resolved.SelfUrl, resolved.ResourceUrl)); if (result == null) return Results.NotFound(); result.Ignored = EndpointHelpers.GetIgnoredParams(ctx); return Results.Json(result, contentType: "application/ld+json"); @@ -28,7 +28,7 @@ internal static IEndpointRouteBuilder MapSearchEndpoints(this IEndpointRouteBuil HttpContext ctx) => { var resolved = EndpointHelpers.Resolve(options.Value, ctx, "search/v2/", id, q); - var result = await sender.Send(new SearchV2Request(id, q ?? string.Empty, resolved.SelfUrl)); + var result = await sender.Send(new SearchV2Request(id, q ?? string.Empty, resolved.SelfUrl, resolved.ResourceUrl)); if (result == null) return Results.NotFound(); result.Ignored = EndpointHelpers.GetIgnoredParams(ctx); return Results.Json(result, contentType: "application/ld+json"); diff --git a/src/TextServices.Search.Api/Features/Search/SearchHandlerBase.cs b/src/TextServices.Search.Api/Features/Search/SearchHandlerBase.cs index b19257e..db9bf17 100644 --- a/src/TextServices.Search.Api/Features/Search/SearchHandlerBase.cs +++ b/src/TextServices.Search.Api/Features/Search/SearchHandlerBase.cs @@ -6,19 +6,19 @@ namespace TextServices.Search.Api.Features.Search; public abstract class SearchHandlerBase(ITextCache cache) { - protected async Task HandleCore(string id, string query, string selfUrl, CancellationToken ct) + protected async Task HandleCore(string id, string query, string selfUrl, string resourceUrl, CancellationToken ct) { if (!await cache.IsEnabledAsync(id, JobServices.Search, ct)) return default; if (string.IsNullOrWhiteSpace(query)) - return EmptyQueryResponse(selfUrl); + return EmptyQueryResponse(selfUrl, resourceUrl); var text = await cache.GetTextAsync(id, ct); if (text == null) return default; - return BuildResponse(text, text.Search(query), selfUrl); + return BuildResponse(text, text.Search(query), selfUrl, resourceUrl); } - protected abstract TResponse EmptyQueryResponse(string selfUrl); - protected abstract TResponse BuildResponse(Text text, List rects, string selfUrl); + protected abstract TResponse EmptyQueryResponse(string selfUrl, string resourceUrl); + protected abstract TResponse BuildResponse(Text text, List rects, string selfUrl, string resourceUrl); } diff --git a/src/TextServices.Search.Api/Features/Search/SearchQuery.cs b/src/TextServices.Search.Api/Features/Search/SearchQuery.cs index fabe885..972fd4e 100644 --- a/src/TextServices.Search.Api/Features/Search/SearchQuery.cs +++ b/src/TextServices.Search.Api/Features/Search/SearchQuery.cs @@ -5,18 +5,18 @@ namespace TextServices.Search.Api.Features.Search; -public record SearchRequest(string Id, string Query, string SelfUrl) : IRequest; +public record SearchRequest(string Id, string Query, string SelfUrl, string ResourceUrl) : IRequest; public class SearchHandler(ITextCache cache) : SearchHandlerBase(cache), IRequestHandler { public Task Handle(SearchRequest request, CancellationToken ct) - => HandleCore(request.Id, request.Query, request.SelfUrl, ct); + => HandleCore(request.Id, request.Query, request.SelfUrl, request.ResourceUrl, ct); - protected override SearchAnnotationList EmptyQueryResponse(string selfUrl) => + protected override SearchAnnotationList EmptyQueryResponse(string selfUrl, string resourceUrl) => new() { Id = selfUrl, Within = new SearchLayer { Total = 0 }, Resources = [], Hits = [] }; - protected override SearchAnnotationList BuildResponse(Text text, List rects, string selfUrl) + protected override SearchAnnotationList BuildResponse(Text text, List rects, string selfUrl, string resourceUrl) { var resources = new List(rects.Count); var hits = new List(); @@ -29,7 +29,7 @@ protected override SearchAnnotationList BuildResponse(Text text, List; +public record SearchV2Request(string Id, string Query, string SelfUrl, string ResourceUrl) : IRequest; public class SearchV2Handler(ITextCache cache) : SearchHandlerBase(cache), IRequestHandler { public Task Handle(SearchV2Request request, CancellationToken ct) - => HandleCore(request.Id, request.Query, request.SelfUrl, ct); + => HandleCore(request.Id, request.Query, request.SelfUrl, request.ResourceUrl, ct); - protected override SearchAnnotationPageV2 EmptyQueryResponse(string selfUrl) => + protected override SearchAnnotationPageV2 EmptyQueryResponse(string selfUrl, string resourceUrl) => new() { Id = selfUrl, Items = [], Annotations = null }; - protected override SearchAnnotationPageV2 BuildResponse(Text text, List rects, string selfUrl) + protected override SearchAnnotationPageV2 BuildResponse(Text text, List rects, string selfUrl, string resourceUrl) { var items = new List(rects.Count); var contexts = new List(); @@ -40,13 +40,13 @@ protected override SearchAnnotationPageV2 BuildResponse(Text text, List Date: Thu, 28 May 2026 17:20:56 +0100 Subject: [PATCH 3/3] Document AllowedCustomHosts and forwarded-header URL rewriting Adds AllowFileImageProxy and AllowedCustomHosts to the configuration table, plus a new section explaining X-Forwarded-Host / X-Forwarded-Path behaviour and the allowlist requirement. Co-Authored-By: Claude Sonnet 4.6 --- docs/search-api.md | 32 ++++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/docs/search-api.md b/docs/search-api.md index 43ffe21..cbbeb94 100644 --- a/docs/search-api.md +++ b/docs/search-api.md @@ -517,5 +517,37 @@ Search API configuration lives under the `TextServices` key in `appsettings.json | `StorageRootPath` | `textservices-data` | Root directory of the text artefact store. Must point to the same location as the Builder API's `Storage:RootPath`. | | `PdfTriggerQueueCapacity` | `50` | Maximum number of PDF trigger requests that can be queued for background generation. Requests beyond this limit receive `503 Service Unavailable`. | | `PdfTriggerMaxConcurrency` | `2` | Maximum number of PDFs generated concurrently by the background trigger queue. Each in-flight generation buffers the full PDF in memory — keep this low on memory-constrained hosts. | +| `AllowFileImageProxy` | `false` | When `true`, the `/proxy/image` endpoint streams local `file://` images. Only enable in trusted local-dev environments where those files are not access-controlled. | +| `AllowedCustomHosts` | `[]` | Hostnames accepted from the `X-Forwarded-Host` request header (e.g. custom CloudFront distributions). See [Forwarded-header URL rewriting](#forwarded-header-url-rewriting) below. | + +--- + +## Forwarded-header URL rewriting + +When the Search API sits behind a reverse proxy that rewrites the public URL (e.g. a CloudFront +distribution with a custom domain), the `id` values in IIIF responses must reflect the +public-facing URL rather than the internal one. + +Configure `AllowedCustomHosts` with the public hostnames you trust: + +```json +{ + "TextServices": { + "AllowedCustomHosts": ["custom.example.org"] + } +} +``` + +When a request arrives carrying `X-Forwarded-Host: custom.example.org` and that value matches +an entry in `AllowedCustomHosts`: + +- The host in all generated IIIF URLs is replaced with the forwarded host. +- If `X-Forwarded-Path` is also present, the Search API extracts the effective job ID from it + (stripping the route prefix), so the `id` values in the response reflect the public path + rather than the internal route. This is useful when the proxy maps a path like + `/iiif/search/my-book` to the internal `/search/v2/my-book`. + +Hosts not in `AllowedCustomHosts` are always ignored, regardless of what headers the request +carries. The default empty array means `X-Forwarded-Host` is never honoured. All responses include `Access-Control-Allow-Origin: *`. The Search API is entirely read-only, so open CORS is required by the IIIF specification and safe without restriction.