diff --git a/CHANGELOG.md b/CHANGELOG.md index 7c35cfd43..56d98bdfa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -222,21 +222,19 @@ the version on nuget.org, which is why it trails `VERSION` between releases rath ### Changed -- **Automatic redirects on Ashlar HTTP clients are followed by Ashlar, one hop at a time (SPEC-007 PR 4.3).** - Still report-only: `EgressDecision.Refused` stays false and a decision does not stop a send. On every - `HttpClientHandler` or `SocketsHttpHandler` Ashlar builds or binds, `AllowAutoRedirect` reads false. The - per-hop behaviour is Ashlar's follower, using the primary's own previous settings (whether it followed, and - its maximum): a relative `Location` is resolved and keeps the fragment; HTTPS to HTTP and any scheme other - than http or https is returned; 301, 302 and 303 on POST become GET and drop the content (303 keeps HEAD); - 307 and 308 keep the method and content; `Authorization` is cleared on a followed redirect. An `EgressHttp` - client follows only a same-host redirect (P2) and returns a cross-host 3xx without requesting the second - host. Every factory client follows a cross-host redirect and each hop is evaluated again (P1). A primary - type Ashlar cannot flip is left alone; if the response URI's authority is not the one evaluated before the - send, that authority is evaluated after the response and a warning says the body may already have gone. - The SNS signing clients (`ashlar-sns-signing` on the API host and the commercial fleet host) set - `AllowAutoRedirect` false on the existing primary and do not follow. The Bedrock runtime client sets - `AmazonBedrockRuntimeConfig.AllowAutoRedirect` false; the SDK still owns that HTTP stack, so a redirect it - followed anyway is not re-evaluated inside the SDK. The default MEAI Ollama client is unchanged from PR 4.1. +- **HTTP redirect boundaries hardened (SPEC-007 PR 4.3/4.3b).** `EgressHttp` follows only the same + origin (scheme, host and port; P2), and factory clients follow across origins (P1). On both routes, Ashlar’s + follower returns a remote redirect into Host or link-local unfollowed and record the attempted hop (owner O2, 2026-10-06). + The follower preserves the primary's original setting and limit, resolves relative locations and fragments, + refuses HTTPS-to-HTTP and non-HTTP targets, rewrites methods as the runtime does, clears `Authorization`, + disposes intermediate responses, and checks cancellation before another hop. Each followed hop is decided. + Credentialed, unknown, already-started or immutable transports retain their own redirects, so only a + changed final origin can be reported afterwards. Composite and netstandard2.0 chains avoid duplicate + followers. Factory binding restores a removed guard (7303), leaves normal logging scopes quiet, and warns + once per unknown client/type on AG/SW (7304). Explicit no-follow policy follows the named client's request through shared primaries and existing followers without changing another client's policy. SNS clients use the configure-existing `NeverFollowRedirects` + helper; Bedrock's `RuntimeConfig` disables SDK redirects. Real Kestrel, API and Fleet twins cover these paths. + Decisions remain report-only by default; recording an enforcing guard's refusal does not stop a send until + PR 4.7. See EgressInventory for custom-header, DNS, transport-ownership and post-send limits. - **AirGapped stays on local execution, and AirGapped and SecureWorkstation inbound listeners must bind loopback (SPEC-007 PR 4.10).** The egress guard is still report-only. Responses on inbound connections are not mediated until PR 5's CanRead at the server seams. What this release enforces at boot and at routing: diff --git a/application/src/Ashlar.API/Program.cs b/application/src/Ashlar.API/Program.cs index abe1628ae..05f428418 100644 --- a/application/src/Ashlar.API/Program.cs +++ b/application/src/Ashlar.API/Program.cs @@ -170,13 +170,7 @@ builder.Services.AddSingleton(); builder.Services.AddHttpClient("ashlar-sns-signing", c => c.Timeout = TimeSpan.FromSeconds(15)) - .ConfigurePrimaryHttpMessageHandler(static (handler, _) => - { - if (handler is HttpClientHandler httpClientHandler) - httpClientHandler.AllowAutoRedirect = false; - else if (handler is SocketsHttpHandler socketsHttpHandler) - socketsHttpHandler.AllowAutoRedirect = false; - }); + .NeverFollowRedirects(); builder.Services.AddSingleton(); builder.Services.AddRateLimiter(static o => { diff --git a/ci/cert-gate-assertions.md b/ci/cert-gate-assertions.md index 858d71839..ab5aa3764 100644 --- a/ci/cert-gate-assertions.md +++ b/ci/cert-gate-assertions.md @@ -61,7 +61,7 @@ away, can see what they would be turning off. | **Public-API tracking is never switched off, and a deprecation disable names what it is for.** No source disables RS0016, RS0017, RS0026 or RS0027 by pragma, `[SuppressMessage]`/`[UnconditionalSuppressMessage]` or a bare `#pragma warning disable`; no MSBuild file lists them in `NoWarn` or `WarningsNotAsErrors`; no `.editorconfig`/`.globalconfig` lowers them to none, silent or suggestion or sets `dotnet_public_api_analyzer.skip_namespaces` (nor, where the config applies to an analyzer project, lowers the `ApiDesign` category or every analyzer); no analyzer project or `Directory.Build.*` above one sets `RunAnalyzers`/`RunAnalyzersDuringBuild` false or removes an API file from `AdditionalFiles`; and every project referencing `Microsoft.CodeAnalysis.PublicApiAnalyzers` wires both `PublicAPI.*.txt` files as `AdditionalFiles`. Every `#pragma warning disable` of CS0618 or ASHLAREXP001 - in any spelling the compiler accepts, including the bare `618`/`0618` - names, as an exact identifier, an in-tree `[Obsolete]` (CS0618: not `error: true`, no `DiagnosticId`) or `[Experimental(ASHLAREXP001)]` symbol that the code it governs still uses OUTSIDE that symbol's own attributed declaration, and gives a reason of at least three words. A disable that cannot carry that comment - a `[SuppressMessage]` naming either id, a `NoWarn`/`WarningsNotAsErrors` element or attribute listing either id in any MSBuild file, or a `dotnet_diagnostic..severity` of none, silent or suggestion - is a violation too; the two project-wide ones that exist (`Directory.Build.targets`' test-project `ASHLAREXP001` opt-in and the `FirstFlight` spike's) are on a reviewed list that records why. Eight existing pragmas in files outside that change are grandfathered by exact text. Both lists fail in both directions (a new violation, or an entry that no longer matches). Stated floors (2500 C# files, 100 MSBuild files, 6 analyzer projects, 40 experimental names and 1 obsolete name, 10 disable pragmas; measured 3149, 133, 6, 65, 2 and 15 on f0fd3fde - the obsolete floor sits under the measured 2 so that retiring one forwarder is not reported as a broken parser) stop an emptied scan passing; controls pin every banned spelling, immunity to comments and literals, the obsolete-context rule, and the shipped `HostAshlarSdkBuilder.cs` dead pragma verbatim and in its numeric spellings. | `DiagnosticSuppressionConventionTests` | Five `Ashlar.Brick.Contracts` Ports files opened with a file-wide `#pragma warning disable RS0016`, so 18 public types published in `v0.1.0` to `v0.1.2` were tracked by nothing and the stable-tier promise in `docs/SdkCompatibilityPolicy.md` was false for them. `HostAshlarSdkBuilder.cs:7` disabled CS0618 for a forwarder #223 had moved out, and suppressed nothing for 88 days while still reading as a reason. **Lexical only:** this proves a disable names a real symbol the governed code still mentions outside its own context, not that the compiler would report there; an obsolete API declared outside the tree cannot be named and needs a reviewed grandfather entry; CS0612 is not policed; MSBuild is not evaluated, so conditions are ignored and a disable routed through another property (`$(MyList)`) or passed on a command line (`-p:NoWarn=`, `-nowarn:`) is not seen; `TreatWarningsAsErrors`, `WarningLevel` and bulk `dotnet_analyzer_diagnostic.*` settings are not checked for Rule 2. | | **Security labels form a lattice; the reference monitor refuses read-up and write-down.** `Ashlar.Abstractions.Security.SecurityLabel` is a level (`Public` < `Internal` < `Confidential` < `Secret` < `TopSecret`), a compartment set and a caveat set, plus a distinguished top, `SystemHigh`. `a <= b` iff `a`'s level is at most `b`'s and each of `a`'s token sets is a subset of `b`'s; `Join` is (higher level, union, union) and `Meet` is (lower level, intersection, intersection); `Public` is the bottom, only `SystemHigh` dominates `SystemHigh`, joining with it gives it, and `x.Meet(SystemHigh)` is `x`. The order and both operations are held to the lattice laws over generated labels that include `SystemHigh`: the order is reflexive, antisymmetric and transitive, `Join` and `Meet` are commutative, associative, idempotent and absorb each other, and `Dominates` agrees with both. The canonical text form (`Secret//C:ALPHA,BRAVO//K:NOWEB`, `Public//K:NOWEB`, `SystemHigh`) round-trips, and `TryParse` accepts only canonical text - exact-case level, no whitespace, no empty segment or token, `C` before `K`, tokens strictly ascending - and sets its out label to `SystemHigh` on every refusal, which is also what `ParseOrSystemHigh` returns; JSON (and TypeDescriptor) carries a label only as that canonical string, so `SystemHigh` cannot come back lower and anything else, `null` included, is refused. `SecurityLevel`'s names and values match the primitive `DataSensitivityLevels` (name and `SensitivityValue`). `ReferenceMonitor.CanRead(clearance, data)` allows iff `data <= clearance` and `CanWrite(current, destination)` iff `current <= destination`; a refusal reports the first of `SystemHighData`, `LevelTooLow`, `MissingCompartment` and `MissingCaveat`, with a `Detail` naming the offending level or tokens, and `default(AccessDecision)` is refused with `NoDecision`, so `Reason` is `None` exactly when allowed. `HighWaterMark` starts at its floor, only rises, joins atomically when `Observe` is raced from many threads, and judges a write against the join of everything it observed. | `SecurityLabelLatticeLawTests`, `SecurityLabelTextFormTests`, `SecurityLabelLevelParityTests`, `ReferenceMonitorDecisionTests`, `HighWaterMarkTests` | Four of the ways this goes wrong are one-token edits that still compile, and three of them fail open. `Dominates` testing superset rather than subset says a clearance lacking a compartment dominates data that carries it; `<` for `<=` on levels stops a label dominating anything at its own level, itself included; a `Join` that intersects caveats makes a high-water mark forget `NOWEB`, so data derived from a no-web source can be written somewhere the source could not go; swapping `CanWrite`'s operands turns no-write-down into no-write-up. A parser that tolerated a lowercase token or a space would mint a second spelling of one label, a parse failure that fell back to anything below `SystemHigh` would make unlabelled data readable, and an `AccessDecision` whose default read as allowed would turn an uninitialised field into a grant. The level parity exists because the two level vocabularies are meant to be one ordering, and a drift between them silently moves a level for anything that maps one onto the other. **Scope:** nothing in the runtime consults the monitor yet, so this pins the semantics a later caller inherits, not enforcement anywhere. These are classification-style controls inside the runtime, not an accredited cross-domain solution. A clearance or a write destination that cannot be parsed must fail closed the other way, to `Public`; that is the caller's decision, documented on `ParseOrSystemHigh` and not tested here. | | **The existing sensitivity vocabularies map onto security labels without moving their order.** Two bridges, both one way and level only. `Ashlar.AI.Pipeline.Rag.TrustTierOrder.RecordLabel` and `CallerLabel` resolve a tier name through `TryRank`, so they accept exactly what `RecordRank` and `CallerRank` accept (trimmed, any case, the `top-secret` alias): a known tier becomes the bare label at the level both ranks give it and prints as the canonical tier name, and a blank or unknown name fails closed in the direction its rank function already does, a record to `SystemHigh` and a caller to `Public`, so no tier name - `SystemHigh` and canonical label text included - yields a `SystemHigh` clearance. `Ashlar.BackgroundAgents.DataSensitivity.DataSensitivityLabelBridge.ToDataLabel` and `TryToClearance` key an `IDataSensitivityLevel` on `SensitivityValue` alone, the only thing `DataSensitivityRegistry.CanAccess` compares: 0 to 4 is the `SecurityLevel` of that number whatever the level is named or displayed as. Out of range they fail closed per role: as data, above 4 is `SystemHigh` and below 0 is `Public` (raising a data label only narrows who may read it); as a clearance, above 4 is narrowed to a bare `TopSecret` and below 0 is refused, because no label lies below `Public`. No level at all is `SystemHigh` as data and `Public` as a clearance. Names resolve by role, as the legacy resolvers do: a data label name through `GetByName` (an unknown name gives no level, so `SystemHigh`), a clearance name through `ResolveClearance`, whose floor the bridge refuses when a custom level below `Public` is registered, so the subject is never wider than legacy (pinned). Every bridged label has no compartments and no caveats: the four level flags (`AllowsExternalLLM`, `AllowsWebSearch`, `RequiresLocalOnly`, `AllowsNetworkExports`) are not carried, two levels that differ only in their flags map to equal labels, and whether flags become caveats is for a later change. `ReferenceMonitor.CanRead` over bridged labels decides as the legacy rule does on every pair of known tiers and every pair of values in 0 to 4, and is never wider anywhere: as `TrustTierOrder.IsAllowed` in every cell of the full matrix of known spellings and unknown names but the divergence below, and as `CanAccess` on every pair of values in 0 to 4, over an edge grid and generated levels with random flags. Outside 0 to 4 it is deliberately narrower (a custom level above 4 cannot read data at its own level, and a clearance below 0 reads nothing), pinned by the edge grid and the out-of-range facts. Within range, the one intended divergence is pinned on both sides, and the tier matrices count its cells exactly, so it cannot widen, vanish or move: an unlabelled or unrecognised record, or unlabelled data, is `SystemHigh`, so a `TopSecret` clearance that legacy lets read it (`IsAllowed`, or `CanAccess` after `DataSensitivityFallbacks.ResolveLabel`) is refused with `SystemHighData`, and the label form of the re-index downgrade rule refuses re-labelling an unlabelled stored record at `TopSecret`, which ranks accept. `RAGTool` calls `ToDataLabel` for a canonical hit (PR 4.5); `TryToClearance` and `TrustTierOrder.RecordLabel` are still not on a production decision path except the parity the producer fact pins. | `TrustTierLabelBridgeTests`, `DataSensitivityLabelBridgeTests`, `EgressSubjectProducerTests` | Each wrong bridge here compiles and looks reasonable. One that parsed tier or level names as label text (`SecurityLabel.TryParse`, `ParseOrSystemHigh`) would make `secret`, `top-secret` and `" Secret "`, all valid tiers today, `SystemHigh`: as a record label that hides the record from callers who read it now, and as a clearance, through `ParseOrSystemHigh`, one that reads everything. One that mapped an out-of-range clearance to `SystemHigh` would read everything; one that fell back to `TopSecret` for unlabelled data, as `ResolveLabel` does when no custom level above `TopSecret` is registered, would make that data readable by a `TopSecret` clearance; one that mapped by name instead of `SensitivityValue` would let a custom level named `SystemHigh` read everything. The divergence pins exist so that the change which switches enforcement to the bridges flips the legacy-side assertions by name, on purpose, rather than discovering the difference in production. **Scope:** `RAGTool.MapHitLabel` calls `ToDataLabel` for the egress read report (PR 4.5), pinned against `TrustTierOrder.RecordLabel`. `IsAllowed`, `VectorDataRagService`'s search filter and re-index downgrade check, `CanAccess`, `RagSensitivity`, `DataSensitivityFallbacks` and the markers decide exactly as before, so this pins the mapping a later caller inherits, not enforcement anywhere. Nothing may derive a tier or level name back from a label's `Level`, because `SystemHigh` reports `TopSecret` there; that is documented on both bridges and not tested here. | -| **Every outbound path from production code is pinned in the egress inventory, a new one cannot appear unlisted, and the egress guard decides as specified - report-only, with every listed non-exempt site routed through it.** The guard (`Ashlar.Abstractions.Security.Egress`: `IEgressGuard`, `EgressGuard`, `EgressHttp`, `EgressDecisionLog`) exists and is report-only. `Evaluate` classifies the destination by a table derived from the built-in `DataSensitivityLevels` flags (loopback, `localhost`, `unix`, `npipe` and a declared `host:` name are the host boundary at `SystemHigh`, and since SPEC-007 PR 4.1 a `file` URI or a `file:` name, recorded as written, never is; a model family is `Internal`, web search `Confidential`, a network export `Internal`, anything else `Public`), decides `ReferenceMonitor.CanWrite(current, destination)`, where current is the join of the high-water marks of every `EgressSubject` frame on the flow's chain, live or disposed, up to a detachment (the flow's own frame and every frame it was entered inside; the frame-semantics rows below) or, with no live frame or under a detachment, `SystemHigh` (`no-subject`), and publishes the record to the `Ashlar-Egress` EventSource and to every `EgressDecisionLog` subscriber, except a decision made on a thread that is already publishing (an egress that a sink or listener itself causes), which is returned to its caller and counted but not published, so the pipeline cannot recurse. `AddAshlarEgressGuard` (`Ashlar.Infrastructure.Egress`) subscribes an `ILogger` sink at Debug under `Ashlar.Egress` and puts the guard handler on every `IHttpClientFactory` client through one `ConfigureHttpClientDefaults` call. The guard never throws and refuses nothing: a classifier fault is recorded with `Fault` and `NoDecision`, and a URI or URL-shaped destination is recorded as scheme, host and port only, never userinfo, path or query (a `file:` name is a path and, since PR 4.1, is recorded as written, as every other file site's path is). **The mode (SPEC-007 PR 4.6).** Every decision also records a `Mode` (`report` or `enforce`) from one resolver, `EgressEnforcement.ResolveMode(profile, override)`, the `ModeBasis` that decided it, `Refused` (`Mode` is `enforce` and `Access` does not allow the egress) and `Ref`, a random 64-bit reference in 16 hex digits, and event 1 appends `modeBasis`, `refused` and `ref` at version 1. Every profile still defaults to `report`; an unreadable override or profile and a fault while resolving the mode fail closed to `enforce`; no route acts on the mode until PR 4.7, so the guard still refuses nothing. `EgressModeResolutionTests` pins the 6 × 4 mode table and what an explicit guard records; `EgressModeProcessBindingTests` pins the two bindings: the override read once, the hosting option raise-only, a guard without a profile never lowering a process that enforces, the strictest profile noted winning for `ProcessDefault` and for the guard `AddAshlar` composes and its startup line (a later `AddAshlar` on the same collection replaces the guard an earlier one composed, so a stricter profile or a raised mode reaches that guard and its line together), one profile read per decision, the reset seam, and the probe staying on its flow. The twins execute it: the table equals the derivation; `no-subject` is refused with `SystemHighData` off the host and allowed on loopback; an `Internal` subject frame is allowed to an external model and restored on `Dispose` across `await`; `air-gapped` and `secure-workstation` set `ProfileEnforcesByDefault` and `full` does not; `EgressHttp.Wrap` records exactly one decision per send, returns the inner response instance and never reads the request content, and a guard fault does not change the response (on net8.0 and later); a subscriber that causes an egress does not recurse, and its nested decision is not published; `AddAshlarEgressGuard` called twice, with a named client over a stub handler, records exactly one decision with site `factory:x`. The netstandard2.0 asset, which .NET 5-7 apps resolve, cannot override a synchronous `Send`, so it refuses one (SPEC-007 PR 4.2): `EgressHttpNetstandard20TwinTests` loads that build of `Ashlar.Abstractions`, which the csproj builds and copies to the output, into an `AssemblyLoadContext` of its own and proves the harness binds it, then shows a synchronous `Send` through a client or a `Wrap` handler refused with `NotSupportedException` naming the hop before the inner handler sees it and with no decision, `SendAsync` still evaluated exactly once with the same request and response, no record published when a client or handler is built (no egress has happened then), the hop under the guard handler in every client shape with the caller's inner handler reachable through the hop's internal `Inner`, the inner handler still owned, and `CreateDelegatingHandler` refused with `PlatformNotSupportedException` after its argument checks; and, with that copy and its own `Ashlar-Egress` event source loaded, the guard's own source still writes every decision (.NET will not initialize a second event source of the same name, so the harness initializes the guard's first); at the base commit that `Send` returned the inner handler's response with no decision. SPEC-007 PR 4.3 stops automatic redirects on every primary Ashlar builds or binds (`AllowAutoRedirect` reads false on `HttpClientHandler` and `SocketsHttpHandler`) and follows them in an internal handler directly above the primary. `EgressRedirectTwinTests` shows an `EgressHttp` client (P2) follows a same-host redirect and records a second decision, returns a cross-host 3xx without requesting the second host, and in report mode does not send hop 2 to a different host; a factory client (P1) follows a same-host and a cross-host redirect and re-evaluates each hop; a handler between the guard and the primary that rewrites the request URI is evaluated for the rewritten host before the primary sends; `ConfigureAdditionalHttpMessageHandlers` that `Clear()`s still has the guard handler and exactly one decision; an unknown primary is not flipped, a followed authority is evaluated after the send with a warning that the body may already have gone, and building one logs a warning naming the type; a started handler whose setter throws does not throw out of the bind; and the follower matches `SocketsHttpHandler` on 301, 302, 303, 307 and 308, a relative or missing `Location`, the fragment, the redirect limit, an HTTPS-to-HTTP refusal and clearing `Authorization`. On the netstandard2.0 asset the hop stays `SynchronousSendRefusedOnNetstandard20Asset` outside the redirect handler, so a synchronous `Send` still throws before the follower runs. The route twins run the routed sites themselves: a provider composed by `AddAshlarProfile` (which calls `AddAshlar`) records one `factory:` decision per send on its default client, and every kernel member that installs the guard still leaves one handler per client (`EgressKernelFactoryTwinTests`); every keyed MEAI target's outermost client is `EgressGuardChatClient`, which records one decision per call with the destination the target's inner client names (its `ChatClientMetadata.ProviderUri`, else the fail-closed `meai:`; only the in-process LLamaSharp client under `local:onnx` records none, and an Ollama model ending in `-cloud` or `:cloud` is recorded at `https://ollama.com`; a Bedrock key keeps site `EG-MDL-02` under a reported URI, and the AWS Bedrock client reports none, so its record is the region's endpoint), the default Ollama client does not follow a redirect, before PolicyGate's synchronous deny, and never lets a throwing host guard reach the caller (`EgressGuardChatClientTwinTests`); `ProviderFactory`'s static and Ollama clients and a gRPC channel record their sites, and `OllamaProvider` records an Ollama cloud model at `https://ollama.com`, a short name that resolves to one included (`EgressRawClientTwinTests`); and the explicit guards record their site, family and destination, among them `ProcessCommandRunner`'s docker host or context, Sneakernet's `.nxpkg` path, the shared-adaptation broadcast (both record a path spelled `//127.0.0.1/…` as written, by family, never as Host), the dotnet restore rule and Bing's endpoint (`EgressExplicitSiteTwinTests`). **3b routes every listed non-exempt site.** `ci/egress-inventory.tsv` pins every outbound site the scan sees: 86 (path, marker) rows with total and guarded counts and the `EG-` ids of `docs/EgressInventory.md`, 150 occurrences of which 48 are guarded (PR 4.2 adds the netstandard2.0 hop's `HttpMessageInvoker`, `Exempt:GuardImpl`). 33 rows have nothing unguarded (`-`); 35 carry a final reason from a closed `Exempt:` set (`Exempt:GuardImpl` only under `src/Ashlar.Abstractions/Security/Egress/`, `Exempt:ConsumerSdk` only under `src/Ashlar.Client/`); 10 are `Factory`, 4 `Upstream:` and 4 `Governance`. The 3a reason `Unrouted` is gone, and F3 rejects it. The population is every `.cs` under src, application, applications, commercial, products, tools and consumer-template, minus bin, obj, dot-directories, nested clones and test projects identified by their csproj content (never by name), with `spikes/` and `samples/` excluded by name, and with comments and literal contents blanked. The markers are `http.new`, `http.param`, `http.register`, `sdk.client`, `socket`, `process`, `door`, `telemetry`, `store`, `chat.register` and `banned`. The pins hold in both directions: a count above its pin or an unlisted pair fails, and so does a row that no longer matches exactly. Routes hold. A Wrapped count is met by the construction rule. A Precedes count is met by the guard-call rule, and for an http.param by the stored-client rule: in a type that is not partial, and never in a record's primary constructor (whose parameter is also a public property), the parameter is only named, null-checked, configured, stored into a private field or property, or sent after a guard; every other mention of that holder is a configuration or guard-preceded; and at least one send is guard-preceded. A Factory http.register is met by D1: a non-declaration `AddAshlarEgressGuard(` after the registration, as a statement of its own innermost code block (the file root of a top-level program counts), never before it or in an enclosing or sibling block. An http.param `Factory` holds by the call proof, where every supply site passes, in the parameter's position, a client made by an `IHttpClientFactory`-typed receiver declared in that file (directly, through a never-reassigned local, through a same-file private factory method, or through the caller's own parameter proven the same way; a deconstruction into the local or parameter, a tuple swap included, counts as a reassignment), or by the typed proof, where the type is a typed client of a Factory-classified `AddHttpClient<…, T>` and nothing else constructs it. `Upstream:` holds for an http.param when the named file is its only supplier, builds no unguarded client, exposes no client field, property, indexer or explicit interface property, is not partial and is itself grounded (a Wrapped http.new or another routed http.param row), and for an http.register when the row's project cannot reach `Ashlar.Infrastructure` and every executable that composes it calls `AddAshlarEgressGuard` in its program, the named one included. `Governance` holds only for the four MEAI pairs, and only while F5 is green. An Exempt reason is in the closed set. Factory and Upstream reason about a closed world: every supply site of a receiving member is a production file in this scan, found by name, an auto-property initializer `T P { … } = new(…)` included; a positional record's primary constructor fails both routes, since `with` or an object initializer can replace its public property; a spelling the scan cannot follow (`CreateInstance`, `typeof(T)`, an empty-argument generic registration, a method group) fails the route instead of being assumed away; the receiving type must not be partial and, for a constructor, must be sealed; and a cycle fails. A raw construction is Wrapped only inside an `EgressHttp.Wrap(` or `EgressHttp.CreateClient(` argument region, or when a local declaration stores it and every later mention of that local, to the end of its block, is inside such an argument region or configures it (`name.Prop = …`), with at least one inside a wrap; a field, or a local returned, passed elsewhere or reassigned, stays unguarded. `ConfigureHttpClientDefaults(` occurs exactly once in production, in `EgressServiceCollectionExtensions.cs`, around `EgressHttp.CreateDelegatingHandler(`, and `Add…`, `typeof(HttpClient)` and `Get[Required][Keyed]Service[s]` occur nowhere. F4 also holds (A) every http.register in AddAshlar's own file is Factory; (B) `AddAshlarEgressGuard(` is declared exactly once in production, in `EgressServiceCollectionExtensions.cs`; (D) no production type implements `IHttpClientFactory` and nothing registers it directly (`Add…`, `typeof(IHttpClientFactory)`, `ServiceDescriptor.{Singleton|Scoped|Transient}`); and (E) every call of `AddAshlarEgressGuard(` covers at least one registration under D1, so a call in the wrong place fails. Chat and embedding registrations occur only in `MeaiPipelineServiceCollectionExtensions.cs`; its four `AddKeyedChatClient` statements each carry `.UseAshlarGovernance(` with the same key, compared on the raw text because cleaning blanks literals; the leaf chat clients are built only inside them or in a local that only they use, and the Bedrock client only in `AwsBedrockChatClientFactory.cs`; the four leaf types (`OllamaHttpChatClient`, `LlamaSharpChatClient`, `AwsBedrockChatClientFactory`, `IBedrockChatClientFactory`) occur only in their own files and that registration file; no raw `IChatClient` is registered (`Add…`, `typeof(IChatClient)`, `new ServiceDescriptor(typeof(IChatClient), …)`); and governance composes EgressGuard, then PolicyGate, then Sanitizing, then Auditing, with `new EgressGuardChatClient(` in no other production file. Every TSV id is a row of `docs/EgressInventory.md`, every `EG-` literal in production code is a row id there, and every row there whose route is not `Unscanned:*` is named by a TSV row; a docs file from which no `EG-` row parses fails rather than checking against nothing. Stated floors (1000 scanned files, 60 examined occurrences and 30 parsed docs rows; measured 2,145, 150 and 67 with PR 4.5's one file on top of PR 4.3's two on top of PR 4.10, after 2,142, 150 and 67 at PR 4.10, 2,135, 150 and 67 at PR 4.4, 2,134, 150 and 67 at PR 4.2, 2,133, 149 and 67 at PR 4.6, 2,130, 148 and 67 at 3b and 2,128, 146 and 67 at 3a; 4.1 adds one occurrence, 4.6 three files, 4.2 one file and one occurrence, 4.4 one file, 4.10 seven files, and 4.3 two files (the redirect handler and the factory binding filter; neither constructs an HTTP client, so the occurrence count stays 150), and 4.5 one file (`IEgressLabelledTool.cs`, no new HTTP client)), the six guard files, named anchor files per marker and the reach of the scan (`RemoteExecutionPlatform.cs`, `Ashlar.Agents.TestKit`, the CLI and `commercial/src/` in; four test projects out) stop an emptied scan passing. 113 named classification controls (72 at 3a) drive the classifier on real shapes, and 72 route controls build small fixture trees whose route must hold or fail with a named clause; the known misses they pin hold what the scan reports today, so the change that teaches the scan must flip them. The class runs 194 tests: 9 facts, 113 classification controls and 72 route controls. A failure prints the observed rows in TSV form, so a pin is updated mechanically. | `EgressGuardConventionTests`, `EgressGuardDecisionTests`, `EgressModeResolutionTests`, `EgressModeProcessBindingTests`, `EgressHttpHandlerTwinTests`, `EgressHttpNetstandard20TwinTests`, `EgressRedirectTwinTests`, `EgressFactoryDefaultsTwinTests`, `EgressKernelFactoryTwinTests`, `EgressGuardChatClientTwinTests`, `EgressRawClientTwinTests`, `EgressExplicitSiteTwinTests` | An outbound path arrives and nothing records it. The inventory found three that no gate saw: `ProviderFactory`'s static `new()` client, which sends prompts to OpenAI outside `UseAshlarGovernance`, under AirGapped too whenever `OPENAI_API_KEY` is set; the `IdeEndpoints.cs` fallback `new HttpClient()`; and the MCP `HttpClientTransport` built with two arguments, so the SDK builds a client of its own. 3b builds all three with `EgressHttp`. Without the pins, a routed site could go back to a raw client and stay green, and the next one would be added unlisted. Without the twins, the guard could stop deciding, start reading request bodies or throw into a caller while every static fact stayed green; without the netstandard2.0 twin, the asset .NET 5-7 apps resolve would run in no required check, and a synchronous `Send` through it could go out unevaluated again while every net8.0 twin stayed green; without the route twins, a route could hold on paper while the client it names records nothing. **A tripwire, not a proof, and report-only:** the scan is textual. It does not see HTTP clients built inside third-party SDKs, inbound server responses (`Unscanned:Inbound`), file egress through no door method, aliases, interpolation holes or reflection, or an executable chosen by a caller of a process funnel. Its guard-call rule is textual order, not temporal, and it reads braces only: a guard with no braces of its own counts for its enclosing block, so a guard in an unbraced `if` or loop body, in another `switch` case, or in an expression-bodied lambda or local function counts though it may never run. A new network SDK is caught only once its constructor is taught. The routes add their own blind spots: an Upstream supplier passing a static client defined in another file; a guard installed on a different `IServiceCollection` from the one the registration fills; a host outside the repository that composes a factory consumer with no Ashlar member that installs the guard (`AddAshlarFederatedBrickMesh` alone); reflection and target-typed `new(…)` suppliers in an argument, a `return` or an expression-bodied `T P => new(…)`, and a bare call through `using static` from another file; an explicit interface method that returns the client from an Upstream file; an `IHttpClientFactory` or raw `IChatClient` registration whose service type the text does not name; an early return between a registration and its guard, and an install call with no braces of its own; an `IHttpClientFactory`-typed receiver backed by a custom implementation, which only F4 (D) catches; an Upstream file that hands its client out through a non-private method or a `Func`, `Lazy`, array or tuple member, and a guarded holder mention that copies a stored client into another member sent unguarded elsewhere (both pinned as known misses); and every factory client records the family `http.factory`, the model calls included. So this proves the inventory is complete for the taught shapes, that each pinned route holds as the scan reads it, and that the guard decides as specified; it does not prove that any path is mediated. Every listed non-exempt site now reaches a decision and the guard refuses nothing; enforcement is PR 4. These are classification-style controls inside the runtime, not an accredited cross-domain solution. | +| **Every outbound path from production code is pinned in the egress inventory, a new one cannot appear unlisted, and the egress guard decides as specified - report-only, with every listed non-exempt site routed through it.** The guard (`Ashlar.Abstractions.Security.Egress`: `IEgressGuard`, `EgressGuard`, `EgressHttp`, `EgressDecisionLog`) exists and is report-only. `Evaluate` classifies the destination by a table derived from the built-in `DataSensitivityLevels` flags (loopback, `localhost`, `unix`, `npipe` and a declared `host:` name are the host boundary at `SystemHigh`, and since SPEC-007 PR 4.1 a `file` URI or a `file:` name, recorded as written, never is; a model family is `Internal`, web search `Confidential`, a network export `Internal`, anything else `Public`), decides `ReferenceMonitor.CanWrite(current, destination)`, where current is the join of the high-water marks of every `EgressSubject` frame on the flow's chain, live or disposed, up to a detachment (the flow's own frame and every frame it was entered inside; the frame-semantics rows below) or, with no live frame or under a detachment, `SystemHigh` (`no-subject`), and publishes the record to the `Ashlar-Egress` EventSource and to every `EgressDecisionLog` subscriber, except a decision made on a thread that is already publishing (an egress that a sink or listener itself causes), which is returned to its caller and counted but not published, so the pipeline cannot recurse. `AddAshlarEgressGuard` (`Ashlar.Infrastructure.Egress`) subscribes an `ILogger` sink at Debug under `Ashlar.Egress` and puts the guard handler on every `IHttpClientFactory` client through one `ConfigureHttpClientDefaults` call. The guard never throws and refuses nothing: a classifier fault is recorded with `Fault` and `NoDecision`, and a URI or URL-shaped destination is recorded as scheme, host and port only, never userinfo, path or query (a `file:` name is a path and, since PR 4.1, is recorded as written, as every other file site's path is). **The mode (SPEC-007 PR 4.6).** Every decision also records a `Mode` (`report` or `enforce`) from one resolver, `EgressEnforcement.ResolveMode(profile, override)`, the `ModeBasis` that decided it, `Refused` (`Mode` is `enforce` and `Access` does not allow the egress) and `Ref`, a random 64-bit reference in 16 hex digits, and event 1 appends `modeBasis`, `refused` and `ref` at version 1. Every profile still defaults to `report`; an unreadable override or profile and a fault while resolving the mode fail closed to `enforce`; no route acts on the mode until PR 4.7, so the guard still refuses nothing. `EgressModeResolutionTests` pins the 6 × 4 mode table and what an explicit guard records; `EgressModeProcessBindingTests` pins the two bindings: the override read once, the hosting option raise-only, a guard without a profile never lowering a process that enforces, the strictest profile noted winning for `ProcessDefault` and for the guard `AddAshlar` composes and its startup line (a later `AddAshlar` on the same collection replaces the guard an earlier one composed, so a stricter profile or a raised mode reaches that guard and its line together), one profile read per decision, the reset seam, and the probe staying on its flow. The twins execute it: the table equals the derivation; `no-subject` is refused with `SystemHighData` off the host and allowed on loopback; an `Internal` subject frame is allowed to an external model and restored on `Dispose` across `await`; `air-gapped` and `secure-workstation` set `ProfileEnforcesByDefault` and `full` does not; `EgressHttp.Wrap` records exactly one decision per send, returns the inner response instance and never reads the request content, and a guard fault does not change the response (on net8.0 and later); a subscriber that causes an egress does not recurse, and its nested decision is not published; `AddAshlarEgressGuard` called twice, with a named client over a stub handler, records exactly one decision with site `factory:x`. The netstandard2.0 asset, which .NET 5-7 apps resolve, cannot override a synchronous `Send`, so it refuses one (SPEC-007 PR 4.2): `EgressHttpNetstandard20TwinTests` loads that build of `Ashlar.Abstractions`, which the csproj builds and copies to the output, into an `AssemblyLoadContext` of its own and proves the harness binds it, then shows a synchronous `Send` through a client or a `Wrap` handler refused with `NotSupportedException` naming the hop before the inner handler sees it and with no decision, `SendAsync` still evaluated exactly once with the same request and response, no record published when a client or handler is built (no egress has happened then), the hop under the guard handler in every client shape with the caller's inner handler reachable through the hop's internal `Inner`, the inner handler still owned, and `CreateDelegatingHandler` refused with `PlatformNotSupportedException` after its argument checks; and, with that copy and its own `Ashlar-Egress` event source loaded, the guard's own source still writes every decision (.NET will not initialize a second event source of the same name, so the harness initializes the guard's first); at the base commit that `Send` returned the inner handler's response with no decision. SPEC-007 PR 4.3/4.3b disables automatic redirects on unstarted, credential-free known primaries and follows each permitted hop directly above the primary. `EgressRedirectTwinTests`, `EgressRedirectHardeningTests`, `EgressRedirectDifferentialTests` and `EgressRedirectDifferentialHardeningTests` pin full-origin P2, cross-origin P1, O2 returning and recording inward Host/link-local attempts (including unix/npipe), per-hop decisions, credentialed/started/unknown primary behaviour, shared-primary original settings, per-client no-follow through shared primaries/followers on sync and async sends (`EgressRedirectNeverFollowTests`), composite rewrites, fresh requests, duplicate followers, cancellation, intermediate-response disposal, runtime method/header/fragment/limit parity, and the enforcing decision-before-send starting point for PR 4.7. Filter-order twins cover both registration orders and warning 7303 after `Clear()`; warning 7304 is AG/SW-only and once per client/type. The netstandard2.0 twin finds the existing follower through the hop and still refuses a synchronous send. `EgressApiHostProdStyleTests` and `EgressFleetHostTwinTests` exercise the SNS no-follow clients over real Kestrel; the Bedrock configuration twin pins its SDK no-follow setting. The route twins run the routed sites themselves: a provider composed by `AddAshlarProfile` (which calls `AddAshlar`) records one `factory:` decision per send on its default client, and every kernel member that installs the guard still leaves one handler per client (`EgressKernelFactoryTwinTests`); every keyed MEAI target's outermost client is `EgressGuardChatClient`, which records one decision per call with the destination the target's inner client names (its `ChatClientMetadata.ProviderUri`, else the fail-closed `meai:`; only the in-process LLamaSharp client under `local:onnx` records none, and an Ollama model ending in `-cloud` or `:cloud` is recorded at `https://ollama.com`; a Bedrock key keeps site `EG-MDL-02` under a reported URI, and the AWS Bedrock client reports none, so its record is the region's endpoint), the default Ollama client does not follow a redirect, before PolicyGate's synchronous deny, and never lets a throwing host guard reach the caller (`EgressGuardChatClientTwinTests`); `ProviderFactory`'s static and Ollama clients and a gRPC channel record their sites, and `OllamaProvider` records an Ollama cloud model at `https://ollama.com`, a short name that resolves to one included (`EgressRawClientTwinTests`); and the explicit guards record their site, family and destination, among them `ProcessCommandRunner`'s docker host or context, Sneakernet's `.nxpkg` path, the shared-adaptation broadcast (both record a path spelled `//127.0.0.1/…` as written, by family, never as Host), the dotnet restore rule and Bing's endpoint (`EgressExplicitSiteTwinTests`). **3b routes every listed non-exempt site.** `ci/egress-inventory.tsv` pins every outbound site the scan sees: 86 (path, marker) rows with total and guarded counts and the `EG-` ids of `docs/EgressInventory.md`, 150 occurrences of which 48 are guarded (PR 4.2 adds the netstandard2.0 hop's `HttpMessageInvoker`, `Exempt:GuardImpl`). 33 rows have nothing unguarded (`-`); 35 carry a final reason from a closed `Exempt:` set (`Exempt:GuardImpl` only under `src/Ashlar.Abstractions/Security/Egress/`, `Exempt:ConsumerSdk` only under `src/Ashlar.Client/`); 10 are `Factory`, 4 `Upstream:` and 4 `Governance`. The 3a reason `Unrouted` is gone, and F3 rejects it. The population is every `.cs` under src, application, applications, commercial, products, tools and consumer-template, minus bin, obj, dot-directories, nested clones and test projects identified by their csproj content (never by name), with `spikes/` and `samples/` excluded by name, and with comments and literal contents blanked. The markers are `http.new`, `http.param`, `http.register`, `sdk.client`, `socket`, `process`, `door`, `telemetry`, `store`, `chat.register` and `banned`. The pins hold in both directions: a count above its pin or an unlisted pair fails, and so does a row that no longer matches exactly. Routes hold. A Wrapped count is met by the construction rule. A Precedes count is met by the guard-call rule, and for an http.param by the stored-client rule: in a type that is not partial, and never in a record's primary constructor (whose parameter is also a public property), the parameter is only named, null-checked, configured, stored into a private field or property, or sent after a guard; every other mention of that holder is a configuration or guard-preceded; and at least one send is guard-preceded. A Factory http.register is met by D1: a non-declaration `AddAshlarEgressGuard(` after the registration, as a statement of its own innermost code block (the file root of a top-level program counts), never before it or in an enclosing or sibling block. An http.param `Factory` holds by the call proof, where every supply site passes, in the parameter's position, a client made by an `IHttpClientFactory`-typed receiver declared in that file (directly, through a never-reassigned local, through a same-file private factory method, or through the caller's own parameter proven the same way; a deconstruction into the local or parameter, a tuple swap included, counts as a reassignment), or by the typed proof, where the type is a typed client of a Factory-classified `AddHttpClient<…, T>` and nothing else constructs it. `Upstream:` holds for an http.param when the named file is its only supplier, builds no unguarded client, exposes no client field, property, indexer or explicit interface property, is not partial and is itself grounded (a Wrapped http.new or another routed http.param row), and for an http.register when the row's project cannot reach `Ashlar.Infrastructure` and every executable that composes it calls `AddAshlarEgressGuard` in its program, the named one included. `Governance` holds only for the four MEAI pairs, and only while F5 is green. An Exempt reason is in the closed set. Factory and Upstream reason about a closed world: every supply site of a receiving member is a production file in this scan, found by name, an auto-property initializer `T P { … } = new(…)` included; a positional record's primary constructor fails both routes, since `with` or an object initializer can replace its public property; a spelling the scan cannot follow (`CreateInstance`, `typeof(T)`, an empty-argument generic registration, a method group) fails the route instead of being assumed away; the receiving type must not be partial and, for a constructor, must be sealed; and a cycle fails. A raw construction is Wrapped only inside an `EgressHttp.Wrap(` or `EgressHttp.CreateClient(` argument region, or when a local declaration stores it and every later mention of that local, to the end of its block, is inside such an argument region or configures it (`name.Prop = …`), with at least one inside a wrap; a field, or a local returned, passed elsewhere or reassigned, stays unguarded. `ConfigureHttpClientDefaults(` occurs exactly once in production, in `EgressServiceCollectionExtensions.cs`, around `EgressHttp.CreateDelegatingHandler(`, and `Add…`, `typeof(HttpClient)` and `Get[Required][Keyed]Service[s]` occur nowhere. F4 also holds (A) every http.register in AddAshlar's own file is Factory; (B) `AddAshlarEgressGuard(` is declared exactly once in production, in `EgressServiceCollectionExtensions.cs`; (D) no production type implements `IHttpClientFactory` and nothing registers it directly (`Add…`, `typeof(IHttpClientFactory)`, `ServiceDescriptor.{Singleton|Scoped|Transient}`); and (E) every call of `AddAshlarEgressGuard(` covers at least one registration under D1, so a call in the wrong place fails. Chat and embedding registrations occur only in `MeaiPipelineServiceCollectionExtensions.cs`; its four `AddKeyedChatClient` statements each carry `.UseAshlarGovernance(` with the same key, compared on the raw text because cleaning blanks literals; the leaf chat clients are built only inside them or in a local that only they use, and the Bedrock client only in `AwsBedrockChatClientFactory.cs`; the four leaf types (`OllamaHttpChatClient`, `LlamaSharpChatClient`, `AwsBedrockChatClientFactory`, `IBedrockChatClientFactory`) occur only in their own files and that registration file; no raw `IChatClient` is registered (`Add…`, `typeof(IChatClient)`, `new ServiceDescriptor(typeof(IChatClient), …)`); and governance composes EgressGuard, then PolicyGate, then Sanitizing, then Auditing, with `new EgressGuardChatClient(` in no other production file. Every TSV id is a row of `docs/EgressInventory.md`, every `EG-` literal in production code is a row id there, and every row there whose route is not `Unscanned:*` is named by a TSV row; a docs file from which no `EG-` row parses fails rather than checking against nothing. Stated floors (1000 scanned files, 60 examined occurrences and 30 parsed docs rows; measured 2,145, 150 and 67 with PR 4.5's one file on top of PR 4.3's two on top of PR 4.10, after 2,142, 150 and 67 at PR 4.10, 2,135, 150 and 67 at PR 4.4, 2,134, 150 and 67 at PR 4.2, 2,133, 149 and 67 at PR 4.6, 2,130, 148 and 67 at 3b and 2,128, 146 and 67 at 3a; 4.1 adds one occurrence, 4.6 three files, 4.2 one file and one occurrence, 4.4 one file, 4.10 seven files, and 4.3 two files (the redirect handler and the factory binding filter; neither constructs an HTTP client, so the occurrence count stays 150), and 4.5 one file (`IEgressLabelledTool.cs`, no new HTTP client)), the six guard files, named anchor files per marker and the reach of the scan (`RemoteExecutionPlatform.cs`, `Ashlar.Agents.TestKit`, the CLI and `commercial/src/` in; four test projects out) stop an emptied scan passing. 113 named classification controls (72 at 3a) drive the classifier on real shapes, and 72 route controls build small fixture trees whose route must hold or fail with a named clause; the known misses they pin hold what the scan reports today, so the change that teaches the scan must flip them. The class runs 194 tests: 9 facts, 113 classification controls and 72 route controls. A failure prints the observed rows in TSV form, so a pin is updated mechanically. | `EgressGuardConventionTests`, `EgressGuardDecisionTests`, `EgressModeResolutionTests`, `EgressModeProcessBindingTests`, `EgressHttpHandlerTwinTests`, `EgressHttpNetstandard20TwinTests`, `EgressRedirectTwinTests`, `EgressRedirectHardeningTests`, `EgressRedirectDifferentialHardeningTests`, `EgressFactoryDefaultsTwinTests`, `EgressKernelFactoryTwinTests`, `EgressGuardChatClientTwinTests`, `EgressRawClientTwinTests`, `EgressExplicitSiteTwinTests` | An outbound path arrives and nothing records it. The inventory found three that no gate saw: `ProviderFactory`'s static `new()` client, which sends prompts to OpenAI outside `UseAshlarGovernance`, under AirGapped too whenever `OPENAI_API_KEY` is set; the `IdeEndpoints.cs` fallback `new HttpClient()`; and the MCP `HttpClientTransport` built with two arguments, so the SDK builds a client of its own. 3b builds all three with `EgressHttp`. Without the pins, a routed site could go back to a raw client and stay green, and the next one would be added unlisted. Without the twins, the guard could stop deciding, start reading request bodies or throw into a caller while every static fact stayed green; without the netstandard2.0 twin, the asset .NET 5-7 apps resolve would run in no required check, and a synchronous `Send` through it could go out unevaluated again while every net8.0 twin stayed green; without the route twins, a route could hold on paper while the client it names records nothing. **A tripwire, not a proof, and report-only:** the scan is textual. It does not see HTTP clients built inside third-party SDKs, inbound server responses (`Unscanned:Inbound`), file egress through no door method, aliases, interpolation holes or reflection, or an executable chosen by a caller of a process funnel. Its guard-call rule is textual order, not temporal, and it reads braces only: a guard with no braces of its own counts for its enclosing block, so a guard in an unbraced `if` or loop body, in another `switch` case, or in an expression-bodied lambda or local function counts though it may never run. A new network SDK is caught only once its constructor is taught. The routes add their own blind spots: an Upstream supplier passing a static client defined in another file; a guard installed on a different `IServiceCollection` from the one the registration fills; a host outside the repository that composes a factory consumer with no Ashlar member that installs the guard (`AddAshlarFederatedBrickMesh` alone); reflection and target-typed `new(…)` suppliers in an argument, a `return` or an expression-bodied `T P => new(…)`, and a bare call through `using static` from another file; an explicit interface method that returns the client from an Upstream file; an `IHttpClientFactory` or raw `IChatClient` registration whose service type the text does not name; an early return between a registration and its guard, and an install call with no braces of its own; an `IHttpClientFactory`-typed receiver backed by a custom implementation, which only F4 (D) catches; an Upstream file that hands its client out through a non-private method or a `Func`, `Lazy`, array or tuple member, and a guarded holder mention that copies a stored client into another member sent unguarded elsewhere (both pinned as known misses); and every factory client records the family `http.factory`, the model calls included. So this proves the inventory is complete for the taught shapes, that each pinned route holds as the scan reads it, and that the guard decides as specified; it does not prove that any path is mediated. Every listed non-exempt site now reaches a decision and the guard refuses nothing; enforcement is PR 4. These are classification-style controls inside the runtime, not an accredited cross-domain solution. | | **A nested egress subject never decides below a frame it was entered inside.** A decision's current label is the join of the high-water marks of every `EgressSubject` frame on the flow's chain, live or disposed, up to a detachment (the flow's own frame and every frame it was entered inside), read when the decision is made, with the innermost live frame's `subject:` basis, so a `Public` frame entered inside a `Secret` one decides `Secret`. Disposing a frame observes its mark into every frame it was entered inside, also from a child task whose frames never flow back out. `EgressSubject.Observe` joins a label into every frame on the chain at once, so an enclosing flow that egresses while a nested frame is still running already counts it; it only raises, and with no frame it does nothing. A frame disposed out of order still counts for the frame entered inside it (`Secret`, `LevelTooLow`), and a fire-and-forget task started inside a `Secret` frame still decides `Secret` after that frame's `using` ends, in the frame it had entered and in one it enters later, and with no frame of its own at what the frame's mark rose to after it ended, while the flow that ended it in order decides `Public`; what a sibling task reads after the parent ended, in a frame of its own or with none, still reaches the parent and so the sibling. A frame being disposed counts as live until its mark has reached the frames it was entered inside, so a task with no frame of its own inside it never decides below that mark, nor names the enclosing frame before it holds the mark. A flow leaves a frame only by disposing its own head while that head is undisposed, and goes back to exactly the frame that one was entered under, disposed or not; disposing a frame anywhere else moves no flow, and a flow whose head was already disposed never leaves it. So a flow that disposes its own frames out of order stays inside the outer frame it disposed (fail closed): a frame it enters afterwards joins that frame's mark (`Secret`), in every order of three (the six orders leave 13 frames on one flow, as a model of the rule says after every step), an async loop grows the flow's chain by exactly one frame per iteration (50 iterations, every length pinned), an enclosing live frame keeps the disposed frame's mark, and nothing stays reachable from the inner frame; and what such a flow reads later, with no frame of its own or in a frame it enters and ends in order, raises the outer frame's mark, which is the subject's shared one, so another session of that subject decides at it (`Confidential`, `LevelTooLow`). A task started inside a frame disposed first, also one started after an out-of-order dispose or handed a child scope to end, and the flow that entered a frame another flow disposed (a background task, `await Task.Run(parent.Dispose)`) stay inside it, in every dispose order and also after ending the frames around it, and read its mark when they decide, with no frame of their own or in one they enter, also what it rose to after it ended, by a direct raise or through the same mark entered in another frame (`Secret`). `EgressGuardDecisionTests.An_Internal_subject_may_reach_an_external_model_across_await_and_Dispose_restores_the_previous_frame` was flipped by name: its inner block pinned "inner Public decides Public". | `EgressSubjectNestingTests`, `EgressGuardDecisionTests` | Before SPEC-007 PR 4.4 the guard read only the innermost live frame, so any code that entered a fresh frame declassified everything its subject had read, and a nested subject's reads were lost when its frame ended. Once the guard enforces, either one is a write-down with no downgrade. A parent frame that ends first would otherwise declassify a child task still running inside it, whose closures can still hold the parent's data. If a flow could leave a frame other than by disposing its own head, some flow that never left a frame could decide below it. Staying inside fails closed, at two costs recorded as known limits in the `EgressSubject` remarks: a flow that disposes its frames out of order keeps the outer frame, so its chain grows with every repetition, which every later decision on it walks (availability), and what it reads later raises that subject's shared mark (another session's label). Neither arises on the flow that entered a frame and disposed it in a `using` block, in order, which every production `Enter` must be (PR 4.5); work created and started inside that frame, such as a task or a thread, still keeps it for as long as it runs, so what it reads later still raises the frame's shared mark. Work keeps the frames of the flow where it captured the execution context: a `Task`, a `System.Threading.Timer`, a registration or a continuation where it is created, a `Thread` or a `System.Timers.Timer` where it is started. So what is read by a `Task`, `System.Threading.Timer`, registration or continuation created before the frame and started or triggered inside it, such as a cold task, or by a thread created inside it and started after the `using` ends, never reaches the frame's mark, and a decision in the frame can be below it: a producer creates and starts the work it reads through inside its `using` block. Such an `Enter` is never inside an async helper, whose frame does not reach the flow that awaits it, and never across a `yield return` of an async iterator, whose body resumes on its consumer's flow, so the rest of the block would decide at the consumer's frames and its reads would never reach the frame's mark. If a flow still inside a disposed frame read only the mark that frame had when it ended, a later read by the same subject, under the same mark elsewhere, would not reach it. Report-only until the switch (4.11). Production enters a frame only at the floor-pin sites (PR 4.5): `SelfExtendRunnerAdapter.RunAsync` at `SecurityLabel.SystemHigh`. | | **A read counts at what it reported only when it returned and reported; otherwise it counts as `SystemHigh`.** `EgressSubject.BeginRead()` returns a `ReadScope`, which observes into every frame of the chain it was begun on when it is disposed: after `Complete()` with no `Report`, `SystemHigh`; after `Complete()` with reports, their join and nothing else (a `Public` report reads nothing and raises no mark); without `Complete()`, as when the read throws, `SystemHigh` even after a report. `EgressSubject.Observe` never satisfies a scope. The scope observes into the chain it was begun on from whatever flow it ends on, including one that never had that chain (`ExecutionContext.SuppressFlow`, a thread started without the execution context, or a callback run detached); an unreported read begun in a task with no frame of its own after its parent frame ended still raises that parent, so a sibling task inside it decides `SystemHigh`; a report after it ended still reaches the frames from any flow, concurrent reports all count, and with no frame a scope changes nothing. | `EgressSubjectReadScopeTests` | Without the unreported-read rule, an unlabelled tool result counts as nothing read and the agent's next model call carries it at the floor. Without the completion rule, a tool that reads and then throws sends its data out in the exception's message at whatever it reported first. If `Observe` satisfied a scope, any tool could launder its result by observing `Public` on a side value. `ToolCallingAgent` begins a read around each tool call and accepts `Report` only from `IEgressLabelledTool` (`RAGTool` in PR 4.5); an unlabelled tool or a throw is `SystemHigh`. | | **Work handed to another component is not decided at its caller's mark.** The internal, raise-only `EgressSubject.RunDetached(Action)` runs a callback with no subject on the calling flow and puts back exactly the caller's frame when it returns or throws, and when it throws, before any exception filter of the caller runs, since an exception's first pass runs filters before any `finally` block: the callback's own filters and `finally` blocks run detached, and a filter of the caller decides at the caller's mark (`Secret`, `LevelTooLow`), also around a nested callback, and what it reads, by `Observe` or a read scope, raises the caller's frame, and a frame it enters stays on the caller's flow until the flow disposes it (`Secret`, two frames on the flow); a decision inside it has no subject (`SystemHigh`); a frame entered inside it starts a chain of its own, whose reads never reach the caller's frames; a frame the callback enters and leaves undisposed is dropped from the calling flow, which decides at its own mark (`Internal`), also after it disposes that frame; a task inside a parent frame that has ended is back inside that parent after an `AgentBus` publish, and a frame it enters afterwards still decides at the parent's mark (`Secret`); and a task started inside the callback, and a `Task`, `System.Threading.Timer`, registration or continuation created inside it wherever it is started or triggered, keep no subject for their whole life: a task started inside it, after the callback returns, after the caller's frame ends, and when a frame it was handed ends after the callback returned, and a cold task, a timer, a cancellation registration and a continuation created inside it, wherever they are started or triggered (`SystemHigh`). Each of those captures the flow where it is created, so one the caller created keeps the caller's frame although the callback starts or triggers it (`Secret`, `LevelTooLow`); a thread and a `System.Timers.Timer` capture it where they are started, so one the caller created and the callback starts runs detached (`SystemHigh`), and one the callback created and the caller starts runs in the caller's frame (`Secret`, `LevelTooLow`). No caller holds the detachment, so none can end it out of order, on another flow, or by handing it to a task, and no program on one flow leaves the flow detached, or below a live frame it entered outside every callback: not the 24 that dispose three frames entered inside the callback in every order, 0 to 3 of them before it returns and the rest after it (each back in the caller's frame, at its mark, with one frame on the flow), and not any of the 2,092 programs of an enclosing frame, up to two more frames and up to two callbacks, nested or in turn, each checked step by step against a model of the rule, whose flows keep only the frames they entered outside the callbacks and disposed out of order (2,474). Read from its compiled body, `RunDetached` writes the flow's frame only inside a try that catches everything or in that catch (the detachment and both restores). `AgentBus.PublishAsync` starts every subscriber's task inside it, so a subscriber decides with no subject while the publisher is inside a frame, and the publisher's own frame is restored. A convention fact pins every `RunDetached` call site in the repository's C#, every tree but build output, dot directories and nested checkouts (exactly `AgentBus.cs` x1, over a floor of 1000 scanned files), and that the bus's `Task.Run(` and `subscription.Handler(` sit inside the callback and nowhere else in the file. | `EgressSubjectDetachTests` | `Task.Run` captures the publisher's execution context, so before SPEC-007 PR 4.4 a subscriber's egress was decided at the publisher's mark and recorded as the publisher's. A detachment a caller could hold could be disposed out of order, which left that flow detached below the live frame it was inside, so a frame it entered later decided below that frame: a write-down. A callback cannot be ended out of order. A restore made only in a `finally` would run a filter of the caller, such as `catch (Exception ex) when (Log(ex))`, at the callback's frame, below the live frame the caller is inside: a write-down, and its reads would miss the caller's frames. A restore repeated in a `finally` after the catch would run after that filter, in the rethrow's second pass, and drop a frame the filter entered, which the flow never disposed. A write of the flow's frame outside that try and its catch, such as a restore after the try, leaves a window in which an asynchronous abort (`ControlledExecution.Run` on .NET 7 and later) leaves `RunDetached` with the flow detached: the caller's filter for it decides below the caller's mark and what it reads misses the caller's frames, and the flow stays detached afterwards. A `Task`, a `System.Threading.Timer`, a registration or a continuation captures the flow where it is created, and a `Thread` or a `System.Timers.Timer` where it is started, so a new site must create and start the work it hands off inside the callback: a task created before it and started inside keeps the caller's frame, and a thread created inside it and started after it returns runs in the caller's frame. `RunDetached` only raises, but a call placed around a read would hide that read from the caller's frames, so a new site needs its reason recorded here. A tripwire, not a proof: a call through an alias, a delegate or reflection is not seen. | diff --git a/ci/egress-inventory.tsv b/ci/egress-inventory.tsv index 1b0071942..5e9251ff8 100644 --- a/ci/egress-inventory.tsv +++ b/ci/egress-inventory.tsv @@ -6,8 +6,8 @@ # ids: EG-... rows of docs/EgressInventory.md, comma-separated, or -. F1, F2 and F3 print the observed rows in this form when they fail. application/src/Ashlar.API/Endpoints/IdeEndpoints.cs http.new 1 1 Wrapped - EG-MDL-14 IDE tags endpoint: the fallback client when no IHttpClientFactory is registered, built by EgressHttp.CreateClient (:193) application/src/Ashlar.API/Middleware/Ingress/AwsSnsSmsWebhook.cs http.param 1 0 - Factory EG-HTTP-03 SNS confirm/verify helper (:107) is called only with the ashlar-sns-signing factory client (:72) -application/src/Ashlar.API/Program.cs http.register 1 1 Factory - EG-HTTP-03 named client ashlar-sns-signing (:172); AddAshlarEgressGuard follows AddAshlar in the same top-level block (:246) -application/src/Ashlar.API/Program.cs telemetry 2 2 Precedes - EG-TEL-01 OTLP metrics and traces exporters (:266, :273); the EG-TEL-01 guard opens their block (:261), decided once at registration +application/src/Ashlar.API/Program.cs http.register 1 1 Factory - EG-HTTP-03 named client ashlar-sns-signing (:171); AddAshlarEgressGuard follows AddAshlar in the same top-level block (:239) +application/src/Ashlar.API/Program.cs telemetry 2 2 Precedes - EG-TEL-01 OTLP metrics and traces exporters (:252, :259); the EG-TEL-01 guard opens their block (:247), decided once at registration application/src/Ashlar.CLI/Commands/BackgroundAgent/MeshAutoPullService.cs http.new 2 2 Wrapped - EG-MESH-04 static mTLS SocketsHttpHandler (:57), handed to EgressHttp.CreateClient for the pull client (:72) application/src/Ashlar.CLI/Commands/BackgroundAgent/MeshAutoPullService.cs http.param 1 0 - Upstream:application/src/Ashlar.CLI/Commands/BackgroundAgent/MeshAutoPullService.cs EG-MESH-04 PullPeerOnceAsync takes the static EgressHttp pull client; its only production call is in this file (:166) application/src/Ashlar.CLI/Commands/BackgroundAgent/MeshDiscoveryService.cs socket 2 1 Precedes Exempt:Inbound EG-MESH-05 beacon sender (:253) after the EG-MESH-05 guard that opens AnnounceLoopAsync (:249); the listener (:191) is Exempt:Inbound @@ -35,7 +35,7 @@ commercial/src/Ashlar.Commercial.Fleet.Host/Program.cs http.register 1 1 Factory commercial/src/Ashlar.Commercial.Fleet.Infrastructure/FleetServiceCollectionExtensions.cs http.register 1 0 - Upstream:commercial/src/Ashlar.Commercial.Fleet.Host/Program.cs EG-EXE-05,EG-LAT-01,EG-LAT-04 MeshLab worker client (:116); the Fleet's factory also serves the knowledge-sync, network-bus and peer-pull clients; this project cannot reach Ashlar.Infrastructure, and Fleet.Host, the one executable that composes it, installs the guard commercial/src/Ashlar.Commercial.Fleet.Infrastructure/MeshLab/MeshLabWorkerExecutorClient.cs http.param 3 0 - Factory EG-EXE-05 commercial copy: director and peer calls take the named factory client commercial/src/Ashlar.Commercial.MeshDirector/MeshDirectorCommand.cs http.new 1 1 Wrapped - EG-HTTP-07 mesh director fleet client, built by EgressHttp.CreateClient (:415) -src/Ashlar.AI.Pipeline/Clients/AwsBedrockChatClientFactory.cs sdk.client 3 0 - Governance EG-MDL-02 AmazonBedrockRuntimeClient (:40, :41) and AsIChatClient (:47); AllowAutoRedirect false on AmazonBedrockRuntimeConfig (SPEC-007 PR 4.3); the SDK owns the HTTP; reached only through the governed cloud:bedrock registration, whose outermost layer is EgressGuardChatClient +src/Ashlar.AI.Pipeline/Clients/AwsBedrockChatClientFactory.cs sdk.client 2 0 - Governance EG-MDL-02 AmazonBedrockRuntimeClient (:38) and AsIChatClient (:39); AllowAutoRedirect false on AmazonBedrockRuntimeConfig (SPEC-007 PR 4.3); the SDK owns the HTTP; reached only through the governed cloud:bedrock registration, whose outermost layer is EgressGuardChatClient src/Ashlar.AI.Pipeline/Clients/OllamaHttpChatClient.cs http.new 2 0 - Governance EG-MDL-01 default client to the resolved Ollama base URL and its HttpClientHandler with AllowAutoRedirect off (:170, SPEC-007 PR 4.1); reached only through the governed local:ollama registration, whose outermost layer is EgressGuardChatClient src/Ashlar.AI.Pipeline/Clients/OllamaHttpChatClient.cs http.param 1 0 - Governance EG-MDL-01 constructor taking a caller's client (:34); built only inside the governed registrations F5 holds src/Ashlar.AI.Pipeline/MeaiPipelineServiceCollectionExtensions.cs chat.register 7 0 - Governance EG-MDL-01,EG-MDL-02 keyed ollama/onnx/governed/Bedrock-tier registrations, the router, and the two leaf clients; F5 holds the four keyed registrations under UseAshlarGovernance (outermost EgressGuardChatClient), and the router dispatches only to them diff --git a/commercial/src/Ashlar.Commercial.Fleet.Host/Program.cs b/commercial/src/Ashlar.Commercial.Fleet.Host/Program.cs index 64fc79e40..fdeb48cb5 100644 --- a/commercial/src/Ashlar.Commercial.Fleet.Host/Program.cs +++ b/commercial/src/Ashlar.Commercial.Fleet.Host/Program.cs @@ -77,13 +77,7 @@ builder.Services.AddSingleton(); builder.Services.AddHttpClient("ashlar-sns-signing", c => c.Timeout = TimeSpan.FromSeconds(15)) - .ConfigurePrimaryHttpMessageHandler(static (handler, _) => - { - if (handler is HttpClientHandler httpClientHandler) - httpClientHandler.AllowAutoRedirect = false; - else if (handler is SocketsHttpHandler socketsHttpHandler) - socketsHttpHandler.AllowAutoRedirect = false; - }); + .NeverFollowRedirects(); builder.Services.AddSingleton(); builder.Services.AddRateLimiter(static o => { diff --git a/commercial/tests/Ashlar.Commercial.Tests.Fleet.Host/EgressFleetHostTwinTests.cs b/commercial/tests/Ashlar.Commercial.Tests.Fleet.Host/EgressFleetHostTwinTests.cs index f8b5356d7..68a02806f 100644 --- a/commercial/tests/Ashlar.Commercial.Tests.Fleet.Host/EgressFleetHostTwinTests.cs +++ b/commercial/tests/Ashlar.Commercial.Tests.Fleet.Host/EgressFleetHostTwinTests.cs @@ -2,10 +2,15 @@ using System.Net; using Ashlar.Abstractions.Security.Egress; using FluentAssertions; +using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Hosting.Server; +using Microsoft.AspNetCore.Hosting.Server.Features; +using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc.Testing; using Microsoft.AspNetCore.TestHost; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; using Xunit; namespace Ashlar.Commercial.Tests.Fleet.Host; @@ -22,6 +27,8 @@ namespace Ashlar.Commercial.Tests.Fleet.Host; /// proves Fleet.Host binds that client NAME: one send through it is one factory:mesh-lab-worker-executor /// decision. It does not enable the MeshLab worker, whose registration only runs when /// Ashlar:MeshLab:WorkerExecutor:Enabled is true; the factory builds a client by name either way. +/// Redirects (SPEC-007 PR 4.3). The ashlar-sns-signing client never follows one: against a +/// loopback server that answers 302, over the factory's own primary handler, it returns the 302. /// Isolation. The decision log is process-wide, so the assertion filters by a destination host unique /// to the test. Hermetic: a test-only client default puts a stub primary handler (a new one per handler build) under /// every client, so nothing leaves the process. @@ -31,6 +38,7 @@ public sealed class EgressFleetHostTwinTests { private const string TestApiKey = "fleet-host-egress-test-key"; private const string MeshLabWorkerClient = "mesh-lab-worker-executor"; + private const string SnsSigningClient = "ashlar-sns-signing"; [Fact] public async Task TheMeshLabWorkerClient_FromTheFleetHostFactory_RecordsOneDecision_WithItsFactorySite() @@ -72,6 +80,50 @@ public async Task TheMeshLabWorkerClient_FromTheFleetHostFactory_RecordsOneDecis decision.Fault.Should().BeNull(); } + [Fact] + public async Task TheSnsSigningClient_NeverFollowsARedirect_OverTheFactorysOwnPrimaryHandler() + { + // SPEC-007 PR 4.3: the signing-certificate fetch is checked against its host before the send, so a redirect + // would reach a host nobody checked. A loopback server answers 302; the client returns it and never asks for + // the target. The primary handler is the factory's own (no test default replaces it), so the runtime would + // follow on its own, and the egress redirect follower would follow for it, unless Program.cs turns it off. + var paths = new ConcurrentQueue(); + var serverBuilder = WebApplication.CreateBuilder(new WebApplicationOptions { EnvironmentName = "Production", ContentRootPath = AppContext.BaseDirectory }); + serverBuilder.Logging.ClearProviders(); + serverBuilder.WebHost.ConfigureKestrel(kestrel => kestrel.Listen(IPAddress.Loopback, 0)); + await using var server = serverBuilder.Build(); + server.Run(context => + { + paths.Enqueue(context.Request.Path.Value ?? string.Empty); + if (context.Request.Path == "/a") + { + context.Response.StatusCode = StatusCodes.Status302Found; + context.Response.Headers.Location = "/b"; + } + + return Task.CompletedTask; + }); + await server.StartAsync(); + var port = new Uri(server.Services.GetRequiredService().Features.Get()!.Addresses.Single()).Port; + + await using var factory = new WebApplicationFactory().WithWebHostBuilder(builder => + { + builder.UseEnvironment("Development"); + builder.UseSetting("Ashlar:Security:ApiKey", TestApiKey); + }); + using (var health = factory.CreateClient()) + { + (await health.GetAsync("/health")).StatusCode.Should().Be(HttpStatusCode.OK, "the host started"); + } + + using var client = factory.Services.GetRequiredService().CreateClient(SnsSigningClient); + using var response = await client.GetAsync(new Uri($"http://127.0.0.1:{port}/a")); + + response.StatusCode.Should().Be(HttpStatusCode.Found, "the SNS signing client returns the redirect"); + paths.Should().Equal(new[] { "/a" }, "the redirect target is never asked for"); + await server.StopAsync(); + } + /// Keeps the decisions whose destination names this test's unique host. private sealed class DecisionRecorder : IEgressDecisionSink { diff --git a/docs/EgressInventory.md b/docs/EgressInventory.md index 23796fcd8..5ac5e7c0f 100644 --- a/docs/EgressInventory.md +++ b/docs/EgressInventory.md @@ -12,8 +12,10 @@ This file lists every path by which a request or data leaves an Ashlar process, - **The strictest profile noted wins.** Once `AddAshlar` has noted AirGapped nothing replaces it, and once SecureWorkstation, only AirGapped does, so a second `AddAshlar()` with no profile no longer turns an AirGapped process into Full. Tests restore the noted profile and the mode latch through an internal reset seam, `EgressProcessState`, and `ProcessGlobalEnvironmentConventionTests` treats composing AirGapped or SecureWorkstation, raising the mode, and the seam as process-global writes, and pins every file that names the seam or a member that writes this state outside its rules. - **Startup line.** A hosted activator logs one line at Information (`Ashlar.Egress`, event 7302 `EgressMode`) with the mode, its basis, the profile of the guard `AddAshlar` composed and whether that profile was defaulted; an unrecognised override logs at Warning. A later `AddAshlar` on the same collection replaces the line together with the guard, so the line describes the container's guard whenever that guard is one `AddAshlar` composed. When the host's own `IEgressGuard` is the container's guard (registered before `AddAshlar` or after it), that guard decides and the line does not describe it. A process whose mode is not plain `report` also writes the line to standard error, once, so a host-less CLI verb shows it. - **Synchronous `Send` on the netstandard2.0 asset is refused, not evaluated (SPEC-007 PR 4.2).** .NET 5–7 apps resolve the netstandard2.0 asset of `Ashlar.Abstractions`, which is compiled against a `DelegatingHandler` with no `Send` to override, so the guard handler cannot evaluate a synchronous `HttpClient.Send` or `HttpMessageInvoker.Send` there. On that asset `EgressHttp.CreateClient` and `Wrap` put a hop under the guard handler (`SynchronousSendRefusedOnNetstandard20Asset`, an `HttpMessageHandler` that overrides only `SendAsync`), so on a runtime that has a synchronous `Send` (.NET 5 or later) the runtime refuses it with `NotSupportedException`, naming the hop, before anything is sent. No Ashlar code runs on that path, so the refusal is not recorded: the caller gets the exception, and no decision is published, neither for the refused send nor when the client or handler is built. `EgressHttp.CreateDelegatingHandler`, whose inner handler a factory pipeline sets, throws `PlatformNotSupportedException` on such a runtime. Replacing the `InnerHandler` of a `Wrap` handler removes the hop, which the `Wrap` docs forbid. This holds in every mode. `SendAsync` is still evaluated, and a redirect on that path is evaluated again; a synchronous `Send` never reaches the follower. On .NET Framework, classic Mono and Unity, which have no synchronous `Send`, nothing is refused; the only change there is the hop itself, one internal forwarding step under the guard handler, which a walker of `DelegatingHandler.InnerHandler` cannot step through (Known limits, below). No Ashlar source makes a synchronous HTTP `Send`, and every Ashlar host binds the net8.0 or later asset. `docs/SdkCompatibilityPolicy.md` states which asset covers synchronous sends; `EgressHttpNetstandard20TwinTests` (cert-gate) runs the netstandard2.0 build to show it. -- **Redirects are evaluated per hop (SPEC-007 PR 4.3), still report-only.** `AllowAutoRedirect` reads false on every `HttpClientHandler` or `SocketsHttpHandler` Ashlar builds or binds. Ashlar's internal follower then applies that primary's own previous settings. An `EgressHttp` client (P2) follows a same-host redirect and records another decision, and returns a cross-host 3xx without requesting the second host. A factory client (P1) follows a cross-host redirect and re-evaluates each hop. HTTPS to HTTP, and any scheme other than http or https, is not followed. `Authorization` is cleared on a followed redirect. `EgressDecision.Refused` stays false in report mode (under `ASHLAR_EGRESS_MODE=enforce` a refused hop records `Refused=true` and is still sent until PR 4.7). A primary type Ashlar cannot flip is not wrapped; if the response URI's authority differs from the one evaluated before the send, the guard evaluates it afterwards and logs that the body may already have gone. The SNS signing clients set `AllowAutoRedirect` false on the existing primary (`application/src/Ashlar.API/Program.cs:172`, `commercial/src/Ashlar.Commercial.Fleet.Host/Program.cs:79`) and do not follow. The Bedrock runtime client sets `AmazonBedrockRuntimeConfig.AllowAutoRedirect` false (`AwsBedrockChatClientFactory.cs:40-41`); the SDK owns that HTTP stack. `MeshAutoPullService.cs:57` and, since PR 4.1, `OllamaHttpChatClient.cs:170` already refused redirects and are unchanged. On the netstandard2.0 asset the synchronous-send hop still throws before the follower runs. **Known limit until PR 4.3b:** owner O2 (never follow into the Host boundary from outside) is not yet implemented; P1 may still follow into loopback / `*.localhost` / link-local with the request body. +- **Redirects are evaluated per hop (SPEC-007 PR 4.3/4.3b).** Ashlar disables automatic redirects on unstarted, credential-free `HttpClientHandler` and `SocketsHttpHandler` primaries and remembers the original setting and limit. Its follower sits directly above the primary, including a composite primary's tail. `EgressHttp` follows only the same origin (scheme, host and port; P2); factory clients follow across origins (P1). On both routes, Ashlar’s follower returns an outside-to-Host or link-local redirect unfollowed and records the attempted destination (owner O2, 2026-10-06), including loopback, `*.localhost`, unix and npipe. HTTPS to HTTP and non-HTTP schemes are not followed. Every followed hop records another decision, even within one origin; `Authorization` is cleared. Decisions default to report-only; a guard configured to enforce can record `Refused`, but the route acts on it only in PR 4.7. Factory binding restores a removed guard (warning 7303) without warning about the normal logging scope; an unknown-primary warning (7304) is AG/SW-only, once per client/type. An outer EgressHttp wrapper carries P2 on its request even when the pipeline already has a factory P1 follower, without changing another request through the shared factory. Both SNS signing clients call `NeverFollowRedirects` on their existing factory primary; real-host twins pin that behaviour. The helper also marks the named client, so its request flag stops an existing shared follower even when the primary's remembered setting permits redirects; another client's request keeps its own policy. Bedrock's `RuntimeConfig` disables SDK redirects. `MeshAutoPullService` and the default MEAI `OllamaHttpChatClient` already refuse redirects. On netstandard2.0 the walker recognises an existing follower through the synchronous-send hop; synchronous sends still throw before it runs. - **Known limits of the routed records.** These are records, not refusals, and none of them changes a request: + - **The redirect mediator cannot take over every transport.** Credentials, unknown primary types, started primaries and immutable or already-started handler chains retain their own redirect behaviour. The final response's changed origin is decided afterwards and warned, when the body may already have gone; intermediate hops and a return to the original origin are invisible. O2 cannot prevent an inward hop taken inside such a transport. A fresh replacement request loses the decision stamp and can produce a duplicate record; if a handler inside a nested factory pipeline also drops the outer P2 or no-follow request flag, that pipeline retains its inner redirect policy; a rewrite before a mediated primary is decided before that send. P2 and the O2 check compare URI spelling, not DNS resolution, so DNS rebinding and remote names resolving to local addresses remain outside this check. A parent starting concurrently with binding can prevent splicing after its primary was disabled; the resulting chain returns redirects instead of following them. Handler-chain replacement after binding can remove the mediator. + - **Custom headers follow P1 redirects.** A factory client can forward an API-key header and a 307/308 body to another remote origin. Only `Authorization` is cleared; a raw `EgressHttp` client uses full-origin P2. Host-to-Host and Host-to-remote factory redirects remain P1, and a local service can relay onward. Unknown-primary warning names are remembered for the process lifetime. - **Bedrock's host is reconstructed** (EG-MDL-02) from the configured region as `https://bedrock-runtime..amazonaws.com`, unless the inner client reports an absolute `ChatClientMetadata.ProviderUri`, which since PR 4.1 is recorded instead. The AWS MEAI client (`AWSSDK.Extensions.Bedrock.MEAI` 4.0.101.8) reports none, so the default record is the reconstruction; a cert-gate twin pins that. The AWS SDK's own endpoint resolution is not read: `AWS_ENDPOINT_URL`, `AWS_ENDPOINT_URL_BEDROCK_RUNTIME` and a profile `endpoint_url`, the FIPS and dual-stack flags, and partitions other than `aws` (`cn-*` is `amazonaws.com.cn`). So the recorded host can differ from the one the SDK dials. The record still fails closed: model family, `Internal`, whatever the host. - **MEAI guard faults are not recorded.** On the MEAI path a host `IEgressGuard` that throws drops that call's decision, and a guard that cannot be resolved falls back to `EgressGuard.ProcessDefault`; neither fault is counted or logged, where the HTTP handler counts `GuardFaults`. The chat layer also does not activate the Debug logger subscription itself (`Ashlar.AI.Pipeline` cannot reference `Ashlar.Infrastructure`), so its decisions reach the Debug log only once a factory client or the hosted activator has subscribed the logger. The EventSource gets every decision either way. - **EG-TEL-01 reaches the EventSource only.** It is decided in `Program.cs` before `builder.Build()`, when no logger subscription exists yet, so the API host does not write it to the Debug log. @@ -88,14 +90,14 @@ Two kinds of TSV pin have no row of their own here, and their `ids` cell is `-`. | EG-MDL-04 | `ProviderFactory.cs:366-373` | Same static client, POST | `{AZURE_OPENAI_ENDPOINT}/openai/deployments/{deployment}/chat/completions` | Prompts, `api-key` header | As EG-MDL-03, plus all three `AZURE_OPENAI_*` variables | Provider name | Handler(raw), shared client at `:61` | | EG-MDL-05 | `ProviderFactory.cs:422`, reached from `ExecuteVisionAsync` `:555-583` and `ExecuteVisionMultiFrameAsync` `:633-661` | Same static client, with no resilience wrapper | OpenAI or compat base URL | Prompts plus a base64 PNG as a data URI | As EG-MDL-03. The sanitiser never sees the image bytes (`SanitizingProviderFactory.cs:66-117`). | Provider name | Handler(raw) | | EG-MDL-06 | `ProviderFactory.cs:466` | Same static client | Azure deployment URL | Prompts plus a base64 PNG | As EG-MDL-05 | Provider name | Handler(raw) | -| EG-MDL-07 | `src/Ashlar.Infrastructure/Execution/Ollama/OllamaProvider.cs:256` (chat) and `:95` (tags); client built at `ProviderFactory.cs:814` | `HttpClient.SendAsync` / `GetAsync` | The ephemeral container when `ASHLAR_EPHEMERAL_MODELS=1`; otherwise `ASHLAR_OLLAMA_BASE_URL`, then `OLLAMA_BASE_URL`, then `http://localhost:11434` (`ProviderFactory.cs:770-781`) | Prompts and base64 images. Warm-up and availability checks send `GET api/tags`. | The default provider unless `ASHLAR_ALLOW_MOCK=1`. No profile check. Blind to the URL. | Provider name, base URL | Handler(raw) at `:814`. `OllamaProvider` receives that client (Upstream). A model ending in `-cloud` or `:cloud` adds an explicit decision before the chat, an external model at `https://ollama.com` (`OllamaProvider.cs:244`, PR 4.1); the handler's own decision still names the daemon, Host for a loopback one, so under enforcement only this explicit decision can refuse a cloud chat, and it must refuse before `SendAsync` (PR 4.9). | +| EG-MDL-07 | `src/Ashlar.Infrastructure/Execution/Ollama/OllamaProvider.cs:256` (chat) and `:95` (tags); client built at `ProviderFactory.cs:814` | `HttpClient.SendAsync` / `GetAsync` | The ephemeral container when `ASHLAR_EPHEMERAL_MODELS=1`; otherwise `ASHLAR_OLLAMA_BASE_URL`, then `OLLAMA_BASE_URL`, then `http://localhost:11434` (`ProviderFactory.cs:770-781`) | Prompts and base64 images. Warm-up and availability checks send `GET api/tags`. | The default provider unless `ASHLAR_ALLOW_MOCK=1`. No profile check. Blind to the URL. | Provider name, base URL | Handler(raw) at `:814`. `OllamaProvider` receives that client (Upstream). A model ending in `-cloud` or `:cloud` adds an explicit decision before the chat, an external model at `https://ollama.com` (`OllamaProvider.cs:244`, PR 4.1); the handler's own decision still names the daemon, Host for a loopback one, so under enforcement only this explicit decision can refuse a cloud chat, and it must refuse before `SendAsync` (PR 4.9). Its `EgressHttp` client is P2: a redirect changing scheme, host or port is returned unfollowed. | | EG-MDL-08 | `ProviderFactory.cs:732` (`ExecuteVideoAsync` `:677`) | Same static client, multipart POST | `{VIDEO_SERVICE_URL}/v1/analyze` | Prompt, system prompt, and an mp4 of every frame | Needs `VIDEO_SERVICE_URL`. No profile check. Frames are not sanitised. | None | Handler(raw) | | EG-MDL-09 | `src/Ashlar.Infrastructure/Execution/Routing/RunPodHttpClient.cs:43`, `:91`, `:128`, `:160`, `:184` | Typed client `AddHttpClient` (`RunPodCapabilityRoutingServiceCollectionExtensions.cs:54-61`) | `RunPodBrickConfig.BaseUrl`, else `https://api.runpod.io` | Dispatch body `{modelId, prompt, generationParameters, outputFormat, priority}` (`:82-89`). Without an API key, spin-up metadata still leaves, unauthenticated (`:199-209`). | NCR module: F, S, **AG, SW** (`Phases.cs:70-73`). `NcrCapabilityRouter` sends work remote on an overnight flag, VRAM shortfall or queue depth on every profile except AirGapped. On AirGapped those reasons run locally (`AirGapped: remote execution unavailable; running locally ()`) and peer-network-only throws (PR 4.10). The RunPod client is still registered. | None | Handler(factory) | | EG-MDL-10 | `src/Ashlar.Infrastructure/NodeCapabilityRuntime/Backends/OllamaModelServingBackend.cs:224` (`/api/chat` `:70`, `/api/generate` `:106`/`:128`, `/api/pull` `:150`); GETs at `:43`, `:170` | Named factory client (`NodeCapabilityRuntimeServiceCollectionExtensions.cs:136-152`) | `ASHLAR_OLLAMA_BASE_URL`, then `Ashlar:NodeCapabilityRuntime:Ollama:BaseUrl`, then `OLLAMA_BASE_URL`, then `http://127.0.0.1:11434` | Live traffic is load/unload (model id, keep_alive) and health GETs. `RunInferenceAsync` would send prompts but has no product caller. `/api/pull` makes the Ollama daemon download, a second hop outside the process. | NCR module: F, S, AG, SW | Model id | Handler(factory) | | EG-MDL-11 | `src/Ashlar.BackgroundAgents/Autonomy/OllamaProposalSource.cs:133` | `HttpClient.PostAsJsonAsync` on an injected client | `OllamaProposalOptions.BaseUrl` (default `http://localhost:11434`) | The objective, certification-gate feedback, and the proposer's previous source code | `[Experimental]`. Not in any product DI; only `spikes/autonomy-first-flight/FirstFlight/SweepMode.cs:141` builds it. | None | Explicit, in `ProposeAsync` before the send (`OllamaProposalSource.cs:132`). The injected client is held in a private field and sent nowhere else. | | EG-MDL-12 | `src/Ashlar.Infrastructure/Trust/CloudAvailabilityResolver.cs:104`; client at `:98` | `HttpClient.GetAsync` | `https://api.openai.com/v1/models` | None. The GET is unauthenticated, but it reveals that this host is probing. | Runs only with `ASHLAR_AIRGAP_PROBE=1` and no `ASHLAR_AIRGAP` or config key. Registered with trust services (F, S, SW). `ICloudAvailabilityResolver` has no consumer. | n/a | Handler(raw) for the fallback client. The client injectable through the constructor is `Exempt:TestSeam`. | | EG-MDL-13 | `src/Ashlar.Infrastructure/ModelArtifacts/OllamaRemoteLibraryModelArtifactCatalogSource.cs:52` | Named factory client (`ModelArtifactCatalogServiceCollectionExtensions.cs:59-65`) | `https://ollama.com` | None (metadata GET) | The property default is true. `AddModelArtifactCatalog` sets `Enabled` false on AirGapped when the key is absent (PR 4.10); an explicit value still wins. Full stays true. NCR module, AG included. Dormant: `ListInstallableAsync` has no caller. | n/a | Handler(factory) | -| EG-MDL-14 | `OllamaTagsModelArtifactCatalogSource.cs:39`, `:52`; `DockerOllamaModelArtifactCatalogSource.cs:127`; `application/src/Ashlar.API/Endpoints/IdeEndpoints.cs:194`, with a fallback client at `:193` | Factory clients; the IDE endpoint falls back to an `EgressHttp` client | Loopback by default, configurable | None (`GET api/tags`) | NCR module. The IDE endpoint is always mapped in the API. | n/a | Handler(factory); the IDE fallback is Handler(raw) | +| EG-MDL-14 | `OllamaTagsModelArtifactCatalogSource.cs:39`, `:52`; `DockerOllamaModelArtifactCatalogSource.cs:127`; `application/src/Ashlar.API/Endpoints/IdeEndpoints.cs:194`, with a fallback client at `:193` | Factory clients; the IDE endpoint falls back to an `EgressHttp` client | Loopback by default, configurable | None (`GET api/tags`) | NCR module. The IDE endpoint is always mapped in the API. | n/a | Handler(factory); the IDE fallback is Handler(raw); the factory routes use P1 and the IDE fallback uses full-origin P2, so a cross-origin redirect has different behaviour on the two routes. | Checked and found not to be egress: - LLamaSharp (`local:onnx`, `LocalModelProvider`, `LlamaSharpChatClient`). diff --git a/docs/knowledge-graph.json b/docs/knowledge-graph.json index dd00ca1df..44f83602a 100644 --- a/docs/knowledge-graph.json +++ b/docs/knowledge-graph.json @@ -15,8 +15,8 @@ "operator_facing_config_variables": 155, "operator_vars_missing_from_configuration_reference": 18, "operator_vars_absent_from_all_documentation": 0, - "test_files_declaring_facts": 779, - "declared_facts": 4940, + "test_files_declaring_facts": 782, + "declared_facts": 4975, "workflows": 61 }, "operator_vars_missing_from_configuration_reference": [ @@ -5179,7 +5179,7 @@ "file": "src/Ashlar.Tests.Infrastructure/Tests/Certification/EgressHttpNetstandard20TwinTests.cs", "project": "Ashlar.Tests.Infrastructure", "namespace": "Ashlar.Tests.Infrastructure.Tests.Certification", - "declared_facts": 8, + "declared_facts": 9, "in_cert_gate_filter": true }, { @@ -5210,6 +5210,27 @@ "declared_facts": 5, "in_cert_gate_filter": true }, + { + "file": "src/Ashlar.Tests.Infrastructure/Tests/Certification/EgressRedirectDifferentialHardeningTests.cs", + "project": "Ashlar.Tests.Infrastructure", + "namespace": "Ashlar.Tests.Infrastructure.Tests.Certification", + "declared_facts": 8, + "in_cert_gate_filter": true + }, + { + "file": "src/Ashlar.Tests.Infrastructure/Tests/Certification/EgressRedirectHardeningTests.cs", + "project": "Ashlar.Tests.Infrastructure", + "namespace": "Ashlar.Tests.Infrastructure.Tests.Certification", + "declared_facts": 22, + "in_cert_gate_filter": true + }, + { + "file": "src/Ashlar.Tests.Infrastructure/Tests/Certification/EgressRedirectNeverFollowTests.cs", + "project": "Ashlar.Tests.Infrastructure", + "namespace": "Ashlar.Tests.Infrastructure.Tests.Certification", + "declared_facts": 3, + "in_cert_gate_filter": true + }, { "file": "src/Ashlar.Tests.Infrastructure/Tests/Certification/EgressRedirectTwinTests.cs", "project": "Ashlar.Tests.Infrastructure", @@ -7202,7 +7223,7 @@ "file": "src/Ashlar.Tests.Infrastructure/Tests/VirtualProduction/EgressApiHostProdStyleTests.cs", "project": "Ashlar.Tests.Infrastructure", "namespace": "Ashlar.Tests.Infrastructure.Tests.VirtualProduction", - "declared_facts": 3, + "declared_facts": 4, "in_cert_gate_filter": false }, { diff --git a/docs/knowledge-graph.md b/docs/knowledge-graph.md index d57db14ea..c0c532d43 100644 --- a/docs/knowledge-graph.md +++ b/docs/knowledge-graph.md @@ -19,8 +19,8 @@ remembered to update — staleness here is a red build. | of those, operator-facing (not test-only) | 155 | | missing from `docs/Configuration.md` | 18 | | **mentioned in no document at all** | **0** | -| test files declaring xUnit facts | 779 | -| declared facts | 4940 | +| test files declaring xUnit facts | 782 | +| declared facts | 4975 | | workflows | 61 | ## Namespaces cert-gate selects diff --git a/docs/specs/SPEC-007-security-labels-and-reference-monitor.md b/docs/specs/SPEC-007-security-labels-and-reference-monitor.md index 1415879f5..5e329d0e3 100644 --- a/docs/specs/SPEC-007-security-labels-and-reference-monitor.md +++ b/docs/specs/SPEC-007-security-labels-and-reference-monitor.md @@ -98,14 +98,19 @@ The status line above and the starting prompt are the owner's, as written; the s or later. The refusal publishes no decision record (the owner's 2026-10-06 amendment of the design's D31, decisions log): no Ashlar code runs on that path, short of a process-wide first-chance-exception hook, and a record published when a client is built would report a refused egress where none happened. -- **PR 4.3** (#722, `904cf909a`): known primaries Ashlar builds or binds have `AllowAutoRedirect` false, and an - internal follower applies that primary's own redirect settings. An `EgressHttp` client follows only a same-host - redirect (P2); a factory client follows a cross-host redirect and each hop is evaluated again (P1). HTTPS to HTTP - is not followed. `Authorization` is cleared on a followed redirect. Report-only: `Refused` stays false in report - mode. On the netstandard2.0 asset the synchronous-send hop is unchanged and still throws before the follower runs. - **Known limit until PR 4.3b:** owner decision O2 (2026-10-06) — never follow a redirect from outside the host - boundary into it — is not yet implemented; a factory client may still follow into loopback / `*.localhost` / - link-local (see decisions log). +- **PR 4.3** (#722, merge `904cf909a`; hardened in PR 4.3b): an unstarted, credential-free known + primary has `AllowAutoRedirect` false and an internal follower applies its original settings. `EgressHttp` + follows only the same origin (scheme, host and port; P2); factory clients follow across origins (P1). + On both routes, Ashlar’s follower returns an outside-to-Host or link-local redirect unfollowed and records the hop (owner O2, + 2026-10-06), including `*.localhost`, unix and npipe. HTTPS to HTTP and non-HTTP schemes are not followed. + Every followed hop is decided again, including a same-origin hop; `Authorization` is cleared. Credentials, + unknown primary types, already-started handlers and immutable chains keep their transport's behaviour; + only a changed final authority can be reported afterwards. Factory binding restores a removed guard, + mediates composite primaries and avoids duplicate followers; unknown-type warning 7304 is AG/SW-only, + once per client/type, and a normal logging scope emits no relocation warning. The SNS clients use the + configure-existing `NeverFollowRedirects` helper, whose named-client request flag also stops an already-shared follower without changing other clients. Bedrock's `RuntimeConfig` disables SDK redirects. + Decisions remain report-only by default; an explicit enforcing guard can record `Refused`, but the route + acts on it only in PR 4.7. The netstandard2.0 synchronous-send hop still refuses before the follower runs. - **PR 4.6** (#718, `3196ba1`), mode plumbing: one resolver gives every decision a mode (`report` or `enforce`), its basis, `Refused` and a random `Ref`. `enforce` is an opt-in on every profile through `ASHLAR_EGRESS_MODE` (read once per process), `AshlarHostingOptions.EgressMode` (raise-only) or an explicit guard's constructor, and every profile @@ -474,7 +479,7 @@ in the next section. | 2026-10-06 | PR 4 (4.2) | **A synchronous `Send` refused on the `netstandard2.0` asset leaves no decision record; the exception is the only signal.** This amends the PR 4 design's default D31, which wanted the hop to publish a `NoDecision` record so the refusal reaches the operator log. No Ashlar code runs when the runtime refuses that `Send`, so a record could only be published when a client is built, which would claim a refused egress where none happened, or from a process-wide `AppDomain.FirstChanceException` hook, which runs on every exception in the host. The `NotSupportedException` reaches the caller, whose own error handling logs it. The accepted cost: under `AirGapped` or `SecureWorkstation` enforcement, such a refusal never appears in Ashlar's egress log. Ashlar's own hosts bind `net8.0` or `net10.0` and are unaffected; only an app that binds the `netstandard2.0` asset on .NET 5 or later is. | | 2026-10-06 | PR 4 (4.5) | **While a read scope is open and unreported, every egress decided on the flows inside it is decided at `SystemHigh`, for every tool, `RAGTool` included (no labelled-tool exemption).** This answers the read-scope question the PR 4.4 phase carried to PR 4.5: a scope observes its result only when it ends, so without this a tool that reads and then egresses within one call was decided at the mark from before the read. Once the scope ends, the frames it was begun in hold what it observed: the reported label if it completed and reported, else `SystemHigh`, as PR 4.4 already does. The accepted cost: under a runner floor below `SystemHigh`, a tool's own egress during its call is refused once the guard enforces. In the PR 4 design's §5 leak test, Scenario B's expected reason changes from `LevelTooLow` to `SystemHighData`; control C4's inner send during a tool call is decided at `SystemHigh` too. Scenario B and control C5 then share a reason and differ by site and family, which the leak test must assert. **Implemented in:** not yet on master (PR #721 left the 4.4 observe-on-dispose rule and pinned it); tracked in PR 4.5b. | | 2026-10-06 | PR 4 (Q8 clarified) | **In PR 4, RAG maps a custom `IDataSensitivityLevel` to `SystemHigh` (D15 stands); Q8's C3 normalisation ("the lowest built-in level whose flags are no more permissive") is deferred** to the first producer that labels data from a custom level on purpose. **Implemented in:** #721 (`RAGTool.MapHitLabel`: non-canonical levels → `SystemHigh`). | -| 2026-10-06 | PR 4 (4.3, O2) | **A redirect hop from outside the host boundary into it is never followed.** When the redirect follower holds a 3xx whose `Location` names the host boundary (loopback, `localhost` and `*.localhost`, `unix`, `npipe`, as a decision classifies Host, plus a link-local address) and the request came from an authority outside it, the hop is not followed, for factory (P1) clients as much as for `EgressHttp` (P2): the hop is decided and the 3xx is returned. Reason: a remote peer must not bounce a request, body included, to a local service. Cost: a host client behind a remote that legitimately redirects to a local service gets the 3xx. The rest of P1 stands. **Implemented in:** not yet on master (#722 follows cross-host without this exception); tracked in PR 4.3b. | +| 2026-10-06 | PR 4 (4.3, O2) | **A redirect hop from outside the host boundary into it is never followed.** When the redirect follower holds a 3xx whose `Location` names the host boundary (loopback, `localhost` and `*.localhost`, `unix`, `npipe`, as a decision classifies Host, plus a link-local address) and the request came from an authority outside it, the hop is not followed, for factory (P1) clients as much as for `EgressHttp` (P2): the hop is decided and the 3xx is returned. Reason: a remote peer must not bounce a request, body included, to a local service. Cost: a host client behind a remote that legitimately redirects to a local service gets the 3xx. The rest of P1 stands. **Implemented in:** PR 4.3b (this change; #722 followed cross-host without the exception). | ## Design as merged (added 2026-10-04) diff --git a/scripts/mutations/spec007-redirects.json b/scripts/mutations/spec007-redirects.json new file mode 100644 index 000000000..93b519863 --- /dev/null +++ b/scripts/mutations/spec007-redirects.json @@ -0,0 +1,403 @@ +{ + "schema": 1, + "prepared_against": "831a2f5b88b3197f64260d8842352d38b7f5f4fc", + "note": "Prepared only; no mutation run evidence. Revalidate every exact replacement on the integrated shipping SHA. m16 never existed in the historical 25-mutant lane.", + "mutations": [ + { + "id": "m1-per-authority-decision-dropped", + "file": "src/Ashlar.Abstractions/Security/Egress/EgressRedirectHandler.cs", + "old": "request.Options.TryGetValue(Key, out var stamped) && stamped.Equals(Authority.From(request.RequestUri));", + "new": "request.Options.TryGetValue(Key, out var stamped);", + "filter": "FullyQualifiedName~EgressRedirect|FullyQualifiedName~EgressHttpNetstandard20TwinTests|FullyQualifiedName~EgressHttpHandlerTwinTests|FullyQualifiedName~EgressFactoryDefaultsTwinTests", + "framework": "net8.0", + "project": "src/Ashlar.Tests.Infrastructure/Ashlar.Tests.Infrastructure.csproj", + "expected_failures": [ + "A_rewriter_between_guard_and_primary_is_evaluated_for_the_rewritten_host" + ] + }, + { + "id": "m2-egresshttp-installs-no-follower", + "file": "src/Ashlar.Abstractions/Security/Egress/EgressHttp.cs", + "old": "var withRedirect = EgressRedirects.InsertAbovePrimary(inner, family, site, guard, followCrossHost: false);", + "new": "var withRedirect = inner;", + "filter": "FullyQualifiedName~EgressRedirect|FullyQualifiedName~EgressHttpNetstandard20TwinTests|FullyQualifiedName~EgressHttpHandlerTwinTests|FullyQualifiedName~EgressFactoryDefaultsTwinTests", + "framework": "net8.0", + "project": "src/Ashlar.Tests.Infrastructure/Ashlar.Tests.Infrastructure.csproj", + "expected_failures": [ + "EgressHttp_same_host_redirect_records_a_second_decision" + ] + }, + { + "id": "m3-factory-filter-installs-no-follower", + "file": "src/Ashlar.Infrastructure/Egress/EgressRedirectBindingFilter.cs", + "old": "builder.PrimaryHandler = EgressRedirects.InsertAbovePrimary(primary, EgressFamilies.HttpFactory, site, guard, followCrossHost: true);", + "new": "builder.PrimaryHandler = primary;", + "filter": "FullyQualifiedName~EgressRedirect|FullyQualifiedName~EgressHttpNetstandard20TwinTests|FullyQualifiedName~EgressHttpHandlerTwinTests|FullyQualifiedName~EgressFactoryDefaultsTwinTests", + "framework": "net8.0", + "project": "src/Ashlar.Tests.Infrastructure/Ashlar.Tests.Infrastructure.csproj", + "expected_failures": [ + "Factory_cross_host_redirect_is_followed_and_re_evaluated" + ] + }, + { + "id": "m4-guard-handler-not-restored", + "file": "src/Ashlar.Infrastructure/Egress/EgressRedirectBindingFilter.cs", + "old": "if (index < 0)", + "new": "if (index < 0) return;\n if (index < 0)", + "filter": "FullyQualifiedName~EgressRedirect|FullyQualifiedName~EgressHttpNetstandard20TwinTests|FullyQualifiedName~EgressHttpHandlerTwinTests|FullyQualifiedName~EgressFactoryDefaultsTwinTests", + "framework": "net8.0", + "project": "src/Ashlar.Tests.Infrastructure/Ashlar.Tests.Infrastructure.csproj", + "expected_failures": [ + "Clear_still_has_one_guard_handler_and_exactly_one_decision" + ] + }, + { + "id": "m5-p2-cross-origin-followed", + "file": "src/Ashlar.Abstractions/Security/Egress/EgressRedirectHandler.cs", + "old": "if ((!_settings.FollowCrossHost || EgressEvaluatedAuthority.RequiresSameOrigin(request))\n && !EgressEvaluatedAuthority.SameOrigin(requestUri, header))", + "new": "if ((!_settings.FollowCrossHost || EgressEvaluatedAuthority.RequiresSameOrigin(request))\n && !EgressEvaluatedAuthority.SameOrigin(requestUri, header) && _settings.MaxAutomaticRedirections < 0)", + "filter": "FullyQualifiedName~EgressRedirect|FullyQualifiedName~EgressHttpNetstandard20TwinTests|FullyQualifiedName~EgressHttpHandlerTwinTests|FullyQualifiedName~EgressFactoryDefaultsTwinTests", + "framework": "net8.0", + "project": "src/Ashlar.Tests.Infrastructure/Ashlar.Tests.Infrastructure.csproj", + "expected_failures": [ + "P2_does_not_forward_API_key_headers_to_another_scheme_or_port" + ] + }, + { + "id": "m6-https-to-http-followed", + "file": "src/Ashlar.Abstractions/Security/Egress/EgressRedirectHandler.cs", + "old": "if (IsHttpsToHttp(requestUri, header))", + "new": "if (IsHttpsToHttp(header, requestUri))", + "filter": "FullyQualifiedName~EgressRedirect|FullyQualifiedName~EgressHttpNetstandard20TwinTests|FullyQualifiedName~EgressHttpHandlerTwinTests|FullyQualifiedName~EgressFactoryDefaultsTwinTests", + "framework": "net8.0", + "project": "src/Ashlar.Tests.Infrastructure/Ashlar.Tests.Infrastructure.csproj", + "expected_failures": [ + "Differential_https_to_http_is_not_followed" + ] + }, + { + "id": "m7-authorization-not-cleared", + "file": "src/Ashlar.Abstractions/Security/Egress/EgressRedirectHandler.cs", + "old": " request.Headers.Authorization = null;\n", + "new": "", + "filter": "FullyQualifiedName~EgressRedirect|FullyQualifiedName~EgressHttpNetstandard20TwinTests|FullyQualifiedName~EgressHttpHandlerTwinTests|FullyQualifiedName~EgressFactoryDefaultsTwinTests", + "framework": "net8.0", + "project": "src/Ashlar.Tests.Infrastructure/Ashlar.Tests.Infrastructure.csproj", + "expected_failures": [ + "Differential_authorization_is_cleared_on_a_followed_redirect" + ] + }, + { + "id": "m8-post-send-check-dropped-async", + "file": "src/Ashlar.Abstractions/Security/Egress/EgressGuardHandler.cs", + "old": " var response = await send.ConfigureAwait(false);\n FinishSend(request, response);", + "new": " var response = await send.ConfigureAwait(false);", + "filter": "FullyQualifiedName~EgressRedirect|FullyQualifiedName~EgressHttpNetstandard20TwinTests|FullyQualifiedName~EgressHttpHandlerTwinTests|FullyQualifiedName~EgressFactoryDefaultsTwinTests", + "framework": "net8.0", + "project": "src/Ashlar.Tests.Infrastructure/Ashlar.Tests.Infrastructure.csproj", + "expected_failures": [ + "Unknown_primary_followed_authority_is_evaluated_after_the_send" + ] + }, + { + "id": "m9-original-setting-forgotten", + "file": "src/Ashlar.Abstractions/Security/Egress/EgressRedirectHandler.cs", + "old": "return new RedirectSettings(original.Follow, original.MaxAutomaticRedirections, followCrossHost);", + "new": "var forgotten = ReadAndDisable(primary);\n return new RedirectSettings(forgotten.Follow, forgotten.MaxAutomaticRedirections, followCrossHost);", + "filter": "FullyQualifiedName~EgressRedirect|FullyQualifiedName~EgressHttpNetstandard20TwinTests|FullyQualifiedName~EgressHttpHandlerTwinTests|FullyQualifiedName~EgressFactoryDefaultsTwinTests", + "framework": "net8.0", + "project": "src/Ashlar.Tests.Infrastructure/Ashlar.Tests.Infrastructure.csproj", + "expected_failures": [ + "A_shared_primary_keeps_its_original_setting_on_later_wrappers" + ] + }, + { + "id": "m10-method-rewrite-dropped", + "file": "src/Ashlar.Abstractions/Security/Egress/EgressRedirectHandler.cs", + "old": "return method == HttpMethod.Post;", + "new": "return false;", + "filter": "FullyQualifiedName~EgressRedirect|FullyQualifiedName~EgressHttpNetstandard20TwinTests|FullyQualifiedName~EgressHttpHandlerTwinTests|FullyQualifiedName~EgressFactoryDefaultsTwinTests", + "framework": "net8.0", + "project": "src/Ashlar.Tests.Infrastructure/Ashlar.Tests.Infrastructure.csproj", + "expected_failures": [ + "Differential_status_method_and_body_match_SocketsHttpHandler" + ] + }, + { + "id": "m11-walker-stops-at-the-hop", + "file": "src/Ashlar.Abstractions/Security/Egress/EgressRedirectHandler.cs", + "old": " current = hop.Inner;", + "new": " current = hop;", + "filter": "FullyQualifiedName~EgressHttpNetstandard20TwinTests", + "framework": "net8.0", + "project": "src/Ashlar.Tests.Infrastructure/Ashlar.Tests.Infrastructure.csproj", + "expected_failures": [ + "On_the_netstandard20_asset_the_follower_is_found_through_the_hop_and_follows_a_same_origin_redirect" + ] + }, + { + "id": "m12-credentials-ignored", + "file": "src/Ashlar.Abstractions/Security/Egress/EgressRedirectHandler.cs", + "old": "case HttpClientHandler http when http.Credentials is null:", + "new": "case HttpClientHandler http:", + "filter": "FullyQualifiedName~EgressRedirect|FullyQualifiedName~EgressHttpNetstandard20TwinTests|FullyQualifiedName~EgressHttpHandlerTwinTests|FullyQualifiedName~EgressFactoryDefaultsTwinTests", + "framework": "net8.0", + "project": "src/Ashlar.Tests.Infrastructure/Ashlar.Tests.Infrastructure.csproj", + "expected_failures": [ + "A_primary_with_credentials_or_of_an_unknown_type_is_left_as_it_is" + ] + }, + { + "id": "m13-api-sns-follows", + "file": "application/src/Ashlar.API/Program.cs", + "old": ".NeverFollowRedirects();", + "new": ";", + "filter": "FullyQualifiedName~EgressApiHostProdStyleTests", + "framework": "net10.0", + "project": "src/Ashlar.Tests.Infrastructure/Ashlar.Tests.Infrastructure.csproj", + "expected_failures": [ + "TheSnsSigningClient_NeverFollowsARedirect_OverTheFactorysOwnPrimaryHandler" + ] + }, + { + "id": "m14-fleet-sns-follows", + "file": "commercial/src/Ashlar.Commercial.Fleet.Host/Program.cs", + "old": ".NeverFollowRedirects();", + "new": ";", + "filter": "FullyQualifiedName~EgressFleetHostTwinTests", + "framework": "net10.0", + "project": "commercial/tests/Ashlar.Commercial.Tests.Fleet.Host/Ashlar.Commercial.Tests.Fleet.Host.csproj", + "expected_failures": [ + "TheSnsSigningClient_NeverFollowsARedirect_OverTheFactorysOwnPrimaryHandler" + ] + }, + { + "id": "m15-bedrock-follows", + "file": "src/Ashlar.AI.Pipeline/Clients/AwsBedrockChatClientFactory.cs", + "old": "new AmazonBedrockRuntimeConfig { AllowAutoRedirect = false }", + "new": "new AmazonBedrockRuntimeConfig { AllowAutoRedirect = true }", + "filter": "FullyQualifiedName~EgressRedirect|FullyQualifiedName~EgressHttpNetstandard20TwinTests|FullyQualifiedName~EgressHttpHandlerTwinTests|FullyQualifiedName~EgressFactoryDefaultsTwinTests", + "framework": "net8.0", + "project": "src/Ashlar.Tests.Infrastructure/Ashlar.Tests.Infrastructure.csproj", + "expected_failures": [ + "CloudBedrock_TheRuntimeClientsConfigurationNeverFollowsARedirect" + ] + }, + { + "id": "m17-unknown-primary-warning-inverted", + "file": "src/Ashlar.Infrastructure/Egress/EgressRedirectBindingFilter.cs", + "old": "&& (guard as EgressGuard ?? EgressGuard.ProcessDefault).ProfileEnforcesByDefault())", + "new": "&& !(guard as EgressGuard ?? EgressGuard.ProcessDefault).ProfileEnforcesByDefault())", + "filter": "FullyQualifiedName~EgressRedirect|FullyQualifiedName~EgressHttpNetstandard20TwinTests|FullyQualifiedName~EgressHttpHandlerTwinTests|FullyQualifiedName~EgressFactoryDefaultsTwinTests", + "framework": "net8.0", + "project": "src/Ashlar.Tests.Infrastructure/Ashlar.Tests.Infrastructure.csproj", + "expected_failures": [ + "On_AirGapped_an_unknown_factory_primary_is_named_in_a_warning_and_on_Full_it_is_not" + ] + }, + { + "id": "m18-redirect-limit-off-by-one", + "file": "src/Ashlar.Abstractions/Security/Egress/EgressRedirectHandler.cs", + "old": " private async Task FollowAsync(HttpRequestMessage request, CancellationToken cancellationToken)\n {\n EnsureEvaluated(request);\n var response = await base.SendAsync(request, cancellationToken).ConfigureAwait(false);\n var redirects = 0;\n while (TryGetRedirect(request, response, out var location))\n {\n redirects++;\n if (redirects > _settings.MaxAutomaticRedirections)\n break;\n\n if (cancellationToken.IsCancellationRequested)\n {\n response.Dispose();\n cancellationToken.ThrowIfCancellationRequested();\n }\n\n var status = response.StatusCode;\n response.Dispose();\n ApplyRedirect(request, status, location);\n EvaluateHop(request);\n response = await base.SendAsync(request, cancellationToken).ConfigureAwait(false);\n }\n\n return response;\n }\n", + "new": " private async Task FollowAsync(HttpRequestMessage request, CancellationToken cancellationToken)\n {\n EnsureEvaluated(request);\n var response = await base.SendAsync(request, cancellationToken).ConfigureAwait(false);\n var redirects = 0;\n while (TryGetRedirect(request, response, out var location))\n {\n redirects++;\n if (redirects > _settings.MaxAutomaticRedirections + 1)\n break;\n\n if (cancellationToken.IsCancellationRequested)\n {\n response.Dispose();\n cancellationToken.ThrowIfCancellationRequested();\n }\n\n var status = response.StatusCode;\n response.Dispose();\n ApplyRedirect(request, status, location);\n EvaluateHop(request);\n response = await base.SendAsync(request, cancellationToken).ConfigureAwait(false);\n }\n\n return response;\n }\n", + "filter": "FullyQualifiedName~EgressRedirect|FullyQualifiedName~EgressHttpNetstandard20TwinTests|FullyQualifiedName~EgressHttpHandlerTwinTests|FullyQualifiedName~EgressFactoryDefaultsTwinTests", + "framework": "net8.0", + "project": "src/Ashlar.Tests.Infrastructure/Ashlar.Tests.Infrastructure.csproj", + "expected_failures": [ + "EgressRedirectDifferentialHardeningTests.The_follower_does_what_the_runtime_does(name: \"limit-exceeded\")" + ] + }, + { + "id": "m19-no-note-treated-as-decided", + "file": "src/Ashlar.Abstractions/Security/Egress/EgressRedirectHandler.cs", + "old": "request.Options.TryGetValue(Key, out var stamped) && stamped.Equals(Authority.From(request.RequestUri));", + "new": "!request.Options.TryGetValue(Key, out var stamped) || stamped.Equals(Authority.From(request.RequestUri));", + "filter": "FullyQualifiedName~EgressRedirect|FullyQualifiedName~EgressHttpNetstandard20TwinTests|FullyQualifiedName~EgressHttpHandlerTwinTests|FullyQualifiedName~EgressFactoryDefaultsTwinTests", + "framework": "net8.0", + "project": "src/Ashlar.Tests.Infrastructure/Ashlar.Tests.Infrastructure.csproj", + "expected_failures": [ + "A_handler_that_sends_a_fresh_request_is_still_decided_at_the_cost_of_a_duplicate" + ] + }, + { + "id": "m20-hop-decided-by-process-default", + "file": "src/Ashlar.Abstractions/Security/Egress/EgressRedirectHandler.cs", + "old": "_ = (_guard ?? EgressGuard.ProcessDefault).Evaluate(egress);", + "new": "_ = EgressGuard.ProcessDefault.Evaluate(egress);", + "filter": "FullyQualifiedName~EgressRedirect|FullyQualifiedName~EgressHttpNetstandard20TwinTests|FullyQualifiedName~EgressHttpHandlerTwinTests|FullyQualifiedName~EgressFactoryDefaultsTwinTests", + "framework": "net8.0", + "project": "src/Ashlar.Tests.Infrastructure/Ashlar.Tests.Infrastructure.csproj", + "expected_failures": [ + "Every_hop_is_decided_by_the_guard_the_guard_handler_uses_not_the_process_default" + ] + }, + { + "id": "m21-started-primary-as-if-flipped", + "file": "src/Ashlar.Abstractions/Security/Egress/EgressRedirectHandler.cs", + "old": " {\n allow = false;\n }\n\n return new RedirectSettings", + "new": " {\n allow = true;\n }\n\n return new RedirectSettings", + "filter": "FullyQualifiedName~EgressRedirect|FullyQualifiedName~EgressHttpNetstandard20TwinTests|FullyQualifiedName~EgressHttpHandlerTwinTests|FullyQualifiedName~EgressFactoryDefaultsTwinTests", + "framework": "net8.0", + "project": "src/Ashlar.Tests.Infrastructure/Ashlar.Tests.Infrastructure.csproj", + "expected_failures": [ + "A_primary_that_has_already_sent_keeps_following_on_its_own_and_the_final_authority_is_decided_after_the_send" + ] + }, + { + "id": "m22-known-primary-by-exact-type", + "file": "src/Ashlar.Abstractions/Security/Egress/EgressRedirectHandler.cs", + "old": "case HttpClientHandler http when http.Credentials is null:", + "new": "case HttpClientHandler http when http.GetType() == typeof(HttpClientHandler) && http.Credentials is null:", + "filter": "FullyQualifiedName~EgressRedirect|FullyQualifiedName~EgressHttpNetstandard20TwinTests|FullyQualifiedName~EgressHttpHandlerTwinTests|FullyQualifiedName~EgressFactoryDefaultsTwinTests", + "framework": "net8.0", + "project": "src/Ashlar.Tests.Infrastructure/Ashlar.Tests.Infrastructure.csproj", + "expected_failures": [ + "Factory_cross_host_redirect_is_followed_and_re_evaluated" + ] + }, + { + "id": "m23-composite-primary-skipped", + "file": "src/Ashlar.Infrastructure/Egress/EgressRedirectBindingFilter.cs", + "old": "builder.PrimaryHandler = EgressRedirects.InsertAbovePrimary(primary, EgressFamilies.HttpFactory, site, guard, followCrossHost: true);", + "new": "if (primary is HttpClientHandler or SocketsHttpHandler)\n builder.PrimaryHandler = EgressRedirects.InsertAbovePrimary(primary, EgressFamilies.HttpFactory, site, guard, followCrossHost: true);", + "filter": "FullyQualifiedName~EgressRedirect|FullyQualifiedName~EgressHttpNetstandard20TwinTests|FullyQualifiedName~EgressHttpHandlerTwinTests|FullyQualifiedName~EgressFactoryDefaultsTwinTests", + "framework": "net8.0", + "project": "src/Ashlar.Tests.Infrastructure/Ashlar.Tests.Infrastructure.csproj", + "expected_failures": [ + "A_composite_factory_primary_is_followed_at_its_tail_and_its_own_rewrite_is_decided" + ] + }, + { + "id": "m24-cancellation-between-hops-ignored", + "file": "src/Ashlar.Abstractions/Security/Egress/EgressRedirectHandler.cs", + "old": " private async Task FollowAsync(HttpRequestMessage request, CancellationToken cancellationToken)\n {\n EnsureEvaluated(request);\n var response = await base.SendAsync(request, cancellationToken).ConfigureAwait(false);\n var redirects = 0;\n while (TryGetRedirect(request, response, out var location))\n {\n redirects++;\n if (redirects > _settings.MaxAutomaticRedirections)\n break;\n\n if (cancellationToken.IsCancellationRequested)\n {\n response.Dispose();\n cancellationToken.ThrowIfCancellationRequested();\n }\n\n var status = response.StatusCode;\n response.Dispose();\n ApplyRedirect(request, status, location);\n EvaluateHop(request);\n response = await base.SendAsync(request, cancellationToken).ConfigureAwait(false);\n }\n\n return response;\n }\n", + "new": " private async Task FollowAsync(HttpRequestMessage request, CancellationToken cancellationToken)\n {\n EnsureEvaluated(request);\n var response = await base.SendAsync(request, cancellationToken).ConfigureAwait(false);\n var redirects = 0;\n while (TryGetRedirect(request, response, out var location))\n {\n redirects++;\n if (redirects > _settings.MaxAutomaticRedirections)\n break;\n\n\n var status = response.StatusCode;\n response.Dispose();\n ApplyRedirect(request, status, location);\n EvaluateHop(request);\n response = await base.SendAsync(request, cancellationToken).ConfigureAwait(false);\n }\n\n return response;\n }\n", + "filter": "FullyQualifiedName~EgressRedirect|FullyQualifiedName~EgressHttpNetstandard20TwinTests|FullyQualifiedName~EgressHttpHandlerTwinTests|FullyQualifiedName~EgressFactoryDefaultsTwinTests", + "framework": "net8.0", + "project": "src/Ashlar.Tests.Infrastructure/Ashlar.Tests.Infrastructure.csproj", + "expected_failures": [ + "A_token_cancelled_after_the_first_hop_stops_the_follower_before_the_next_hop_is_sent_or_decided" + ] + }, + { + "id": "m25-intermediate-response-not-disposed", + "file": "src/Ashlar.Abstractions/Security/Egress/EgressRedirectHandler.cs", + "old": " private async Task FollowAsync(HttpRequestMessage request, CancellationToken cancellationToken)\n {\n EnsureEvaluated(request);\n var response = await base.SendAsync(request, cancellationToken).ConfigureAwait(false);\n var redirects = 0;\n while (TryGetRedirect(request, response, out var location))\n {\n redirects++;\n if (redirects > _settings.MaxAutomaticRedirections)\n break;\n\n if (cancellationToken.IsCancellationRequested)\n {\n response.Dispose();\n cancellationToken.ThrowIfCancellationRequested();\n }\n\n var status = response.StatusCode;\n response.Dispose();\n ApplyRedirect(request, status, location);\n EvaluateHop(request);\n response = await base.SendAsync(request, cancellationToken).ConfigureAwait(false);\n }\n\n return response;\n }\n", + "new": " private async Task FollowAsync(HttpRequestMessage request, CancellationToken cancellationToken)\n {\n EnsureEvaluated(request);\n var response = await base.SendAsync(request, cancellationToken).ConfigureAwait(false);\n var redirects = 0;\n while (TryGetRedirect(request, response, out var location))\n {\n redirects++;\n if (redirects > _settings.MaxAutomaticRedirections)\n break;\n\n if (cancellationToken.IsCancellationRequested)\n {\n response.Dispose();\n cancellationToken.ThrowIfCancellationRequested();\n }\n\n var status = response.StatusCode;\n ApplyRedirect(request, status, location);\n EvaluateHop(request);\n response = await base.SendAsync(request, cancellationToken).ConfigureAwait(false);\n }\n\n return response;\n }\n", + "filter": "FullyQualifiedName~EgressRedirect|FullyQualifiedName~EgressHttpNetstandard20TwinTests|FullyQualifiedName~EgressHttpHandlerTwinTests|FullyQualifiedName~EgressFactoryDefaultsTwinTests", + "framework": "net8.0", + "project": "src/Ashlar.Tests.Infrastructure/Ashlar.Tests.Infrastructure.csproj", + "expected_failures": [ + "An_intermediate_redirect_response_is_disposed_when_the_hop_is_followed" + ] + }, + { + "id": "m26-follower-follows-into-the-host-boundary", + "file": "src/Ashlar.Abstractions/Security/Egress/EgressRedirectHandler.cs", + "old": "if (!InsideHostBoundary(requestUri) && InsideHostBoundary(header))", + "new": "if (!InsideHostBoundary(requestUri) && InsideHostBoundary(header) && _settings.MaxAutomaticRedirections < 0)", + "filter": "FullyQualifiedName~EgressRedirect|FullyQualifiedName~EgressHttpNetstandard20TwinTests|FullyQualifiedName~EgressHttpHandlerTwinTests|FullyQualifiedName~EgressFactoryDefaultsTwinTests", + "framework": "net8.0", + "project": "src/Ashlar.Tests.Infrastructure/Ashlar.Tests.Infrastructure.csproj", + "expected_failures": [ + "A_redirect_from_outside_the_host_boundary_into_it_is_returned_unfollowed_with_the_hop_recorded" + ] + }, + { + "id": "m27-unknown-warning-not-deduplicated", + "file": "src/Ashlar.Infrastructure/Egress/EgressRedirectBindingFilter.cs", + "old": "if (NamedUnknownPrimaries.TryAdd(builder.Name + \"\\u0000\" + type, 0))", + "new": "if (NamedUnknownPrimaries.TryAdd(builder.Name + \"\\u0000\" + type, 0) || type.Length > 0)", + "filter": "FullyQualifiedName~EgressRedirect|FullyQualifiedName~EgressHttpNetstandard20TwinTests|FullyQualifiedName~EgressHttpHandlerTwinTests|FullyQualifiedName~EgressFactoryDefaultsTwinTests", + "framework": "net8.0", + "project": "src/Ashlar.Tests.Infrastructure/Ashlar.Tests.Infrastructure.csproj", + "expected_failures": [ + "An_unknown_primary_type_is_warned_once_per_client_across_factory_rebuilds" + ] + }, + { + "id": "m28-nested-P2-flag-ignored", + "file": "src/Ashlar.Abstractions/Security/Egress/EgressRedirectHandler.cs", + "old": "(!_settings.FollowCrossHost || EgressEvaluatedAuthority.RequiresSameOrigin(request))", + "new": "(!_settings.FollowCrossHost)", + "filter": "FullyQualifiedName~EgressRedirect|FullyQualifiedName~EgressHttpNetstandard20TwinTests|FullyQualifiedName~EgressHttpHandlerTwinTests|FullyQualifiedName~EgressFactoryDefaultsTwinTests", + "framework": "net8.0", + "project": "src/Ashlar.Tests.Infrastructure/Ashlar.Tests.Infrastructure.csproj", + "expected_failures": [ + "P2_around_a_factory_pipeline_restricts_only_that_request_without_changing_shared_P1" + ] + }, + { + "id": "m29-sockets-credentials-ignored", + "file": "src/Ashlar.Abstractions/Security/Egress/EgressRedirectHandler.cs", + "old": "case SocketsHttpHandler sockets when sockets.Credentials is null:", + "new": "case SocketsHttpHandler sockets:", + "filter": "FullyQualifiedName~EgressRedirect|FullyQualifiedName~EgressHttpNetstandard20TwinTests|FullyQualifiedName~EgressHttpHandlerTwinTests|FullyQualifiedName~EgressFactoryDefaultsTwinTests", + "framework": "net8.0", + "project": "src/Ashlar.Tests.Infrastructure/Ashlar.Tests.Infrastructure.csproj", + "expected_failures": [ + "Credentialed_primaries_keep_the_runtime_redirect_policy" + ] + }, + { + "id": "m30-primary-auto-redirect-not-disabled", + "file": "src/Ashlar.Abstractions/Security/Egress/EgressRedirectHandler.cs", + "old": "http.AllowAutoRedirect = false;", + "new": "http.AllowAutoRedirect = true;", + "filter": "FullyQualifiedName~EgressRedirect|FullyQualifiedName~EgressHttpNetstandard20TwinTests|FullyQualifiedName~EgressHttpHandlerTwinTests|FullyQualifiedName~EgressFactoryDefaultsTwinTests", + "framework": "net8.0", + "project": "src/Ashlar.Tests.Infrastructure/Ashlar.Tests.Infrastructure.csproj", + "expected_failures": [ + "The_CreateClient_primary_AllowAutoRedirect_reads_false" + ] + }, + { + "id": "m31-filter-before-factory-logging", + "file": "src/Ashlar.Infrastructure/Egress/EgressServiceCollectionExtensions.cs", + "old": " services.ConfigureHttpClientDefaults(defaults => defaults.Services.ConfigureAll(\n options => options.HttpMessageHandlerBuilderActions.Add(handlers => handlers.AdditionalHandlers.Insert(\n 0,\n EgressHttp.CreateDelegatingHandler(\n EgressFamilies.HttpFactory,\n FactorySitePrefix + handlers.Name,\n ResolveGuardAndActivateLogging(handlers))))));\n\n // AddHttpClient's logging filter was registered by ConfigureHttpClientDefaults. Filters run in registration\n // order around next: our post-next step runs before logging adds its outer scope handler. A normal client\n // therefore needs no relocation and emits no false removed/moved-handler warning.\n services.TryAddEnumerable(ServiceDescriptor.Singleton());\n", + "new": " services.TryAddEnumerable(ServiceDescriptor.Singleton());\n\n services.ConfigureHttpClientDefaults(defaults => defaults.Services.ConfigureAll(\n options => options.HttpMessageHandlerBuilderActions.Add(handlers => handlers.AdditionalHandlers.Insert(\n 0,\n EgressHttp.CreateDelegatingHandler(\n EgressFamilies.HttpFactory,\n FactorySitePrefix + handlers.Name,\n ResolveGuardAndActivateLogging(handlers))))));\n\n // AddHttpClient's logging filter was registered by ConfigureHttpClientDefaults. Filters run in registration\n // order around next: our post-next step runs before logging adds its outer scope handler. A normal client\n // therefore needs no relocation and emits no false removed/moved-handler warning.\n\n", + "filter": "FullyQualifiedName~EgressRedirect|FullyQualifiedName~EgressHttpNetstandard20TwinTests|FullyQualifiedName~EgressHttpHandlerTwinTests|FullyQualifiedName~EgressFactoryDefaultsTwinTests", + "framework": "net8.0", + "project": "src/Ashlar.Tests.Infrastructure/Ashlar.Tests.Infrastructure.csproj", + "expected_failures": [ + "Factory_filter_order_warns_only_when_client_configuration_removed_the_guard" + ] + }, + { + "id": "m32-per-hop-decision-dropped", + "file": "src/Ashlar.Abstractions/Security/Egress/EgressRedirectHandler.cs", + "old": " private void EvaluateHop(HttpRequestMessage request)\n {", + "new": " private void EvaluateHop(HttpRequestMessage request)\n {\n if (EgressEvaluatedAuthority.Matches(request)) return;", + "filter": "FullyQualifiedName~EgressRedirect|FullyQualifiedName~EgressHttpNetstandard20TwinTests|FullyQualifiedName~EgressHttpHandlerTwinTests|FullyQualifiedName~EgressFactoryDefaultsTwinTests", + "framework": "net8.0", + "project": "src/Ashlar.Tests.Infrastructure/Ashlar.Tests.Infrastructure.csproj", + "expected_failures": [ + "EgressHttp_same_host_redirect_records_a_second_decision" + ] + }, + { + "id": "m33-client-no-follow-marker-dropped", + "file": "src/Ashlar.Infrastructure/Egress/EgressHttpClientBuilderExtensions.cs", + "old": "builder.Services.AddSingleton(new EgressNoFollowClient(builder.Name));", + "new": "// mutation: client-specific no-follow marker omitted", + "filter": "FullyQualifiedName~EgressRedirect|FullyQualifiedName~EgressHttpNetstandard20TwinTests|FullyQualifiedName~EgressHttpHandlerTwinTests|FullyQualifiedName~EgressFactoryDefaultsTwinTests", + "framework": "net8.0", + "project": "src/Ashlar.Tests.Infrastructure/Ashlar.Tests.Infrastructure.csproj", + "expected_failures": [ + "Never_follow_wins_after_another_wrapper_remembers_the_shared_primary" + ] + }, + { + "id": "m34-started-sockets-redundant-setter", + "file": "src/Ashlar.Infrastructure/Egress/EgressHttpClientBuilderExtensions.cs", + "old": "primary is SocketsHttpHandler sockets && sockets.AllowAutoRedirect", + "new": "primary is SocketsHttpHandler sockets", + "filter": "FullyQualifiedName~EgressRedirect|FullyQualifiedName~EgressHttpNetstandard20TwinTests|FullyQualifiedName~EgressHttpHandlerTwinTests|FullyQualifiedName~EgressFactoryDefaultsTwinTests", + "framework": "net8.0", + "project": "src/Ashlar.Tests.Infrastructure/Ashlar.Tests.Infrastructure.csproj", + "expected_failures": [ + "A_no_follow_client_can_share_a_primary_that_Ashlar_already_disabled_and_started" + ] + } + ] +} diff --git a/src/Ashlar.AI.Pipeline/Clients/AwsBedrockChatClientFactory.cs b/src/Ashlar.AI.Pipeline/Clients/AwsBedrockChatClientFactory.cs index 13fb40143..6f118223d 100644 --- a/src/Ashlar.AI.Pipeline/Clients/AwsBedrockChatClientFactory.cs +++ b/src/Ashlar.AI.Pipeline/Clients/AwsBedrockChatClientFactory.cs @@ -35,15 +35,20 @@ public IChatClient Create(string modelId) ArgumentException.ThrowIfNullOrWhiteSpace(modelId); // Construct privately — never AddSingleton. - // AllowAutoRedirect is ClientConfig's; the SDK client follows unless it is off. No Ashlar follower runs inside the SDK. - IAmazonBedrockRuntime runtime = string.IsNullOrWhiteSpace(_options.Region) - ? new AmazonBedrockRuntimeClient(new AmazonBedrockRuntimeConfig { AllowAutoRedirect = false }) - : new AmazonBedrockRuntimeClient(new AmazonBedrockRuntimeConfig - { - RegionEndpoint = RegionEndpoint.GetBySystemName(_options.Region.Trim()), - AllowAutoRedirect = false, - }); - + IAmazonBedrockRuntime runtime = new AmazonBedrockRuntimeClient(RuntimeConfig(_options.Region)); return runtime.AsIChatClient(modelId); } + + /// + /// The runtime client's configuration: the configured region when there is one (otherwise the SDK's own default + /// chain), and never following a redirect (SPEC-007 PR 4.3). The SDK owns its HTTP, below every handler Ashlar can + /// place, so a redirect it followed would reach a host no decision names. + /// + internal static AmazonBedrockRuntimeConfig RuntimeConfig(string? region) + { + var config = new AmazonBedrockRuntimeConfig { AllowAutoRedirect = false }; + if (!string.IsNullOrWhiteSpace(region)) + config.RegionEndpoint = RegionEndpoint.GetBySystemName(region.Trim()); + return config; + } } diff --git a/src/Ashlar.Abstractions/Security/Egress/EgressDestinations.cs b/src/Ashlar.Abstractions/Security/Egress/EgressDestinations.cs index 31180f041..86e995d06 100644 --- a/src/Ashlar.Abstractions/Security/Egress/EgressDestinations.cs +++ b/src/Ashlar.Abstractions/Security/Egress/EgressDestinations.cs @@ -192,10 +192,40 @@ private static string DescribeUri(Uri uri, out bool insideHost) if (!uri.IsAbsoluteUri) throw new ArgumentException("A relative URI names no destination host.", nameof(uri)); - insideHost = !IsFileScheme(uri.Scheme) && (IsHostScheme(uri.Scheme) || IsLoopbackHost(uri.Host)); + insideHost = IsInsideHost(uri); return Bound(uri.GetComponents(UriComponents.SchemeAndServer, UriFormat.UriEscaped)); } + /// + /// when the absolute names the host boundary as a decision records + /// it: a unix or npipe scheme, or a loopback host (); never a + /// file URI, and never a relative one. + /// + internal static bool IsInsideHost(Uri uri) => + uri.IsAbsoluteUri && !IsFileScheme(uri.Scheme) && (IsHostScheme(uri.Scheme) || IsLoopbackHost(uri.Host)); + + /// + /// when is a link-local IP address (169.254.0.0/16, or + /// IPv6 fe80::/10, in brackets or not; an IPv4-mapped IPv6 address is read as its IPv4). A decision records + /// such a host as a network export, not as the host boundary; the redirect follower treats it as inside the + /// boundary for the purpose of never following a hop into it (owner decision 2026-10-06). Nothing is resolved. + /// + internal static bool IsLinkLocalHost(string? host) + { + var bare = Unbracket(host); + if (bare.Length == 0 || !IPAddress.TryParse(bare, out var address)) + return false; + + if (address.IsIPv4MappedToIPv6) + address = address.MapToIPv4(); + + if (address.AddressFamily == System.Net.Sockets.AddressFamily.InterNetworkV6) + return address.IsIPv6LinkLocal; + + var bytes = address.GetAddressBytes(); + return bytes.Length == 4 && bytes[0] == 169 && bytes[1] == 254; + } + private static string DescribeName(string? name, out bool insideHost) { insideHost = false; diff --git a/src/Ashlar.Abstractions/Security/Egress/EgressGuard.cs b/src/Ashlar.Abstractions/Security/Egress/EgressGuard.cs index 2a8368385..40fd90cb0 100644 --- a/src/Ashlar.Abstractions/Security/Egress/EgressGuard.cs +++ b/src/Ashlar.Abstractions/Security/Egress/EgressGuard.cs @@ -172,6 +172,24 @@ public EgressDecision Evaluate(EgressRequest request) return decision; } + /// + /// when the profile this guard decides under, read as reads it, is + /// AirGapped or SecureWorkstation, or cannot be read (fail closed). For warnings only; it decides nothing. + /// + internal bool ProfileEnforcesByDefault() + { + try + { + return DescribeProfile(ReadProfile()).EnforcesByDefault; + } +#pragma warning disable CA1031 // A profile that cannot be read is treated as the strictest, so the warning it gates is given. + catch (Exception) +#pragma warning restore CA1031 + { + return true; + } + } + // A guard built with a profile uses it as given. Otherwise the profile is the effective value the remote-protocol // option validators read: the strictest one AddAshlar noted in this process, else the variable. private string? ReadProfile() => diff --git a/src/Ashlar.Abstractions/Security/Egress/EgressGuardHandler.cs b/src/Ashlar.Abstractions/Security/Egress/EgressGuardHandler.cs index 59aa3b4a7..336881d0d 100644 --- a/src/Ashlar.Abstractions/Security/Egress/EgressGuardHandler.cs +++ b/src/Ashlar.Abstractions/Security/Egress/EgressGuardHandler.cs @@ -60,6 +60,12 @@ internal EgressGuardHandler(HttpMessageHandler innerHandler, string family, stri /// internal Action? UnmediatedRedirectWarning { get; set; } + /// Preserves P2 when this EgressHttp guard surrounds an existing factory P1 follower. + internal bool RestrictRedirectsToSameOrigin { get; set; } + + /// Preserves this factory client's explicit no-follow policy through a shared redirect follower. + internal bool NeverFollowRedirects { get; set; } + /// Counts a guard fault swallowed by this handler or by the redirect handler under it. internal static void RecordGuardFault() => Interlocked.Increment(ref _guardFaults); @@ -95,6 +101,11 @@ private void Report(HttpRequestMessage? request) if (request is null) return; + if (RestrictRedirectsToSameOrigin) + EgressEvaluatedAuthority.RequireSameOrigin(request); + if (NeverFollowRedirects) + EgressEvaluatedAuthority.RequireNoRedirects(request); + try { Evaluate(request.RequestUri); diff --git a/src/Ashlar.Abstractions/Security/Egress/EgressHttp.cs b/src/Ashlar.Abstractions/Security/Egress/EgressHttp.cs index da05e02d3..63b03ea6e 100644 --- a/src/Ashlar.Abstractions/Security/Egress/EgressHttp.cs +++ b/src/Ashlar.Abstractions/Security/Egress/EgressHttp.cs @@ -147,9 +147,9 @@ private static EgressGuardHandler Guarded(HttpMessageHandler inner, string famil #pragma warning disable CA2000 // Ownership passes inward: the guard handler owns the redirect handler (and, on netstandard2.0, the hop), which owns inner. var withRedirect = EgressRedirects.InsertAbovePrimary(inner, family, site, guard, followCrossHost: false); #if NETSTANDARD2_0 - return new EgressGuardHandler(SynchronousSendRefusedOnNetstandard20Asset.Over(withRedirect), family, site, guard); + return new EgressGuardHandler(SynchronousSendRefusedOnNetstandard20Asset.Over(withRedirect), family, site, guard) { RestrictRedirectsToSameOrigin = true }; #else - return new EgressGuardHandler(withRedirect, family, site, guard); + return new EgressGuardHandler(withRedirect, family, site, guard) { RestrictRedirectsToSameOrigin = true }; #endif #pragma warning restore CA2000 } diff --git a/src/Ashlar.Abstractions/Security/Egress/EgressRedirectHandler.cs b/src/Ashlar.Abstractions/Security/Egress/EgressRedirectHandler.cs index 07cf67a2e..97c794973 100644 --- a/src/Ashlar.Abstractions/Security/Egress/EgressRedirectHandler.cs +++ b/src/Ashlar.Abstractions/Security/Egress/EgressRedirectHandler.cs @@ -13,10 +13,13 @@ namespace Ashlar.Abstractions.Security.Egress; /// handler, then the primary. On netstandard2.0 the synchronous-send hop stays between the guard handler and this /// one, and a synchronous Send throws from that hop before this handler runs. /// It follows only when the primary originally had AllowAutoRedirect true, at most that primary's -/// MaxAutomaticRedirections. An client does not follow a redirect whose host differs -/// from the current request (P2). A factory client does, and the next send is evaluated (P1). HTTPS to HTTP, and any -/// scheme other than http or https, is returned to the caller. Report-only: evaluation records a decision and does -/// not throw, and stays false. +/// MaxAutomaticRedirections. An client follows only the same origin (scheme, host +/// and port; P2). A factory client follows across origins (P1). This follower never follows a redirect from outside +/// the host boundary into it (including link-local addresses); that attempted hop is recorded and the 3xx is returned +/// (owner decision 2026-10-06, O2). HTTPS to HTTP, and any scheme other than http or https, is returned to the caller. +/// Evaluation records decisions; until PR 4.7 the route does not act on . +/// Transports whose redirects Ashlar could not disable keep their own behaviour. The guard can report a +/// changed final authority afterwards, but cannot prevent an inward hop already sent by such a transport. /// internal sealed class EgressRedirectHandler : DelegatingHandler { @@ -46,7 +49,7 @@ internal EgressRedirectHandler( /// protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) { - if (!_settings.Follow) + if (!_settings.Follow || EgressEvaluatedAuthority.RequiresNoRedirects(request)) { EnsureEvaluated(request); return base.SendAsync(request, cancellationToken); @@ -59,7 +62,7 @@ protected override Task SendAsync(HttpRequestMessage reques /// protected override HttpResponseMessage Send(HttpRequestMessage request, CancellationToken cancellationToken) { - if (!_settings.Follow) + if (!_settings.Follow || EgressEvaluatedAuthority.RequiresNoRedirects(request)) { EnsureEvaluated(request); return base.Send(request, cancellationToken); @@ -74,12 +77,18 @@ private async Task FollowAsync(HttpRequestMessage request, EnsureEvaluated(request); var response = await base.SendAsync(request, cancellationToken).ConfigureAwait(false); var redirects = 0; - while (TryGetRedirect(request.RequestUri, response, out var location)) + while (TryGetRedirect(request, response, out var location)) { redirects++; if (redirects > _settings.MaxAutomaticRedirections) break; + if (cancellationToken.IsCancellationRequested) + { + response.Dispose(); + cancellationToken.ThrowIfCancellationRequested(); + } + var status = response.StatusCode; response.Dispose(); ApplyRedirect(request, status, location); @@ -96,12 +105,18 @@ private HttpResponseMessage Follow(HttpRequestMessage request, CancellationToken EnsureEvaluated(request); var response = base.Send(request, cancellationToken); var redirects = 0; - while (TryGetRedirect(request.RequestUri, response, out var location)) + while (TryGetRedirect(request, response, out var location)) { redirects++; if (redirects > _settings.MaxAutomaticRedirections) break; + if (cancellationToken.IsCancellationRequested) + { + response.Dispose(); + cancellationToken.ThrowIfCancellationRequested(); + } + var status = response.StatusCode; response.Dispose(); ApplyRedirect(request, status, location); @@ -160,8 +175,9 @@ private void Evaluate(Uri? uri) _ = (_guard ?? EgressGuard.ProcessDefault).Evaluate(egress); } - private bool TryGetRedirect(Uri? requestUri, HttpResponseMessage response, out Uri location) + private bool TryGetRedirect(HttpRequestMessage request, HttpResponseMessage response, out Uri location) { + var requestUri = request.RequestUri; location = null!; if (requestUri is null || !requestUri.IsAbsoluteUri || !IsRedirect(response.StatusCode)) return false; @@ -177,13 +193,21 @@ private bool TryGetRedirect(Uri? requestUri, HttpResponseMessage response, out U if (!string.IsNullOrEmpty(requestFragment) && string.IsNullOrEmpty(header.Fragment)) header = new UriBuilder(header) { Fragment = requestFragment }.Uri; + // O2 precedes the scheme/P2 checks: unix/npipe and an HTTPS-to-loopback HTTP attempt must be recorded too. + if (!InsideHostBoundary(requestUri) && InsideHostBoundary(header)) + { + RecordUnfollowed(header); + return false; + } + if (!IsHttpOrHttps(header)) return false; if (IsHttpsToHttp(requestUri, header)) return false; - if (!_settings.FollowCrossHost && !SameHost(requestUri, header)) + if ((!_settings.FollowCrossHost || EgressEvaluatedAuthority.RequiresSameOrigin(request)) + && !EgressEvaluatedAuthority.SameOrigin(requestUri, header)) return false; location = header; @@ -221,8 +245,24 @@ private static bool IsHttpsToHttp(Uri from, Uri to) => string.Equals(from.Scheme, Uri.UriSchemeHttps, StringComparison.OrdinalIgnoreCase) && string.Equals(to.Scheme, Uri.UriSchemeHttp, StringComparison.OrdinalIgnoreCase); - private static bool SameHost(Uri left, Uri right) => - string.Equals(left.Host, right.Host, StringComparison.OrdinalIgnoreCase); + private static bool InsideHostBoundary(Uri uri) => + EgressDestinations.IsInsideHost(uri) || EgressDestinations.IsLinkLocalHost(uri.Host); + + // An attempted inward hop gets a record without stamping the request: the response still belongs to the + // original authority, and the post-send check must not mistake that original response for another redirect. + private void RecordUnfollowed(Uri destination) + { + try + { + Evaluate(destination); + } +#pragma warning disable CA1031 // The inward redirect remains unfollowed even if a custom reporting guard throws. + catch (Exception) +#pragma warning restore CA1031 + { + EgressGuardHandler.RecordGuardFault(); + } + } private static bool RequestRequiresForceGet(HttpStatusCode status, HttpMethod method) { @@ -261,6 +301,7 @@ internal RedirectSettings(bool follow, int maxAutomaticRedirections, bool follow internal static class EgressRedirects { private static readonly ConditionalWeakTable Memory = new(); + private static readonly object Gate = new(); internal static HttpMessageHandler InsertAbovePrimary( HttpMessageHandler inner, @@ -269,96 +310,128 @@ internal static HttpMessageHandler InsertAbovePrimary( IEgressGuard? guard, bool followCrossHost) { - HttpMessageHandler? parent = null; + DelegatingHandler? parent = null; var current = inner; - while (current is DelegatingHandler delegating && delegating.InnerHandler is not null) + var underHop = false; + for (var depth = 0; depth < 64; depth++) { - parent = current; - current = delegating.InnerHandler; + if (current is EgressRedirectHandler) + return inner; + + if (current is DelegatingHandler delegating && delegating.InnerHandler is { } next) + { + parent = delegating; + current = next; + continue; + } +#if NETSTANDARD2_0 + if (current is SynchronousSendRefusedOnNetstandard20Asset hop) + { + underHop = true; + current = hop.Inner; + continue; + } +#endif + break; } + // The hop owns an immutable invoker. A started parent cannot be spliced either. Keep such a chain intact; + // the decider and the guard's post-send check record it without taking over the primary's redirects. + if (underHop || (parent is not null && !CanReplaceInner(parent))) + return Decider(inner, family, site, guard, followCrossHost); + var settings = RememberAndDisable(current, followCrossHost); #pragma warning disable CA2000 // Ownership passes to the caller or to the parent handler's InnerHandler. var redirect = new EgressRedirectHandler(current, family, site, guard, settings); #pragma warning restore CA2000 - if (parent is not DelegatingHandler parentHandler) + if (parent is null) return redirect; - parentHandler.InnerHandler = redirect; - return inner; + try + { + parent.InnerHandler = redirect; + return inner; + } + catch (InvalidOperationException) + { + // The parent started between the probe and the splice. Do not re-enable a primary that another wrapper + // may already be mediating; fail closed to returning its redirects rather than following outside it. + return Decider(inner, family, site, guard, followCrossHost); + } + } + + private static EgressRedirectHandler Decider(HttpMessageHandler inner, string family, string site, IEgressGuard? guard, bool followCrossHost) => + new EgressRedirectHandler(inner, family, site, guard, new RedirectSettings(false, 50, followCrossHost)); + + private static bool CanReplaceInner(DelegatingHandler parent) + { + try + { + parent.InnerHandler = parent.InnerHandler!; + return true; + } + catch (InvalidOperationException) + { + return false; + } } internal static RedirectSettings RememberAndDisable(HttpMessageHandler primary, bool followCrossHost) { SecurityGuard.ThrowIfNull(primary, nameof(primary)); - if (Memory.TryGetValue(primary, out var existing)) - return existing; + lock (Gate) + { + if (!Memory.TryGetValue(primary, out var original)) + { + original = ReadAndDisable(primary); + Memory.Add(primary, original); + } - var created = ReadAndDisable(primary, followCrossHost); - Memory.Add(primary, created); - return created; + // The primary's original redirect settings are shared; the P1/P2 policy belongs to this wrapper. + return new RedirectSettings(original.Follow, original.MaxAutomaticRedirections, followCrossHost); + } } - private static RedirectSettings ReadAndDisable(HttpMessageHandler primary, bool followCrossHost) + private static RedirectSettings ReadAndDisable(HttpMessageHandler primary) { - bool? allow = null; + var allow = false; var max = 50; try { switch (primary) { - case HttpClientHandler http: + case HttpClientHandler http when http.Credentials is null: allow = http.AllowAutoRedirect; max = http.MaxAutomaticRedirections; + if (allow) + http.AllowAutoRedirect = false; break; #if !NETSTANDARD2_0 - case SocketsHttpHandler sockets: + case SocketsHttpHandler sockets when sockets.Credentials is null: allow = sockets.AllowAutoRedirect; max = sockets.MaxAutomaticRedirections; + if (allow) + sockets.AllowAutoRedirect = false; break; #endif } } -#pragma warning disable CA1031 // A handler that cannot be read fails closed: the follower does not follow. +#pragma warning disable CA1031 // A started, unsupported or unreadable primary keeps its own behaviour; we never follow for it. catch (Exception) #pragma warning restore CA1031 { - allow = null; + allow = false; } - if (allow is not null) - { - try - { - switch (primary) - { - case HttpClientHandler http: - http.AllowAutoRedirect = false; - break; -#if !NETSTANDARD2_0 - case SocketsHttpHandler sockets: - sockets.AllowAutoRedirect = false; - break; -#endif - } - } -#pragma warning disable CA1031 // A started handler's setter throws; the remembered value still decides whether we follow. - catch (Exception) -#pragma warning restore CA1031 - { - } - } - - if (max < 0) - max = 0; - - return new RedirectSettings(allow == true, max, followCrossHost); + return new RedirectSettings(allow, Math.Max(0, max), false); } } /// The authority the guard last evaluated, stored on the request so a later hop can see that it changed. internal static class EgressEvaluatedAuthority { + internal static bool SameOrigin(Uri left, Uri right) => Authority.From(left).Equals(Authority.From(right)); + private readonly struct Authority : IEquatable { private Authority(string? scheme, string? host, int port) @@ -400,6 +473,16 @@ public override int GetHashCode() #if NET5_0_OR_GREATER private static readonly HttpRequestOptionsKey Key = new("Ashlar.Egress.EvaluatedAuthority"); + private static readonly HttpRequestOptionsKey SameOriginKey = new("Ashlar.Egress.RequireSameOrigin"); + private static readonly HttpRequestOptionsKey NoRedirectsKey = new("Ashlar.Egress.RequireNoRedirects"); + + internal static void RequireNoRedirects(HttpRequestMessage request) => request.Options.Set(NoRedirectsKey, true); + + internal static bool RequiresNoRedirects(HttpRequestMessage request) => request is not null && request.Options.TryGetValue(NoRedirectsKey, out var required) && required; + + internal static void RequireSameOrigin(HttpRequestMessage request) => request.Options.Set(SameOriginKey, true); + + internal static bool RequiresSameOrigin(HttpRequestMessage request) => request.Options.TryGetValue(SameOriginKey, out var required) && required; internal static void Stamp(HttpRequestMessage request, Uri? uri) => request.Options.Set(Key, Authority.From(uri)); @@ -410,6 +493,16 @@ internal static bool Matches(HttpRequestMessage request, Uri? uri) => request.Options.TryGetValue(Key, out var stamped) && stamped.Equals(Authority.From(uri)); #else private const string Key = "Ashlar.Egress.EvaluatedAuthority"; + private const string SameOriginKey = "Ashlar.Egress.RequireSameOrigin"; + private const string NoRedirectsKey = "Ashlar.Egress.RequireNoRedirects"; + + internal static void RequireNoRedirects(HttpRequestMessage request) => request.Properties[NoRedirectsKey] = true; + + internal static bool RequiresNoRedirects(HttpRequestMessage request) => request is not null && request.Properties.TryGetValue(NoRedirectsKey, out var value) && value is true; + + internal static void RequireSameOrigin(HttpRequestMessage request) => request.Properties[SameOriginKey] = true; + + internal static bool RequiresSameOrigin(HttpRequestMessage request) => request.Properties.TryGetValue(SameOriginKey, out var value) && value is true; internal static void Stamp(HttpRequestMessage request, Uri? uri) => request.Properties[Key] = Authority.From(uri); diff --git a/src/Ashlar.Infrastructure/Egress/EgressHttpClientBuilderExtensions.cs b/src/Ashlar.Infrastructure/Egress/EgressHttpClientBuilderExtensions.cs new file mode 100644 index 000000000..e98cefd09 --- /dev/null +++ b/src/Ashlar.Infrastructure/Egress/EgressHttpClientBuilderExtensions.cs @@ -0,0 +1,45 @@ +using Microsoft.Extensions.DependencyInjection; + +namespace Ashlar.Infrastructure.Egress; + +/// +/// Per-client redirect settings for clients under the egress guard (SPEC-007 PR 4.3). +/// +public static class EgressHttpClientBuilderExtensions +{ + /// + /// A client over a known factory primary never follows a redirect: a 3xx response is returned to the caller. + /// Unknown primary types keep their own behaviour. For a client whose destination + /// was checked before the send (the SNS signing-certificate fetch checks the certificate URL's host), where any + /// redirect would reach a host that was never checked. + /// + /// The client's builder. + /// . + /// is . + /// + /// It uses the configure-existing ConfigurePrimaryHttpMessageHandler overload, so the primary handler + /// stays the one the factory and the client's other configuration chose; it only turns that handler's + /// AllowAutoRedirect off. The egress redirect filter, which runs after every configuration action, reads that + /// setting and so does not follow either. A named-client marker also makes Ashlar's follower return redirects + /// when another client already mediated the shared primary, or when this client wraps an existing follower. + /// The marker applies only to this client's requests; other clients keep their own redirect policy. A primary + /// that is neither an nor a is left as it is. + /// + public static IHttpClientBuilder NeverFollowRedirects(this IHttpClientBuilder builder) + { + ArgumentNullException.ThrowIfNull(builder); + builder.Services.AddSingleton(new EgressNoFollowClient(builder.Name)); + return builder.ConfigurePrimaryHttpMessageHandler(static (primary, _) => + { + // A shared primary may already have sent after Ashlar disabled it. Even assigning false again would + // throw once it has started; the named-client flag handles its existing follower without a setter. + if (primary is HttpClientHandler handler && handler.AllowAutoRedirect) + handler.AllowAutoRedirect = false; + else if (primary is SocketsHttpHandler sockets && sockets.AllowAutoRedirect) + sockets.AllowAutoRedirect = false; + }); + } +} + +/// Names a factory client whose requests must never be followed by Ashlar's redirect mediator. +internal sealed record EgressNoFollowClient(string Name); diff --git a/src/Ashlar.Infrastructure/Egress/EgressRedirectBindingFilter.cs b/src/Ashlar.Infrastructure/Egress/EgressRedirectBindingFilter.cs index e2d94fe4d..9c1bd2c7d 100644 --- a/src/Ashlar.Infrastructure/Egress/EgressRedirectBindingFilter.cs +++ b/src/Ashlar.Infrastructure/Egress/EgressRedirectBindingFilter.cs @@ -1,3 +1,4 @@ +using System.Collections.Concurrent; using Ashlar.Abstractions.Security.Egress; using Microsoft.Extensions.Http; using Microsoft.Extensions.Logging; @@ -10,15 +11,20 @@ namespace Ashlar.Infrastructure.Egress; /// internal sealed class EgressRedirectBindingFilter : IHttpMessageHandlerBuilderFilter { + private static readonly ConcurrentDictionary NamedUnknownPrimaries = new(StringComparer.Ordinal); private readonly ILogger _logger; + private readonly HashSet _neverFollowNames; /// Logs on the Ashlar.Egress category. /// The logger factory. + /// Named clients with an explicit no-follow redirect policy. /// is . - public EgressRedirectBindingFilter(ILoggerFactory loggerFactory) + public EgressRedirectBindingFilter(ILoggerFactory loggerFactory, IEnumerable neverFollowClients) { ArgumentNullException.ThrowIfNull(loggerFactory); + ArgumentNullException.ThrowIfNull(neverFollowClients); _logger = loggerFactory.CreateLogger("Ashlar.Egress"); + _neverFollowNames = neverFollowClients.Select(client => client.Name).ToHashSet(StringComparer.Ordinal); } /// @@ -37,24 +43,20 @@ private void Bind(HttpMessageHandlerBuilder builder) var primary = builder.PrimaryHandler; var guard = EgressServiceCollectionExtensions.ResolveGuardAndActivateLogging(builder); var site = EgressServiceCollectionExtensions.FactorySitePrefix + builder.Name; - if (primary is HttpClientHandler || primary is SocketsHttpHandler) + var neverFollow = _neverFollowNames.Contains(builder.Name ?? string.Empty); + if (primary is not null) { - var settings = EgressRedirects.RememberAndDisable(primary, followCrossHost: true); -#pragma warning disable CA2000 // The handler builder owns PrimaryHandler and disposes it with the pipeline. - builder.PrimaryHandler = new EgressRedirectHandler( - primary, - EgressFamilies.HttpFactory, - site, - guard, - settings); -#pragma warning restore CA2000 - } - else if (primary is not null) - { - _logger.LogWarning( - "Factory client {Client} is built on primary handler {PrimaryType}; automatic redirects were not disabled.", - builder.Name, - primary.GetType().FullName ?? primary.GetType().ToString()); + builder.PrimaryHandler = EgressRedirects.InsertAbovePrimary(primary, EgressFamilies.HttpFactory, site, guard, followCrossHost: true); + var tail = Tail(builder.PrimaryHandler); + if (tail is not HttpClientHandler and not SocketsHttpHandler + && (guard as EgressGuard ?? EgressGuard.ProcessDefault).ProfileEnforcesByDefault()) + { + var type = tail.GetType().FullName ?? tail.GetType().Name; + if (NamedUnknownPrimaries.TryAdd(builder.Name + "\u0000" + type, 0)) + _logger.LogWarning(new EventId(7304, "EgressUnknownPrimaryHandler"), + "Factory client {Client} is built on primary handler {PrimaryType}; automatic redirects were not disabled.", + builder.Name, type); + } } var handlers = builder.AdditionalHandlers; @@ -70,10 +72,11 @@ private void Bind(HttpMessageHandlerBuilder builder) if (index < 0) { - _logger.LogWarning( + _logger.LogWarning(new EventId(7303, "EgressGuardHandlerRestored"), "Factory client {Client} removed the egress guard handler; it was re-inserted at the front.", builder.Name); var guardHandler = (EgressGuardHandler)EgressHttp.CreateDelegatingHandler(EgressFamilies.HttpFactory, site, guard); + guardHandler.NeverFollowRedirects = neverFollow; handlers.Insert(0, guardHandler); guardHandler.UnmediatedRedirectWarning = Warn; return; @@ -84,13 +87,24 @@ private void Bind(HttpMessageHandlerBuilder builder) var existing = handlers[index]; handlers.RemoveAt(index); handlers.Insert(0, existing); - _logger.LogWarning( + _logger.LogWarning(new EventId(7303, "EgressGuardHandlerRestored"), "Factory client {Client} moved the egress guard handler off the front; it was put back.", builder.Name); } if (handlers[0] is EgressGuardHandler atFront) + { atFront.UnmediatedRedirectWarning = Warn; + atFront.NeverFollowRedirects = neverFollow; + } + } + + private static HttpMessageHandler Tail(HttpMessageHandler handler) + { + var current = handler; + for (var depth = 0; depth < 64 && current is DelegatingHandler { InnerHandler: { } inner }; depth++) + current = inner; + return current; } private void Warn(string message) => _logger.LogWarning("{Message}", message); diff --git a/src/Ashlar.Infrastructure/Egress/EgressServiceCollectionExtensions.cs b/src/Ashlar.Infrastructure/Egress/EgressServiceCollectionExtensions.cs index 8d19b367b..0a41ce934 100644 --- a/src/Ashlar.Infrastructure/Egress/EgressServiceCollectionExtensions.cs +++ b/src/Ashlar.Infrastructure/Egress/EgressServiceCollectionExtensions.cs @@ -20,7 +20,7 @@ namespace Ashlar.Infrastructure.Egress; /// resilience handlers among them), whether in an earlier default or in the client's own registration, runs inside /// the guard, and one send is one decision until a redirect or a rewritten URI is evaluated again. Only /// configuration that itself inserts at the front after this, or a handler-builder filter, can sit outside it; the -/// factory's own logging scope handler does. A filter registered here, before the logging filter, sees the primary +/// factory's own logging scope handler does. A filter registered here, after the logging filter, sees the primary /// after those actions and, when the primary is an HttpClientHandler or SocketsHttpHandler, turns /// AllowAutoRedirect off and puts the redirect handler directly above it. /// Per-client site. A factory client's decision site is factory: plus the client's name; the @@ -65,11 +65,6 @@ public static IServiceCollection AddAshlarEgressGuard(this IServiceCollection se services.TryAddSingleton(); services.TryAddEnumerable(ServiceDescriptor.Singleton()); - // Registered before ConfigureHttpClientDefaults, which is what first calls AddHttpClient and registers the - // logging filter. Filters run last-registered outermost, so this filter's post-next step sees the primary - // after the client's builder actions and before the logging filter inserts its scope handler at index 0. - services.TryAddEnumerable(ServiceDescriptor.Singleton()); - services.ConfigureHttpClientDefaults(defaults => defaults.Services.ConfigureAll( options => options.HttpMessageHandlerBuilderActions.Add(handlers => handlers.AdditionalHandlers.Insert( 0, @@ -78,6 +73,11 @@ public static IServiceCollection AddAshlarEgressGuard(this IServiceCollection se FactorySitePrefix + handlers.Name, ResolveGuardAndActivateLogging(handlers)))))); + // AddHttpClient's logging filter was registered by ConfigureHttpClientDefaults. Filters run in registration + // order around next: our post-next step runs before logging adds its outer scope handler. A normal client + // therefore needs no relocation and emits no false removed/moved-handler warning. + services.TryAddEnumerable(ServiceDescriptor.Singleton()); + return services; } diff --git a/src/Ashlar.Tests.Infrastructure/Tests/Certification/EgressHttpNetstandard20TwinTests.cs b/src/Ashlar.Tests.Infrastructure/Tests/Certification/EgressHttpNetstandard20TwinTests.cs index 95ec179bf..f22da1ae9 100644 --- a/src/Ashlar.Tests.Infrastructure/Tests/Certification/EgressHttpNetstandard20TwinTests.cs +++ b/src/Ashlar.Tests.Infrastructure/Tests/Certification/EgressHttpNetstandard20TwinTests.cs @@ -46,6 +46,7 @@ public sealed class EgressHttpNetstandard20TwinTests private const string Netstandard20Framework = ".NETStandard,Version=v2.0"; private const string GuardHandlerTypeName = "EgressGuardHandler"; private const string HopTypeName = "SynchronousSendRefusedOnNetstandard20Asset"; + private const string FollowerTypeName = "EgressRedirectHandler"; private const string EventSourceName = "Ashlar-Egress"; // --------------------------------------------------------------------------------------------------------- @@ -134,17 +135,34 @@ public void Every_client_and_Wrap_handler_puts_the_hop_between_the_guard_and_the var observed = Netstandard20.Run>(nameof(Netstandard20Driver.BuildEveryShape), NewSite()); // The chain is walked through DelegatingHandler.InnerHandler and, at the hop, which is not a DelegatingHandler, - // through its internal Inner: the step a handler-chain walker (SPEC-007 PR 4.3's redirect flip) takes there. - var overHttpClientHandler = GuardHandlerTypeName + " > " + HopTypeName + " > EgressRedirectHandler > " + nameof(HttpClientHandler); - var overStub = GuardHandlerTypeName + " > " + HopTypeName + " > EgressRedirectHandler > StubHandler"; + // through its internal Inner: the step the redirect follower's walker (SPEC-007 PR 4.3) takes there. + // SPEC-007 PR 4.3 puts the redirect follower directly above the primary handler, under the hop. + var overHttpClientHandler = GuardHandlerTypeName + " > " + HopTypeName + " > " + FollowerTypeName + " > " + nameof(HttpClientHandler); + var overStub = GuardHandlerTypeName + " > " + HopTypeName + " > " + FollowerTypeName + " > StubHandler"; observed["shape.client-process-default"].Should().Be(overHttpClientHandler, "CreateClient(family, site) is the guard over the hop over an HttpClientHandler"); observed["shape.client-with-guard"].Should().Be(overHttpClientHandler); observed["shape.client-over-inner"].Should().Be(overStub); observed["shape.wrap"].Should().Be(overStub); - observed["inner.client-over-inner"].Should().Be(true, "the chain ends at the caller's own handler; the redirect handler sits above it"); + observed["inner.client-over-inner"].Should().Be(true, "the chain ends at the caller's own handler instance"); observed["inner.wrap"].Should().Be(true); } + [Fact] + public async Task On_the_netstandard20_asset_the_follower_is_found_through_the_hop_and_follows_a_same_origin_redirect() + { + var observed = await Netstandard20.RunAsync(nameof(Netstandard20Driver.FollowThroughTheHopAsync), NewSite()); + + // SPEC-007 PR 4.3: a handler Wrap built, wrapped again, already has a follower under its hop. The walker that + // places the follower steps through the hop's Inner, finds it, and adds no second one. + observed["nested.followers"].Should().Be(1, "the walker steps through the hop (observed: {0})", observed["nested.shape"]); + observed["follow.status"].Should().Be(200, "the follower under the hop follows a same-origin redirect"); + ((string[])observed["follow.paths"]!).Should().Equal("/a", "/a2"); + ((string[])observed["follow.records"]!).Should().Equal( + ["fault=- reason=SystemHighData allowed=False destination=https://async.example mode=report profile=full family=http", + "fault=- reason=SystemHighData allowed=False destination=https://async.example mode=report profile=full family=http"], + "each followed hop is recorded, including one on the same origin"); + } + [Fact] public void The_client_and_the_Wrap_handler_still_own_the_inner_handler_through_the_hop() { @@ -430,6 +448,30 @@ public static string[] PublishOneDecision(string site) return observed; } + public static async Task> FollowThroughTheHopAsync(string site) + { + var observed = new Dictionary(StringComparer.Ordinal); + var sink = new RecordSink(site); + using var subscription = EgressDecisionLog.Subscribe(sink); + + using (var nested = EgressHttp.Wrap(EgressHttp.Wrap(new StubHandler(), EgressFamilies.Grpc, site, Guard), EgressFamilies.Grpc, site, Guard)) + { + observed["nested.shape"] = Shape(nested); + observed["nested.followers"] = Chain(nested).Count(h => h.GetType().Name == "EgressRedirectHandler"); + } + + var stub = new SameOriginRedirectingHandler(); + using (var client = EgressHttp.CreateClient(stub, EgressFamilies.Http, site, Guard)) + using (var response = await client.GetAsync(new Uri("https://async.example/a"))) + { + observed["follow.status"] = (int)response.StatusCode; + observed["follow.paths"] = stub.Paths; + } + + observed["follow.records"] = sink.Take(); + return observed; + } + public static IReadOnlyDictionary DisposeEveryOwner(string site) { var clientInner = new StubHandler(); @@ -519,12 +561,12 @@ private static async Task SendAsyncOnce( /// /// The handlers from inward: through , and /// through the hop's internal Inner, which a walker of the chain needs because the hop is not a - /// . At most eight, so a chain that loops still ends. + /// . At most ten, so a chain that loops still ends. /// private static List Chain(HttpMessageHandler outer) { var chain = new List(); - for (HttpMessageHandler? handler = outer; handler is not null && chain.Count < 8; handler = Next(handler)) + for (HttpMessageHandler? handler = outer; handler is not null && chain.Count < 10; handler = Next(handler)) chain.Add(handler); return chain; } @@ -572,6 +614,27 @@ public string[] Take() } } + /// + /// A primary of a type the follower knows (it derives from ): /a answers 302 to + /// /a2 on the same origin, anything else 200. It never sends anything and never follows on its own. + /// + private sealed class SameOriginRedirectingHandler : HttpClientHandler + { + private readonly ConcurrentQueue _paths = new(); + + public string[] Paths => _paths.ToArray(); + + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + _paths.Enqueue(request.RequestUri!.AbsolutePath); + var response = request.RequestUri.AbsolutePath == "/a" + ? new HttpResponseMessage(HttpStatusCode.Found) { Headers = { Location = new Uri("/a2", UriKind.Relative) } } + : new HttpResponseMessage(HttpStatusCode.OK); + response.RequestMessage = request; + return Task.FromResult(response); + } + } + /// Answers every send, synchronous or not, with a fresh response and remembers what it was handed. private sealed class StubHandler : HttpMessageHandler { diff --git a/src/Ashlar.Tests.Infrastructure/Tests/Certification/EgressRedirectDifferentialHardeningTests.cs b/src/Ashlar.Tests.Infrastructure/Tests/Certification/EgressRedirectDifferentialHardeningTests.cs new file mode 100644 index 000000000..8a3a92e9f --- /dev/null +++ b/src/Ashlar.Tests.Infrastructure/Tests/Certification/EgressRedirectDifferentialHardeningTests.cs @@ -0,0 +1,545 @@ +using System.Collections.Concurrent; +using System.Net; +using System.Net.Http.Headers; +using System.Net.Security; +using System.Net.Sockets; +using System.Security.Cryptography; +using System.Security.Cryptography.X509Certificates; +using Ashlar.Abstractions.Security; +using Ashlar.Abstractions.Security.Egress; +using Ashlar.Infrastructure.Egress; +using FluentAssertions; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Hosting.Server; +using Microsoft.AspNetCore.Hosting.Server.Features; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Xunit; + +namespace Ashlar.Tests.Infrastructure.Tests.Certification; + +/// +/// SPEC-007 PR 4.3, the differential twin of Ashlar's redirect follower: the same requests, over a real +/// to a loopback Kestrel, once with the runtime following redirects itself and once +/// through a factory client under the egress guard, where the egress redirect follower does (PR 4 design §2.6 gap 2, +/// R-b "runtime parity"; Appendix B: "the runtime's redirect rules, the differential twin settles them"). +/// +/// +/// What is compared. The final status, the final request URI (fragment included) and method, and every +/// request the server saw, in order: scheme, host, method, path and query, whether it carried Authorization, +/// and the body length. Cases: 300, 301, 302, 303, 307 and 308, with GET and POST, and 304 and 305 (not +/// followed); a relative, a scheme-relative, a dot-segment, a missing and an invalid Location; another host; +/// https to http (not followed) and http to https; the redirect limit reached and +/// exceeded; a fragment. +/// Also pinned, against the real handlers. A factory client over the factory's own primary handler +/// decides each new loopback authority before the server sees the hop; under an enforcing guard the hop to a remote +/// host is decided, refused, before the server sees it (report-only until SPEC-007 PR 4.7, so it is still sent); a +/// client with NeverFollowRedirects returns the 3xx; a redirect to a +/// scheme other than http or https is returned, not followed; a primary that has already sent (its +/// AllowAutoRedirect setter throws) keeps following on its own, and the final authority is decided after the +/// send; a hop from a remote host into loopback Kestrel is returned, recorded, and never reaches the server (owner +/// decision 2026-10-06). +/// Hermetic. Every connection goes to the loopback listener: the handlers' ConnectCallback connects +/// any host name to 127.0.0.1, so no name is resolved and nothing leaves the machine. The HTTPS listener uses a +/// self-signed certificate made here, which the client accepts. Each case has its own token in every path and host, +/// so the server's log is filtered per case; decisions are filtered by the token too. +/// +[Trait("Category", "Certification")] +public sealed class EgressRedirectDifferentialHardeningTests : IClassFixture +{ + private const string Payload = "payload"; + + private static long _clock; + + private readonly LoopbackRedirectServer _server; + + public EgressRedirectDifferentialHardeningTests(LoopbackRedirectServer server) => _server = server; + + public static TheoryData Cases => new() + { + "300-POST", "301-GET", "301-POST", "302-GET", "302-POST", "303-GET", "303-POST", "303-PUT", "307-POST", "308-POST", + "304-GET", "305-GET", + "relative-location", "scheme-relative-location", "dot-segment-location", "missing-location", "invalid-location", + "cross-host", "https-to-http", "http-to-https", + "limit-reached", "limit-exceeded", "fragment", + }; + + [Theory] + [MemberData(nameof(Cases))] + public async Task The_follower_does_what_the_runtime_does(string name) + { + var runtime = await RunAsync(name, factory: false); + var ours = await RunAsync(name, factory: true); + + ours.Should().BeEquivalentTo(runtime, options => options.WithStrictOrdering(), "case {0}", name); + runtime.Seen.Should().NotBeEmpty(); + } + + [Fact] + public async Task A_factory_client_over_the_factorys_own_primary_decides_each_new_authority_before_the_server_sees_it() + { + var token = NewToken(); + var name = "own-primary-" + token; + var recorder = new Recorder(d => d.Site == "factory:" + name); + using var subscription = EgressDecisionLog.Subscribe(recorder); + + var services = new ServiceCollection(); + services.AddAshlarEgressGuard(); + services.AddHttpClient(name); + using var provider = services.BuildServiceProvider(); + using var client = provider.GetRequiredService().CreateClient(name); + + var target = $"http://localhost:{_server.HttpPort}/{token}/ok"; + using var response = await client.GetAsync(new Uri($"http://127.0.0.1:{_server.HttpPort}/{token}/r/307?to={Uri.EscapeDataString(target)}")); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + var seen = _server.For(token); + seen.Select(s => s.PathAndQuery).Should().HaveCount(2).And.EndWith($"/{token}/ok"); + recorder.Decisions.Select(d => d.Decision.Destination).Should().Equal( + $"http://127.0.0.1:{_server.HttpPort}", $"http://localhost:{_server.HttpPort}"); + recorder.Decisions[1].At.Should().BeLessThan(seen[1].At, "the new authority was decided before the server saw the hop"); + } + + [Fact] + public async Task Under_an_enforcing_guard_the_hop_to_a_remote_host_is_decided_refused_before_the_server_sees_it() + { + var token = NewToken(); + var name = "enforce-" + token; + var recorder = new Recorder(d => d.Site == "factory:" + name); + using var subscription = EgressDecisionLog.Subscribe(recorder); + + var services = new ServiceCollection(); + services.AddSingleton(new EgressGuard("full", "enforce")); + services.AddAshlarEgressGuard(); + services.AddHttpClient(name).ConfigurePrimaryHttpMessageHandler(() => Primary(maxRedirects: 50)); + using var provider = services.BuildServiceProvider(); + using var client = provider.GetRequiredService().CreateClient(name); + + var remote = $"http://remote-{token}.example:{_server.HttpPort}/{token}/b"; + using var response = await client.GetAsync(new Uri($"http://127.0.0.1:{_server.HttpPort}/{token}/r/307?to={Uri.EscapeDataString(remote)}")); + + var decisions = recorder.Decisions; + decisions.Should().HaveCount(2); + decisions[0].Decision.Refused.Should().BeFalse("hop 1 is loopback, inside the host boundary"); + var hop2 = decisions[1].Decision; + hop2.Destination.Should().Be($"http://remote-{token}.example:{_server.HttpPort}"); + hop2.Mode.Should().Be("enforce"); + hop2.Refused.Should().BeTrue(); + hop2.Access.Reason.Should().Be(AccessDenialReason.SystemHighData); + + var seen = _server.For(token); + seen.Should().HaveCount(2, "report-only until SPEC-007 PR 4.7: the refused hop is still sent"); + seen[1].Host.Should().StartWith($"remote-{token}.example"); + decisions[1].At.Should().BeLessThan(seen[1].At, "the refusal is decided before the hop reaches the server, which is where PR 4.7 stops it"); + } + + [Fact] + public async Task A_client_that_never_follows_returns_the_redirect_and_the_server_never_sees_the_target() + { + var token = NewToken(); + var name = "never-" + token; + + var services = new ServiceCollection(); + services.AddAshlarEgressGuard(); + NeverFollowRedirects(services.AddHttpClient(name)); + using var provider = services.BuildServiceProvider(); + using var client = provider.GetRequiredService().CreateClient(name); + + using var response = await client.GetAsync(new Uri($"http://127.0.0.1:{_server.HttpPort}/{token}/r/302?to={Uri.EscapeDataString($"/{token}/b")}")); + + response.StatusCode.Should().Be(HttpStatusCode.Found); + _server.For(token).Select(s => s.PathAndQuery).Should().ContainSingle().Which.Should().StartWith($"/{token}/r/302"); + } + + [Fact] + public async Task A_no_follow_client_can_share_a_primary_that_Ashlar_already_disabled_and_started() + { + var token = NewToken(); + var primary = Primary(maxRedirects: 50); + var services = new ServiceCollection(); + services.AddAshlarEgressGuard(); + services.AddHttpClient("ordinary").ConfigurePrimaryHttpMessageHandler(() => primary); + services.AddHttpClient("never").ConfigurePrimaryHttpMessageHandler(() => primary).NeverFollowRedirects(); + using var provider = services.BuildServiceProvider(); + var factory = provider.GetRequiredService(); + using var ordinary = factory.CreateClient("ordinary"); + using (var warm = await ordinary.GetAsync(new Uri($"http://127.0.0.1:{_server.HttpPort}/{token}/warm"))) + warm.StatusCode.Should().Be(HttpStatusCode.OK); + primary.AllowAutoRedirect.Should().BeFalse("Ashlar disabled the primary before its first real send"); + + using var restricted = factory.CreateClient("never"); + var start = new Uri($"http://127.0.0.1:{_server.HttpPort}/{token}/r/302?to={Uri.EscapeDataString($"/{token}/target")}"); + using var stopped = await restricted.GetAsync(start); + + stopped.StatusCode.Should().Be(HttpStatusCode.Found, "an already-disabled immutable primary needs no second setter call"); + _server.For(token).Should().HaveCount(2, "only the warmup and the original restricted request were sent"); + using var followed = await ordinary.GetAsync(start); + followed.StatusCode.Should().Be(HttpStatusCode.OK, "the ordinary client still uses the original remembered follow policy"); + _server.For(token).Should().HaveCount(4); + } + + [Fact] + public async Task A_redirect_to_a_scheme_other_than_http_or_https_is_returned_not_followed() + { + var token = NewToken(); + var name = "scheme-" + token; + + var services = new ServiceCollection(); + services.AddAshlarEgressGuard(); + services.AddHttpClient(name).ConfigurePrimaryHttpMessageHandler(() => Primary(maxRedirects: 50)); + using var provider = services.BuildServiceProvider(); + using var client = provider.GetRequiredService().CreateClient(name); + + using var response = await client.GetAsync(new Uri($"http://a-{token}.test:{_server.HttpPort}/{token}/r/302?to={Uri.EscapeDataString("ftp://files.example/x")}")); + + response.StatusCode.Should().Be(HttpStatusCode.Found); + _server.For(token).Should().ContainSingle(); + } + + [Fact] + public async Task A_primary_that_has_already_sent_keeps_following_on_its_own_and_the_final_authority_is_decided_after_the_send() + { + // The AllowAutoRedirect setter throws once the handler has sent. Such a primary is left as it is (fail closed + // would be to refuse the client; the design keeps it building), so the runtime follows below the follower, and + // the authority the response's request names is decided after the send. Known limit in docs/EgressInventory.md. + var token = NewToken(); + var site = "twin:redirect:started-" + token; + var recorder = new Recorder(d => d.Site == site); + using var subscription = EgressDecisionLog.Subscribe(recorder); + + var primary = Primary(maxRedirects: 50); + using (var raw = new HttpClient(primary, disposeHandler: false)) + using (var warm = await raw.GetAsync(new Uri($"http://127.0.0.1:{_server.HttpPort}/{token}/warm"))) + { + warm.StatusCode.Should().Be(HttpStatusCode.OK, "the primary has sent, so its AllowAutoRedirect can no longer change"); + } + + using var client = EgressHttp.CreateClient(primary, EgressFamilies.Http, site, new EgressGuard("full")); + var target = $"http://localhost:{_server.HttpPort}/{token}/ok"; + using var response = await client.GetAsync(new Uri($"http://127.0.0.1:{_server.HttpPort}/{token}/r/307?to={Uri.EscapeDataString(target)}")); + + primary.AllowAutoRedirect.Should().BeTrue("the setter throws on a started handler, so the primary is left as it is"); + FollowerFollows(client).Should().BeFalse("the follower never follows for a primary it could not turn off"); + response.StatusCode.Should().Be(HttpStatusCode.OK, "the primary followed on its own"); + var seen = _server.For(token); + seen.Select(s => s.PathAndQuery).Should().HaveCount(3).And.EndWith($"/{token}/ok"); + recorder.Decisions.Select(d => d.Decision.Destination).Should().Equal( + $"http://127.0.0.1:{_server.HttpPort}", $"http://localhost:{_server.HttpPort}"); + recorder.Decisions[1].At.Should().BeGreaterThan(seen[2].At, "the primary's own hop is decided only after the send"); + } + + [Fact] + public async Task A_remote_hop_into_loopback_Kestrel_is_returned_and_the_server_never_sees_it() + { + // Owner decision 2026-10-06 (O2), against the real primary: the first hop is presented as a remote host by a + // stub that answers 307 to loopback Kestrel; everything else goes through the real SocketsHttpHandler. The 3xx + // comes back, the server sees nothing, and both hops are recorded. + var token = NewToken(); + var name = "inward-" + token; + var recorder = new Recorder(d => d.Site == "factory:" + name); + using var subscription = EgressDecisionLog.Subscribe(recorder); + var target = $"http://127.0.0.1:{_server.HttpPort}/{token}/b"; + + var services = new ServiceCollection(); + services.AddAshlarEgressGuard(); + services.AddHttpClient(name).ConfigurePrimaryHttpMessageHandler(() => new RemoteFrontedPrimary(Primary(maxRedirects: 50), $"remote-{token}.example", target)); + using var provider = services.BuildServiceProvider(); + using var client = provider.GetRequiredService().CreateClient(name); + + using var response = await client.PostAsync(new Uri($"http://remote-{token}.example/{token}/a"), new StringContent(Payload)); + + response.StatusCode.Should().Be(HttpStatusCode.TemporaryRedirect); + _server.For(token).Should().BeEmpty("the server inside the boundary never sees the bounced request"); + recorder.Decisions.Select(d => d.Decision.Destination).Should().Equal($"http://remote-{token}.example", $"http://127.0.0.1:{_server.HttpPort}"); + recorder.Decisions[1].Decision.DestinationClass.Should().Be(EgressDestinationClass.Host); + } + + /// + /// A known-type primary that answers one remote host itself (307 to a given Location) and sends everything else + /// through a real handler to the loopback listener. + /// + private sealed class RemoteFrontedPrimary : HttpClientHandler + { + private readonly HttpMessageInvoker _real; + private readonly string _remoteHost; + private readonly string _location; + + public RemoteFrontedPrimary(SocketsHttpHandler real, string remoteHost, string location) + { + _real = new HttpMessageInvoker(real); + _remoteHost = remoteHost; + _location = location; + } + + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + if (string.Equals(request.RequestUri!.Host, _remoteHost, StringComparison.OrdinalIgnoreCase)) + { + var response = new HttpResponseMessage(HttpStatusCode.TemporaryRedirect) { RequestMessage = request }; + response.Headers.Location = new Uri(_location); + return Task.FromResult(response); + } + + return _real.SendAsync(request, cancellationToken); + } + + protected override void Dispose(bool disposing) + { + if (disposing) + _real.Dispose(); + base.Dispose(disposing); + } + } + + /// The Follows of the redirect follower directly under the client's guard handler, by reflection (no InternalsVisibleTo). + private static bool FollowerFollows(HttpMessageInvoker client) + { + var field = typeof(HttpMessageInvoker).GetFields(System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance) + .Single(f => typeof(HttpMessageHandler).IsAssignableFrom(f.FieldType)); + var guardHandler = (DelegatingHandler)field.GetValue(client)!; + var follower = guardHandler.InnerHandler!; + follower.GetType().Name.Should().Be("EgressRedirectHandler"); + var settings = follower.GetType().GetField("_settings", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance)!.GetValue(follower)!; + return (bool)settings.GetType().GetProperty("Follow", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance)!.GetValue(settings)!; + } + + private static void NeverFollowRedirects(IHttpClientBuilder builder) + { + var extension = typeof(EgressServiceCollectionExtensions).Assembly.GetType("Ashlar.Infrastructure.Egress.EgressHttpClientBuilderExtensions"); + extension.Should().NotBeNull("the reusable SNS redirect configuration must exist"); + extension!.GetMethod("NeverFollowRedirects")!.Invoke(null, [builder]); + } + + private static long Tick() => Interlocked.Increment(ref _clock); + + private static string NewToken() => Guid.NewGuid().ToString("N")[..12]; + + /// A real handler whose every connection goes to the loopback listener on the URL's port. + private static SocketsHttpHandler Primary(int maxRedirects) => new() + { + MaxAutomaticRedirections = maxRedirects, + UseCookies = false, + UseProxy = false, + ConnectCallback = async (context, cancellationToken) => + { + var socket = new Socket(SocketType.Stream, ProtocolType.Tcp) { NoDelay = true }; + try + { + await socket.ConnectAsync(new IPEndPoint(IPAddress.Loopback, context.DnsEndPoint.Port), cancellationToken); + return new NetworkStream(socket, ownsSocket: true); + } + catch + { + socket.Dispose(); + throw; + } + }, + SslOptions = new SslClientAuthenticationOptions { RemoteCertificateValidationCallback = static (_, _, _, _) => true }, + }; + + private async Task RunAsync(string name, bool factory) + { + var token = NewToken(); + var (start, method, maxRedirects) = Build(name, token); + + HttpClient client; + ServiceProvider? provider = null; + if (factory) + { + var services = new ServiceCollection(); + services.AddAshlarEgressGuard(); + services.AddHttpClient("diff").ConfigurePrimaryHttpMessageHandler(() => Primary(maxRedirects)); + provider = services.BuildServiceProvider(); + client = provider.GetRequiredService().CreateClient("diff"); + } + else + { + client = new HttpClient(Primary(maxRedirects)); + } + + try + { + using var request = new HttpRequestMessage(method, start); + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", "secret"); + if (method != HttpMethod.Get) + request.Content = new StringContent(Payload); + + using var response = await client.SendAsync(request); + var final = response.RequestMessage!; + return new Outcome( + (int)response.StatusCode, + Normalize(final.RequestUri!.ToString(), token), + final.Method.Method, + _server.For(token).Select(s => s with { At = 0, Host = Normalize(s.Host, token), PathAndQuery = Normalize(s.PathAndQuery, token) }).ToList()); + } + finally + { + client.Dispose(); + if (provider is not null) + await provider.DisposeAsync(); + } + } + + private string Normalize(string text, string token) => + text.Replace(token, "T", StringComparison.Ordinal) + .Replace(":" + _server.HttpsPort, ":HTTPS", StringComparison.Ordinal) + .Replace(":" + _server.HttpPort, ":HTTP", StringComparison.Ordinal) + .Replace("%3A" + _server.HttpsPort, "%3AHTTPS", StringComparison.Ordinal) + .Replace("%3A" + _server.HttpPort, "%3AHTTP", StringComparison.Ordinal); + + private (Uri Start, HttpMethod Method, int MaxRedirects) Build(string name, string token) + { + var http = $"http://a-{token}.test:{_server.HttpPort}"; + var https = $"https://a-{token}.test:{_server.HttpsPort}"; + string To(string location) => "?to=" + Uri.EscapeDataString(location); + + var parts = name.Split('-'); + if (int.TryParse(parts[0], out var status)) + return (new Uri($"{http}/{token}/r/{status}{To($"/{token}/ok")}"), new HttpMethod(parts[1]), 50); + + return name switch + { + "relative-location" => (new Uri($"{http}/{token}/r/302{To("ok?q=1")}"), HttpMethod.Get, 50), + "scheme-relative-location" => (new Uri($"{http}/{token}/r/302{To($"//b-{token}.test:{_server.HttpPort}/{token}/ok")}"), HttpMethod.Get, 50), + "dot-segment-location" => (new Uri($"{http}/{token}/r/302{To($"../{token}/ok")}"), HttpMethod.Get, 50), + "missing-location" => (new Uri($"{http}/{token}/r/302"), HttpMethod.Get, 50), + "invalid-location" => (new Uri($"{http}/{token}/r/302{To("::not a uri::")}"), HttpMethod.Get, 50), + "cross-host" => (new Uri($"{http}/{token}/r/307{To($"http://b-{token}.test:{_server.HttpPort}/{token}/ok")}"), HttpMethod.Post, 50), + "https-to-http" => (new Uri($"{https}/{token}/r/302{To($"{http}/{token}/ok")}"), HttpMethod.Get, 50), + "http-to-https" => (new Uri($"{http}/{token}/r/302{To($"{https}/{token}/ok")}"), HttpMethod.Get, 50), + "limit-reached" => (new Uri($"{http}/{token}/chain/3"), HttpMethod.Get, 3), + "limit-exceeded" => (new Uri($"{http}/{token}/chain/5"), HttpMethod.Get, 3), + "fragment" => (new Uri($"{http}/{token}/r/301{To($"/{token}/ok")}#keep"), HttpMethod.Get, 50), + _ => throw new ArgumentOutOfRangeException(nameof(name), name, "unknown case"), + }; + } + + public sealed record Seen(long At, string Scheme, string Host, string Method, string PathAndQuery, bool HadAuthorization, int BodyLength); + + private sealed record Outcome(int Status, string FinalUri, string FinalMethod, List Seen); + + private sealed record Stamped(long At, EgressDecision Decision); + + private sealed class Recorder(Func keep) : IEgressDecisionSink + { + private readonly ConcurrentQueue _decisions = new(); + + public IReadOnlyList Decisions => _decisions.ToArray(); + + public void Record(EgressDecision decision) + { + if (keep(decision)) + _decisions.Enqueue(new Stamped(Tick(), decision)); + } + } + + /// + /// A loopback Kestrel with an HTTP and an HTTPS listener. /{token}/r/{status}?to={location} answers that + /// status with that Location (none when to is absent); /{token}/chain/{n} redirects 302 to + /// /{token}/chain/{n-1} until 0; anything else answers 200. Every request is logged with the tick it arrived at. + /// + public sealed class LoopbackRedirectServer : IAsyncLifetime + { + private readonly ConcurrentQueue _seen = new(); + private WebApplication? _app; + + public int HttpPort { get; private set; } + + public int HttpsPort { get; private set; } + + public IReadOnlyList For(string token) => + _seen.Where(s => s.PathAndQuery.Contains(token, StringComparison.Ordinal)).OrderBy(s => s.At).ToList(); + + public async Task InitializeAsync() + { + var builder = WebApplication.CreateBuilder(new WebApplicationOptions + { + EnvironmentName = "Production", + ContentRootPath = AppContext.BaseDirectory, + }); + builder.Logging.ClearProviders(); + var certificate = CreateCertificate(); + builder.WebHost.ConfigureKestrel(kestrel => + { + kestrel.Listen(IPAddress.Loopback, 0); + kestrel.Listen(IPAddress.Loopback, 0, listen => listen.UseHttps(certificate)); + }); + + _app = builder.Build(); + _app.Run(HandleAsync); + await _app.StartAsync(); + + foreach (var address in _app.Services.GetRequiredService().Features.Get()!.Addresses) + { + var uri = new Uri(address); + if (uri.Scheme == Uri.UriSchemeHttps) + HttpsPort = uri.Port; + else + HttpPort = uri.Port; + } + + HttpPort.Should().BePositive(); + HttpsPort.Should().BePositive(); + } + + public async Task DisposeAsync() + { + if (_app is not null) + { + await _app.StopAsync(); + await _app.DisposeAsync(); + } + } + + private static X509Certificate2 CreateCertificate() + { + using var rsa = RSA.Create(2048); + var request = new CertificateRequest("CN=localhost", rsa, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1); + request.CertificateExtensions.Add(new X509EnhancedKeyUsageExtension(new OidCollection { new Oid("1.3.6.1.5.5.7.3.1") }, critical: false)); + // Persist through PFX so the private key survives for Schannel/Kestrel on Windows. + using var created = request.CreateSelfSigned(DateTimeOffset.UtcNow.AddDays(-1), DateTimeOffset.UtcNow.AddDays(1)); + var pfx = created.Export(X509ContentType.Pfx); +#if NET9_0_OR_GREATER + return X509CertificateLoader.LoadPkcs12(pfx, password: null, X509KeyStorageFlags.Exportable); +#else + return new X509Certificate2(pfx, string.Empty, X509KeyStorageFlags.Exportable); +#endif + } + + private async Task HandleAsync(HttpContext context) + { + using var reader = new StreamReader(context.Request.Body); + var body = await reader.ReadToEndAsync(); + var path = context.Request.Path.Value ?? string.Empty; + _seen.Enqueue(new Seen( + Tick(), + context.Request.Scheme, + context.Request.Host.Value ?? string.Empty, + context.Request.Method, + path + context.Request.QueryString.Value, + context.Request.Headers.Authorization.Count > 0, + body.Length)); + + var segments = path.Split('/', StringSplitOptions.RemoveEmptyEntries); + if (segments.Length >= 3 && segments[1] == "r" && int.TryParse(segments[2], out var status)) + { + context.Response.StatusCode = status; + if (context.Request.Query.TryGetValue("to", out var to)) + context.Response.Headers.Location = to.ToString(); + return; + } + + if (segments.Length >= 3 && segments[1] == "chain" && int.TryParse(segments[2], out var left) && left > 0) + { + context.Response.StatusCode = StatusCodes.Status302Found; + context.Response.Headers.Location = $"/{segments[0]}/chain/{left - 1}"; + return; + } + + context.Response.StatusCode = StatusCodes.Status200OK; + await context.Response.WriteAsync("ok"); + } + } +} diff --git a/src/Ashlar.Tests.Infrastructure/Tests/Certification/EgressRedirectHardeningTests.cs b/src/Ashlar.Tests.Infrastructure/Tests/Certification/EgressRedirectHardeningTests.cs new file mode 100644 index 000000000..effe3f790 --- /dev/null +++ b/src/Ashlar.Tests.Infrastructure/Tests/Certification/EgressRedirectHardeningTests.cs @@ -0,0 +1,787 @@ +using System.Collections.Concurrent; +using System.Net; +using System.Net.Http.Headers; +using System.Reflection; +using Ashlar.Abstractions.Security; +using Ashlar.Abstractions.Security.Egress; +using Ashlar.Infrastructure.Egress; +using FluentAssertions; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Xunit; + +namespace Ashlar.Tests.Infrastructure.Tests.Certification; + +/// SPEC-007 PR 4.3b: regression twins for inward redirects, origin boundaries, handler chains and factory binding. +[Trait("Category", "Certification")] +public sealed class EgressRedirectHardeningTests +{ + private const string FollowerTypeName = "EgressRedirectHandler"; + private static readonly EgressGuard Guard = new("full"); + + [Fact] + public void A_shared_primary_keeps_its_original_setting_on_later_wrappers() + { + var primary = new HttpClientHandler(); + using var first = EgressHttp.CreateClient(primary, EgressFamilies.Http, NewSite(), Guard); + using var second = EgressHttp.CreateClient(primary, EgressFamilies.Http, NewSite(), Guard); + + primary.AllowAutoRedirect.Should().BeFalse(); + Follower(Chain(HandlerOf(first))[1]).Follows.Should().BeTrue(); + Follower(Chain(HandlerOf(second))[1]).Follows.Should().BeTrue("the later wrapper remembers that Ashlar disabled the primary"); + } + + [Fact] + public void An_unknown_primary_type_is_warned_once_per_client_across_factory_rebuilds() + { + var name = "rebuild-" + NewId(); + var logs = new CapturingLoggerProvider(); + for (var i = 0; i < 2; i++) + { + var services = new ServiceCollection(); + services.AddLogging(b => b.AddProvider(logs)); + services.AddSingleton(new EgressGuard("secure-workstation")); + services.AddAshlarEgressGuard(); + services.AddHttpClient(name).ConfigurePrimaryHttpMessageHandler(() => new RecordingPrimary(new Recorder(_ => false))); + using var provider = services.BuildServiceProvider(); + using var client = provider.GetRequiredService().CreateClient(name); + } + + logs.Entries.Where(e => e.EventId.Id == 7304 && e.Message.Contains(name, StringComparison.Ordinal)).Should().ContainSingle(); + } + + [Theory] + [InlineData(false, false)] + [InlineData(true, false)] + [InlineData(false, true)] + [InlineData(true, true)] + public void Factory_filter_order_warns_only_when_client_configuration_removed_the_guard(bool factoryFirst, bool clear) + { + var name = "filter-order-" + NewId(); + var logs = new CapturingLoggerProvider(); + var services = new ServiceCollection(); + services.AddLogging(b => b.AddProvider(logs)); + if (factoryFirst) + services.AddHttpClient(); + services.AddAshlarEgressGuard(); + var builder = services.AddHttpClient(name); + if (clear) + builder.ConfigureAdditionalHttpMessageHandlers((handlers, _) => handlers.Clear()); + using var provider = services.BuildServiceProvider(); + using var client = provider.GetRequiredService().CreateClient(name); + + var warnings = logs.Entries.Where(e => e.Level == LogLevel.Warning && e.Message.Contains(name, StringComparison.Ordinal)).ToArray(); + warnings.Should().HaveCount(clear ? 1 : 0, "the factory logging scope is not a removed or moved Ashlar handler"); + if (clear) + warnings[0].EventId.Id.Should().Be(7303); + Chain(HandlerOf(client)).Count(h => h.GetType().Name == "EgressGuardHandler").Should().Be(1); + } + + [Theory] + [InlineData("http://same.example:8080/a", "http://same.example:8081/b")] + [InlineData("http://same.example/a", "https://same.example/b")] + public async Task P2_does_not_forward_API_key_headers_to_another_scheme_or_port(string from, string to) + { + var recorder = new Recorder(_ => false); + var primary = new RedirectingClientHandler(recorder, request => request.RequestUri!.AbsolutePath == "/a" + ? Redirect(HttpStatusCode.TemporaryRedirect, to) : null); + using var client = EgressHttp.CreateClient(primary, EgressFamilies.Http, NewSite(), Guard); + using var request = new HttpRequestMessage(HttpMethod.Post, from) { Content = new StringContent("private-body") }; + request.Headers.Add("X-Api-Key", "test-key"); + using var response = await client.SendAsync(request); + + response.StatusCode.Should().Be(HttpStatusCode.TemporaryRedirect); + primary.Sends.Should().ContainSingle("P2 limits the full origin, even when the host name is unchanged"); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void Credentialed_primaries_keep_the_runtime_redirect_policy(bool sockets) + { + using HttpMessageHandler primary = sockets + ? new SocketsHttpHandler { Credentials = new NetworkCredential("user", "secret") } + : new HttpClientHandler { Credentials = new NetworkCredential("user", "secret") }; + using var client = EgressHttp.CreateClient(primary, EgressFamilies.Http, NewSite(), Guard); + + (primary is SocketsHttpHandler s ? s.AllowAutoRedirect : ((HttpClientHandler)primary).AllowAutoRedirect).Should().BeTrue(); + Follower(Chain(HandlerOf(client))[1]).Follows.Should().BeFalse("the runtime owns credential forwarding semantics"); + } + + [Theory] + [InlineData("us-west-2", "us-west-2")] + [InlineData(" us-west-2 ", "us-west-2")] + [InlineData("", null)] + [InlineData(null, null)] + public void CloudBedrock_TheRuntimeClientsConfigurationNeverFollowsARedirect(string? region, string? expectedRegion) + { + var seam = typeof(Ashlar.AI.Pipeline.Clients.AwsBedrockChatClientFactory) + .GetMethod("RuntimeConfig", BindingFlags.NonPublic | BindingFlags.Static); + seam.Should().NotBeNull("the SDK-owned transport has a configuration seam without contacting AWS"); + var config = (Amazon.BedrockRuntime.AmazonBedrockRuntimeConfig)seam!.Invoke(null, [region])!; + + config.AllowAutoRedirect.Should().BeFalse(); + config.RegionEndpoint?.SystemName.Should().Be(expectedRegion); + } + + [Fact] + public async Task Under_an_enforcing_guard_a_redirect_to_a_remote_host_is_decided_refused_before_the_hop_is_sent() + { + var id = NewId(); + var name = "enforce-" + id; + var recorder = new Recorder(d => d.Site == "factory:" + name); + using var subscription = EgressDecisionLog.Subscribe(recorder); + var stub = new RedirectingClientHandler(recorder, request => + request.RequestUri!.IsLoopback ? Redirect(HttpStatusCode.TemporaryRedirect, $"http://remote-{id}.example/b") : null); + + var services = new ServiceCollection(); + services.AddSingleton(new EgressGuard("full", "enforce")); + services.AddAshlarEgressGuard(); + services.AddHttpClient(name).ConfigurePrimaryHttpMessageHandler(() => stub); + using var provider = services.BuildServiceProvider(); + using var client = provider.GetRequiredService().CreateClient(name); + + using var response = await client.GetAsync(new Uri("http://127.0.0.1:5999/a")); + + recorder.Decisions.Should().HaveCount(2); + var hop1 = recorder.Decisions[0]; + hop1.Destination.Should().Be("http://127.0.0.1:5999"); + hop1.Mode.Should().Be("enforce"); + hop1.Refused.Should().BeFalse("hop 1 is inside the host boundary"); + + var hop2 = recorder.Decisions[1]; + hop2.Destination.Should().Be($"http://remote-{id}.example"); + hop2.Mode.Should().Be("enforce"); + hop2.Refused.Should().BeTrue("with no subject the current label is SystemHigh, which may not go to a network export"); + hop2.Access.Reason.Should().Be(AccessDenialReason.SystemHighData); + stub.Sends.Should().HaveCount(2, "until SPEC-007 PR 4.7 no route acts on a refusal, so the hop is still sent"); + stub.Sends[1].DecisionsBefore.Should().Be(2, "the refusal was decided before the hop was sent, which is where PR 4.7 stops it"); + } + + [Fact] + public void A_primary_with_credentials_or_of_an_unknown_type_is_left_as_it_is() + { + var site = NewSite(); + var withCredentials = new HttpClientHandler { Credentials = new NetworkCredential("u", "p") }; + using var credentialed = EgressHttp.CreateClient(withCredentials, EgressFamilies.Http, site, Guard); + + withCredentials.AllowAutoRedirect.Should().BeTrue( + "the runtime does not send credentials to a redirect target, and a follower above the primary could not stop it"); + Follower(Chain(HandlerOf(credentialed))[1]).Follows.Should().BeFalse(); + + using var unknown = EgressHttp.CreateClient(new RecordingPrimary(new Recorder(_ => false)), EgressFamilies.Http, site, Guard); + var chain = Chain(HandlerOf(unknown)); + chain.Select(h => h.GetType().Name).Should().Equal("EgressGuardHandler", FollowerTypeName, nameof(RecordingPrimary)); + Follower(chain[1]).Follows.Should().BeFalse("an unknown primary is not followed for; it is checked after the send"); + } + + [Fact] + public void A_handler_that_already_has_a_follower_gets_no_second_one() + { + using var nested = EgressHttp.Wrap(EgressHttp.Wrap(new HttpClientHandler(), EgressFamilies.Grpc, NewSite(), Guard), EgressFamilies.Grpc, NewSite(), Guard); + + Chain(nested).Count(h => h.GetType().Name == FollowerTypeName).Should().Be(1); + } + + [Fact] + public async Task P2_around_a_factory_pipeline_restricts_only_that_request_without_changing_shared_P1() + { + var name = "nested-policies-" + NewId(); + var primary = new RedirectingClientHandler(new Recorder(_ => false), request => + request.RequestUri!.Host == "first.example" + ? Redirect(HttpStatusCode.TemporaryRedirect, "https://second.example/target") + : null); + var services = new ServiceCollection(); + services.AddSingleton(Guard); + services.AddAshlarEgressGuard(); + services.AddHttpClient(name).ConfigurePrimaryHttpMessageHandler(() => primary); + using var provider = services.BuildServiceProvider(); + var pipeline = provider.GetRequiredService().CreateHandler(name); + using var restricted = EgressHttp.CreateClient(pipeline, EgressFamilies.Http, NewSite(), Guard); + + using var stopped = await restricted.GetAsync("https://first.example/start"); + stopped.StatusCode.Should().Be(HttpStatusCode.TemporaryRedirect, "the outer EgressHttp route promises full-origin P2"); + primary.Sends.Should().ContainSingle(); + Chain(HandlerOf(restricted)).Count(h => h.GetType().Name == FollowerTypeName).Should().Be(1); + + using var factory = provider.GetRequiredService().CreateClient(name); + using var followed = await factory.GetAsync("https://first.example/start"); + followed.StatusCode.Should().Be(HttpStatusCode.OK, "a different request through the shared factory pipeline retains P1"); + primary.Sends.Should().HaveCount(3); + } + + [Fact] + public void On_AirGapped_an_unknown_factory_primary_is_named_in_a_warning_and_on_Full_it_is_not() + { + foreach (var (profile, expected) in new[] { ("air-gapped", 1), ("full", 0) }) + { + var name = "unknown-" + NewId(); + var logs = new CapturingLoggerProvider(); + var services = new ServiceCollection(); + services.AddLogging(b => b.AddProvider(logs)); + services.AddSingleton(new EgressGuard(profile)); + services.AddAshlarEgressGuard(); + services.AddHttpClient(name).ConfigurePrimaryHttpMessageHandler(() => new RecordingPrimary(new Recorder(_ => false))); + using var provider = services.BuildServiceProvider(); + + using (provider.GetRequiredService().CreateClient(name)) + { + } + + var warnings = logs.Entries.Where(e => e.EventId.Id == 7304 && e.Message.Contains(name, StringComparison.Ordinal)).ToList(); + warnings.Should().HaveCount(expected, "profile {0}", profile); + if (expected == 1) + { + warnings[0].Level.Should().Be(LogLevel.Warning); + warnings[0].Message.Should().Contain(typeof(RecordingPrimary).FullName!); + } + } + } + + [Fact] + public async Task A_change_of_scheme_or_port_alone_is_another_origin_on_both_routes() + { + var id = NewId(); + + // A redirect that changes only the scheme is another origin, returned by an EgressHttp client (P2)... + var site = NewSite(); + var recorder = new Recorder(d => d.Site == site); + using var subscription = EgressDecisionLog.Subscribe(recorder); + var stub = new RedirectingClientHandler(recorder, request => + request.RequestUri!.Scheme == Uri.UriSchemeHttp ? Redirect(HttpStatusCode.Found, $"https://same-{id}.example/a") : null); + using var client = EgressHttp.CreateClient(stub, EgressFamilies.Http, site, Guard); + + using (var response = await client.GetAsync(new Uri($"http://same-{id}.example/a"))) + response.StatusCode.Should().Be(HttpStatusCode.Found, "another scheme is another origin"); + + stub.Sends.Should().ContainSingle(); + recorder.Decisions.Select(d => d.Destination).Should().Equal($"http://same-{id}.example"); + + // ...and a rewrite that changes only the scheme, or only the port, is decided again on a factory client. + foreach (var (rewrite, expected) in new (Func Rewrite, string Expected)[] + { + (uri => new UriBuilder(uri) { Scheme = Uri.UriSchemeHttps, Port = 443 }.Uri, $"https://asked-{id}.example"), + (uri => new UriBuilder(uri) { Port = 8443 }.Uri, $"http://asked-{id}.example:8443"), + }) + { + var name = "origin-" + NewId(); + var factoryRecorder = new Recorder(d => d.Site == "factory:" + name); + using var factorySubscription = EgressDecisionLog.Subscribe(factoryRecorder); + var primary = new RecordingPrimary(factoryRecorder); + var services = new ServiceCollection(); + services.AddAshlarEgressGuard(); + services.AddHttpClient(name).AddHttpMessageHandler(() => new Rewriter(rewrite)).ConfigurePrimaryHttpMessageHandler(() => primary); + using var provider = services.BuildServiceProvider(); + using var factoryClient = provider.GetRequiredService().CreateClient(name); + + using (await factoryClient.GetAsync(new Uri($"http://asked-{id}.example/x"))) + { + } + + factoryRecorder.Decisions.Select(d => d.Destination).Should().Equal($"http://asked-{id}.example", expected); + primary.Sends.Should().ContainSingle().Which.DecisionsBefore.Should().Be(2, "decided before the primary sent it"); + } + } + + [Fact] + public async Task A_handler_that_sends_a_fresh_request_is_still_decided_at_the_cost_of_a_duplicate() + { + // A hedging or retry handler that builds a new HttpRequestMessage per attempt (headers copied, Options not) + // drops the note the guard handler wrote. The follower treats a request with no note as never decided and + // decides it (fail closed), so the same authority is recorded twice; and a rewritten one is recorded a third + // time by the guard handler's post-send check, since its own note never saw it. + var id = NewId(); + foreach (var (host, expected) in new (string? Host, string[] Expected)[] + { + (null, [$"https://asked-{id}.example", $"https://asked-{id}.example"]), + ($"other-{id}.example", [$"https://asked-{id}.example", $"https://other-{id}.example", $"https://other-{id}.example"]), + }) + { + var name = "cloned-" + NewId(); + var recorder = new Recorder(d => d.Site == "factory:" + name); + using var subscription = EgressDecisionLog.Subscribe(recorder); + var primary = new RecordingPrimary(recorder); + + var services = new ServiceCollection(); + services.AddAshlarEgressGuard(); + services.AddHttpClient(name).AddHttpMessageHandler(() => new Cloner(host)).ConfigurePrimaryHttpMessageHandler(() => primary); + using var provider = services.BuildServiceProvider(); + using var client = provider.GetRequiredService().CreateClient(name); + + using (await client.GetAsync(new Uri($"https://asked-{id}.example/x"))) + { + } + + recorder.Decisions.Select(d => d.Destination).Should().Equal(expected, "cloned to {0}", host ?? "the same host"); + primary.Sends.Should().ContainSingle().Which.DecisionsBefore.Should().Be(2, "the fresh request was decided before the primary sent it"); + } + } + + [Fact] + public async Task A_composite_factory_primary_is_followed_at_its_tail_and_its_own_rewrite_is_decided() + { + // ConfigurePrimaryHttpMessageHandler(() => new Rewriter { InnerHandler = new HttpClientHandler() }) is a legal + // primary. The follower goes directly above the tail, under the rewriter, so the rewrite is decided before the + // tail sends it, the tail's own following is turned off, and the tail's 307 is followed and decided. + var id = NewId(); + var name = "composite-" + id; + var recorder = new Recorder(d => d.Site == "factory:" + name); + using var subscription = EgressDecisionLog.Subscribe(recorder); + var tail = new RedirectingClientHandler(recorder, request => + request.RequestUri!.AbsolutePath == "/a" ? Redirect(HttpStatusCode.TemporaryRedirect, $"https://remote-{id}.example/b") : null); + var logs = new CapturingLoggerProvider(); + + var services = new ServiceCollection(); + services.AddLogging(b => b.AddProvider(logs)); + services.AddSingleton(new EgressGuard("air-gapped")); + services.AddAshlarEgressGuard(); + services.AddHttpClient(name).ConfigurePrimaryHttpMessageHandler(() => new Rewriter($"rewritten-{id}.example", tail)); + using var provider = services.BuildServiceProvider(); + using var client = provider.GetRequiredService().CreateClient(name); + + using var response = await client.GetAsync(new Uri($"https://asked-{id}.example/a")); + + response.StatusCode.Should().Be(HttpStatusCode.OK, "the follower under the composite's rewriter follows the tail's 307"); + tail.AllowAutoRedirect.Should().BeFalse("the tail is the primary whose following is turned off"); + tail.Sends.Select(s => s.Uri).Should().Equal($"https://rewritten-{id}.example/a", $"https://remote-{id}.example/b"); + recorder.Decisions.Select(d => d.Destination).Should().Equal($"https://asked-{id}.example", $"https://rewritten-{id}.example", $"https://remote-{id}.example"); + tail.Sends.Select(s => s.DecisionsBefore).Should().Equal(new[] { 2, 3 }, "each authority was decided before the tail sent to it"); + logs.Entries.Should().NotContain(e => e.EventId.Id == 7304 && e.Message.Contains(name, StringComparison.Ordinal), "the tail is a known type, so nothing is named on AirGapped"); + } + + [Theory] + [InlineData("http://127.0.0.1:11434/api/chat", "http://127.0.0.1:11434", EgressDestinationClass.Host)] + [InlineData("http://[::1]:11434/x", "http://[::1]:11434", EgressDestinationClass.Host)] + [InlineData("http://localhost/x", "http://localhost", EgressDestinationClass.Host)] + [InlineData("unix:///var/run/agent.sock", "unix://", EgressDestinationClass.Host)] + [InlineData("npipe://./pipe/agent", "npipe://.", EgressDestinationClass.Host)] + [InlineData("http://svc.localhost/x", "http://svc.localhost", EgressDestinationClass.Host)] + [InlineData("http://169.254.169.254/latest/meta-data/", "http://169.254.169.254", EgressDestinationClass.NetworkExport)] + [InlineData("http://[fe80::1]/x", "http://[fe80::1]", EgressDestinationClass.NetworkExport)] + public async Task A_redirect_from_outside_the_host_boundary_into_it_is_returned_unfollowed_with_the_hop_recorded(string location, string destination, EgressDestinationClass destinationClass) + { + // Owner decision 2026-10-06 (O2): a remote peer must not be able to bounce a request, body included, to a local + // service (loopback, *.localhost, or a link-local address, which a decision records as a network export). The + // 3xx is returned on both routes; the hop is decided so the record shows the attempt. + var id = NewId(); + var name = "inward-" + id; + var recorder = new Recorder(d => d.Site == "factory:" + name); + using var subscription = EgressDecisionLog.Subscribe(recorder); + var stub = new RedirectingClientHandler(recorder, request => + request.RequestUri!.Host.StartsWith("remote-", StringComparison.Ordinal) ? Redirect(HttpStatusCode.TemporaryRedirect, location) : null); + + var services = new ServiceCollection(); + services.AddAshlarEgressGuard(); + services.AddHttpClient(name).ConfigurePrimaryHttpMessageHandler(() => stub); + using var provider = services.BuildServiceProvider(); + using var client = provider.GetRequiredService().CreateClient(name); + + using (var response = await client.PostAsync(new Uri($"http://remote-{id}.example/a"), new StringContent("body"))) + response.StatusCode.Should().Be(HttpStatusCode.TemporaryRedirect, "a hop into the host boundary is returned, never followed (P1 too)"); + + stub.Sends.Should().ContainSingle("the body never went into the boundary").Which.Uri.Should().Be($"http://remote-{id}.example/a"); + recorder.Decisions.Select(d => d.Destination).Should().Equal(new[] { $"http://remote-{id}.example", destination }, "the attempted hop is recorded"); + recorder.Decisions[1].DestinationClass.Should().Be(destinationClass); + + // The same through an EgressHttp client (P2), which also records the attempt. + var site = NewSite(); + var rawRecorder = new Recorder(d => d.Site == site); + using var rawSubscription = EgressDecisionLog.Subscribe(rawRecorder); + var rawStub = new RedirectingClientHandler(rawRecorder, request => + request.RequestUri!.Host.StartsWith("remote-", StringComparison.Ordinal) ? Redirect(HttpStatusCode.TemporaryRedirect, location) : null); + using var raw = EgressHttp.CreateClient(rawStub, EgressFamilies.Http, site, Guard); + + using (var response = await raw.PostAsync(new Uri($"http://remote-{id}.example/a"), new StringContent("body"))) + response.StatusCode.Should().Be(HttpStatusCode.TemporaryRedirect); + + rawStub.Sends.Should().ContainSingle(); + rawRecorder.Decisions.Select(d => d.Destination).Should().Equal($"http://remote-{id}.example", destination); + } + + [Fact] + public async Task A_redirect_inside_the_host_boundary_or_out_of_it_is_still_followed_by_a_factory_client() + { + // The owner's rule is for hops into the boundary from outside only: loopback to loopback on another port, and + // loopback out to a remote host, keep P1 (followed and decided before the send). + var id = NewId(); + var name = "within-" + id; + var recorder = new Recorder(d => d.Site == "factory:" + name); + using var subscription = EgressDecisionLog.Subscribe(recorder); + var stub = new RedirectingClientHandler(recorder, request => request.RequestUri!.AbsolutePath switch + { + "/a" => Redirect(HttpStatusCode.TemporaryRedirect, "http://127.0.0.1:11434/b"), + "/b" => Redirect(HttpStatusCode.TemporaryRedirect, $"http://remote-{id}.example/c"), + _ => null, + }); + + var services = new ServiceCollection(); + services.AddAshlarEgressGuard(); + services.AddHttpClient(name).ConfigurePrimaryHttpMessageHandler(() => stub); + using var provider = services.BuildServiceProvider(); + using var client = provider.GetRequiredService().CreateClient(name); + + using var response = await client.GetAsync(new Uri("http://127.0.0.1:5999/a")); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + stub.Sends.Select(s => s.Uri).Should().Equal("http://127.0.0.1:5999/a", "http://127.0.0.1:11434/b", $"http://remote-{id}.example/c"); + recorder.Decisions.Select(d => d.Destination).Should().Equal("http://127.0.0.1:5999", "http://127.0.0.1:11434", $"http://remote-{id}.example"); + stub.Sends.Select(s => s.DecisionsBefore).Should().Equal(new[] { 1, 2, 3 }); + } + + [Fact] + public async Task Every_hop_is_decided_by_the_guard_the_guard_handler_uses_not_the_process_default() + { + var id = NewId(); + var name = "guard-" + id; + var guard = new RecordingGuard(); + var stub = new RedirectingClientHandler(new Recorder(_ => false), request => + request.RequestUri!.AbsolutePath == "/a" ? Redirect(HttpStatusCode.TemporaryRedirect, $"https://remote-{id}.example/b") : null); + + var services = new ServiceCollection(); + services.AddSingleton(guard); + services.AddAshlarEgressGuard(); + services.AddHttpClient(name).ConfigurePrimaryHttpMessageHandler(() => stub); + using var provider = services.BuildServiceProvider(); + using var client = provider.GetRequiredService().CreateClient(name); + + using (await client.GetAsync(new Uri($"https://origin-{id}.example/a"))) + { + } + + guard.Requests.Where(r => r.Site == "factory:" + name).Select(r => (r.Family, r.Destination!.ToString())).Should().Equal( + (EgressFamilies.HttpFactory, $"https://origin-{id}.example/a"), + (EgressFamilies.HttpFactory, $"https://remote-{id}.example/b")); + } + + [Fact] + public async Task Userinfo_in_a_Location_is_never_recorded_and_the_fragment_is_kept() + { + var id = NewId(); + var name = "userinfo-" + id; + var recorder = new Recorder(d => d.Site == "factory:" + name); + using var subscription = EgressDecisionLog.Subscribe(recorder); + var stub = new RedirectingClientHandler(recorder, request => + request.RequestUri!.AbsolutePath == "/a" ? Redirect(HttpStatusCode.TemporaryRedirect, $"https://user:pw@remote-{id}.example/b#frag") : null); + + var services = new ServiceCollection(); + services.AddAshlarEgressGuard(); + services.AddHttpClient(name).ConfigurePrimaryHttpMessageHandler(() => stub); + using var provider = services.BuildServiceProvider(); + using var client = provider.GetRequiredService().CreateClient(name); + using var request = new HttpRequestMessage(HttpMethod.Get, $"https://origin-{id}.example/a"); + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", "token-" + id); + + using var response = await client.SendAsync(request); + + recorder.Decisions.Select(d => d.Destination).Should().Equal($"https://origin-{id}.example", $"https://remote-{id}.example"); + response.RequestMessage!.RequestUri!.Fragment.Should().Be("#frag"); + stub.Sends.Select(s => s.HadAuthorization).Should().Equal(new[] { true, false }); + } + + [Fact] + public async Task A_relative_uri_below_the_guard_handler_is_decided_as_a_fault_and_nothing_throws() + { + var id = NewId(); + var site = NewSite(); + var recorder = new Recorder(d => d.Site == site); + using var subscription = EgressDecisionLog.Subscribe(recorder); + var stub = new RecordingPrimary(recorder); + using var invoker = new HttpMessageInvoker(EgressHttp.Wrap(new Rewriter(_ => new Uri("/b", UriKind.Relative), stub), EgressFamilies.Http, site, Guard)); + using var request = new HttpRequestMessage(HttpMethod.Get, $"https://asked-{id}.example/x"); + + using var response = await invoker.SendAsync(request, CancellationToken.None); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + recorder.Decisions.Should().HaveCount(2, "the relative URI is one authority of its own, decided once"); + recorder.Decisions[0].Destination.Should().Be($"https://asked-{id}.example"); + recorder.Decisions[1].Fault.Should().NotBeNull("a relative URI names no host, so its decision is a fault"); + recorder.Decisions[1].Destination.Should().Be("unknown"); + stub.Sends.Should().ContainSingle().Which.DecisionsBefore.Should().Be(2); + } + + [Fact] + public async Task A_token_cancelled_after_the_first_hop_stops_the_follower_before_the_next_hop_is_sent_or_decided() + { + var id = NewId(); + var name = "cancel-" + id; + var recorder = new Recorder(d => d.Site == "factory:" + name); + using var subscription = EgressDecisionLog.Subscribe(recorder); + using var cts = new CancellationTokenSource(); + var stub = new RedirectingClientHandler(recorder, request => + { + if (request.RequestUri!.AbsolutePath != "/a") + return null; + + cts.Cancel(); + return Redirect(HttpStatusCode.TemporaryRedirect, $"https://remote-{id}.example/b"); + }); + + var services = new ServiceCollection(); + services.AddAshlarEgressGuard(); + services.AddHttpClient(name).ConfigurePrimaryHttpMessageHandler(() => stub); + using var provider = services.BuildServiceProvider(); + using var client = provider.GetRequiredService().CreateClient(name); + + var act = () => client.GetAsync(new Uri($"https://origin-{id}.example/a"), cts.Token); + + await act.Should().ThrowAsync(); + stub.Sends.Should().ContainSingle("nothing is sent after the caller gave up"); + recorder.Decisions.Select(d => d.Destination).Should().Equal(new[] { $"https://origin-{id}.example" }, "a hop that is never sent is not decided"); + } + + [Fact] + public async Task An_intermediate_redirect_response_is_disposed_when_the_hop_is_followed() + { + var id = NewId(); + var site = NewSite(); + var recorder = new Recorder(d => d.Site == site); + using var subscription = EgressDecisionLog.Subscribe(recorder); + var intermediate = new TrackingContent(); + var stub = new RedirectingClientHandler(recorder, request => + { + if (request.RequestUri!.AbsolutePath != "/a") + return null; + + var redirect = Redirect(HttpStatusCode.Found, "/b"); + redirect.Content = intermediate; + return redirect; + }); + using var client = EgressHttp.CreateClient(stub, EgressFamilies.Http, site, Guard); + + using var response = await client.GetAsync(new Uri($"https://same-{id}.example/a")); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + stub.Sends.Should().HaveCount(2); + intermediate.Disposed.Should().BeTrue("the response the follower does not return is disposed, as the runtime's follower disposes it"); + } + + [Fact] + public async Task Wrap_over_a_delegating_handler_whose_inner_is_set_later_treats_it_as_unknown_and_decides_after_the_send() + { + // The chain has no primary at wrap time (the caller sets InnerHandler afterwards), so there is nothing to turn + // off: the follower only decides, and whatever is attached later follows on its own and is decided after the + // send (Known limit). Not a production shape: DefaultGrpcChannelFactory wraps a configured HttpClientHandler. + var id = NewId(); + var site = NewSite(); + var recorder = new Recorder(d => d.Site == site); + using var subscription = EgressDecisionLog.Subscribe(recorder); + var late = new Rewriter(uri => uri); + using var wrapped = EgressHttp.Wrap(late, EgressFamilies.Http, site, Guard); + + var chain = Chain(wrapped); + chain.Select(h => h.GetType().Name).Should().Equal("EgressGuardHandler", FollowerTypeName, nameof(Rewriter)); + Follower(chain[1]).Follows.Should().BeFalse("no primary was reachable at wrap time"); + + late.InnerHandler = new SelfFollowingPrimary($"moved-{id}.example"); + using var invoker = new HttpMessageInvoker(wrapped, disposeHandler: false); + using var request = new HttpRequestMessage(HttpMethod.Get, $"https://asked-{id}.example/x"); + using var response = await invoker.SendAsync(request, CancellationToken.None); + + recorder.Decisions.Select(d => d.Destination).Should().Equal( + new[] { $"https://asked-{id}.example", $"https://moved-{id}.example" }, + "the primary attached later followed on its own, and the authority it reached is decided after the send"); + } + + private static string NewId() => Guid.NewGuid().ToString("N"); + + private static string NewSite() => "twin:redirect:" + NewId(); + + private static HttpResponseMessage Redirect(HttpStatusCode status, string location) + { + var response = new HttpResponseMessage(status); + response.Headers.Location = new Uri(location, UriKind.RelativeOrAbsolute); + return response; + } + + /// The follower's Follows, MaxRedirects and FollowsAcrossOrigins, by reflection (no InternalsVisibleTo). + private static (bool Follows, int MaxRedirects, bool FollowsAcrossOrigins) Follower(HttpMessageHandler handler) + { + handler.GetType().Name.Should().Be(FollowerTypeName); + var settings = handler.GetType().GetField("_settings", BindingFlags.NonPublic | BindingFlags.Instance)!.GetValue(handler)!; + object? Read(string property) => settings.GetType().GetProperty(property, BindingFlags.NonPublic | BindingFlags.Instance)!.GetValue(settings); + return ((bool)Read("Follow")!, (int)Read("MaxAutomaticRedirections")!, (bool)Read("FollowCrossHost")!); + } + + private static List Chain(HttpMessageHandler outer) + { + var chain = new List(); + for (HttpMessageHandler? handler = outer; handler is not null && chain.Count < 10; handler = (handler as DelegatingHandler)?.InnerHandler) + chain.Add(handler); + return chain; + } + + /// The handler an sends through, which it keeps in a private field. + private static HttpMessageHandler HandlerOf(HttpMessageInvoker invoker) + { + var field = typeof(HttpMessageInvoker) + .GetFields(BindingFlags.NonPublic | BindingFlags.Instance) + .Single(f => typeof(HttpMessageHandler).IsAssignableFrom(f.FieldType)); + return (HttpMessageHandler)field.GetValue(invoker)!; + } + + /// Keeps the decisions the filter accepts. + private sealed class Recorder(Func keep) : IEgressDecisionSink + { + private readonly ConcurrentQueue _decisions = new(); + + public IReadOnlyList Decisions => _decisions.ToArray(); + + public void Record(EgressDecision decision) + { + if (keep(decision)) + _decisions.Enqueue(decision); + } + } + + private sealed record Send(string Uri, int DecisionsBefore, bool HadAuthorization); + + /// + /// A primary handler of a known type (it derives from ) that answers from a script: + /// the script's response, or 200. It never sends anything and never follows on its own. + /// + private sealed class RedirectingClientHandler(Recorder recorder, Func script) : HttpClientHandler + { + private readonly ConcurrentQueue _sends = new(); + + public IReadOnlyList Sends => _sends.ToArray(); + + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + _sends.Enqueue(new Send(request.RequestUri!.ToString(), recorder.Decisions.Count, request.Headers.Authorization is not null)); + var response = script(request) ?? new HttpResponseMessage(HttpStatusCode.OK); + response.RequestMessage = request; + return Task.FromResult(response); + } + } + + /// A primary of an unknown type that answers 200 and notes how many decisions preceded each send. + private sealed class RecordingPrimary(Recorder recorder) : HttpMessageHandler + { + private readonly ConcurrentQueue _sends = new(); + + public IReadOnlyList Sends => _sends.ToArray(); + + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + _sends.Enqueue(new Send(request.RequestUri!.ToString(), recorder.Decisions.Count, request.Headers.Authorization is not null)); + return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) { RequestMessage = request }); + } + } + + /// + /// A primary of an unknown type that "follows a redirect itself": it moves the request to another host (or answers + /// for a new request there) and returns 200, as a primary following internally would. + /// + private sealed class SelfFollowingPrimary(string movedHost, bool replaceRequest = false) : HttpMessageHandler + { + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) => + Task.FromResult(Answer(request)); + + protected override HttpResponseMessage Send(HttpRequestMessage request, CancellationToken cancellationToken) => Answer(request); + + private HttpResponseMessage Answer(HttpRequestMessage request) + { + var moved = new UriBuilder(request.RequestUri!) { Host = movedHost }.Uri; + if (replaceRequest) + return new HttpResponseMessage(HttpStatusCode.OK) { RequestMessage = new HttpRequestMessage(request.Method, moved) }; + + request.RequestUri = moved; + return new HttpResponseMessage(HttpStatusCode.OK) { RequestMessage = request }; + } + } + + /// A handler between the guard handler and the primary that rewrites every request's URI (to another host, by default). + private sealed class Rewriter : DelegatingHandler + { + private readonly Func _rewrite; + + public Rewriter(string host) => _rewrite = uri => new UriBuilder(uri) { Host = host }.Uri; + + public Rewriter(string host, HttpMessageHandler inner) + : base(inner) => _rewrite = uri => new UriBuilder(uri) { Host = host }.Uri; + + public Rewriter(Func rewrite) => _rewrite = rewrite; + + public Rewriter(Func rewrite, HttpMessageHandler inner) + : base(inner) => _rewrite = rewrite; + + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + request.RequestUri = _rewrite(request.RequestUri!); + return base.SendAsync(request, cancellationToken); + } + } + + /// + /// A handler between the guard handler and the primary that sends a fresh request each time (method and URI + /// copied, options not), as hedging and retry handlers do; optionally to another host. + /// + private sealed class Cloner(string? host) : DelegatingHandler + { + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + var uri = host is null ? request.RequestUri! : new UriBuilder(request.RequestUri!) { Host = host }.Uri; + var fresh = new HttpRequestMessage(request.Method, uri); + return base.SendAsync(fresh, cancellationToken); + } + } + + /// Content that remembers whether it was disposed. + private sealed class TrackingContent : HttpContent + { + public bool Disposed { get; private set; } + + protected override Task SerializeToStreamAsync(Stream stream, System.Net.TransportContext? context) => Task.CompletedTask; + + protected override bool TryComputeLength(out long length) + { + length = 0; + return true; + } + + protected override void Dispose(bool disposing) + { + Disposed = true; + base.Dispose(disposing); + } + } + + /// A host's own guard: it remembers every request it was asked about and decides as the explicit full-profile guard does. + private sealed class RecordingGuard : IEgressGuard + { + private readonly ConcurrentQueue _requests = new(); + + public IReadOnlyList Requests => _requests.ToArray(); + + public EgressDecision Evaluate(EgressRequest request) + { + _requests.Enqueue(request); + return Guard.Evaluate(request); + } + } + + private sealed record LogEntry(string Category, LogLevel Level, EventId EventId, string Message); + + private sealed class CapturingLoggerProvider : ILoggerProvider + { + private readonly ConcurrentQueue _entries = new(); + + public IReadOnlyList Entries => _entries.ToArray(); + + public ILogger CreateLogger(string categoryName) => new CapturingLogger(categoryName, _entries); + + public void Dispose() + { + } + + private sealed class CapturingLogger(string category, ConcurrentQueue entries) : ILogger + { + public IDisposable? BeginScope(TState state) where TState : notnull => null; + + public bool IsEnabled(LogLevel logLevel) => true; + + public void Log(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func formatter) => + entries.Enqueue(new LogEntry(category, logLevel, eventId, formatter(state, exception))); + } + } +} diff --git a/src/Ashlar.Tests.Infrastructure/Tests/Certification/EgressRedirectNeverFollowTests.cs b/src/Ashlar.Tests.Infrastructure/Tests/Certification/EgressRedirectNeverFollowTests.cs new file mode 100644 index 000000000..f4c1cf6ab --- /dev/null +++ b/src/Ashlar.Tests.Infrastructure/Tests/Certification/EgressRedirectNeverFollowTests.cs @@ -0,0 +1,127 @@ +using System.Collections.Concurrent; +using System.Net; +using Ashlar.Abstractions.Security.Egress; +using Ashlar.Infrastructure.Egress; +using FluentAssertions; +using Microsoft.Extensions.DependencyInjection; +using Xunit; + +namespace Ashlar.Tests.Infrastructure.Tests.Certification; + +/// Per-client no-follow policy must survive the shared primary's remembered redirect settings. +[Trait("Category", "Certification")] +public sealed class EgressRedirectNeverFollowTests +{ + [Theory] + [InlineData(false, false, false)] + [InlineData(false, true, false)] + [InlineData(true, false, false)] + [InlineData(true, true, false)] + [InlineData(false, false, true)] + [InlineData(false, true, true)] + [InlineData(true, false, true)] + [InlineData(true, true, true)] + public async Task Never_follow_wins_after_another_wrapper_remembers_the_shared_primary(bool factoryFirst, bool clearHandlers, bool synchronous) + { + var name = "never-shared-" + Guid.NewGuid().ToString("N"); + var primary = new RedirectingPrimary(); + using var earlierRaw = factoryFirst ? null : EgressHttp.CreateClient(primary, EgressFamilies.Http, "earlier", new EgressGuard("full")); + var services = new ServiceCollection(); + services.AddAshlarEgressGuard(); + services.AddHttpClient("ordinary").ConfigurePrimaryHttpMessageHandler(() => primary); + var restrictedBuilder = services.AddHttpClient(name).ConfigurePrimaryHttpMessageHandler(() => primary).NeverFollowRedirects(); + if (clearHandlers) + restrictedBuilder.ConfigureAdditionalHttpMessageHandlers((handlers, _) => handlers.Clear()); + using var provider = services.BuildServiceProvider(); + var factory = provider.GetRequiredService(); + using var ordinary = factory.CreateClient("ordinary"); + using var restricted = factory.CreateClient(name); + var recorder = new Recorder("factory:" + name); + using var subscription = EgressDecisionLog.Subscribe(recorder); + + using var stopRequest = new HttpRequestMessage(HttpMethod.Get, "https://first.example/start"); + using var stopped = synchronous ? restricted.Send(stopRequest) : await restricted.SendAsync(stopRequest); + + stopped.StatusCode.Should().Be(HttpStatusCode.TemporaryRedirect, "the client's explicit no-follow policy overrides the primary's remembered original setting"); + primary.Requests.Should().ContainSingle().Which.Should().Be("https://first.example/start"); + recorder.Decisions.Select(d => d.Destination).Should().Equal("https://first.example"); + + using var followRequest = new HttpRequestMessage(HttpMethod.Get, "https://first.example/start"); + using var followed = synchronous ? ordinary.Send(followRequest) : await ordinary.SendAsync(followRequest); + followed.StatusCode.Should().Be(HttpStatusCode.OK, "no-follow belongs to the restricted client, not the shared primary's remembered policy"); + primary.Requests.Should().Equal("https://first.example/start", "https://first.example/start", "https://second.example/target"); + } + + [Fact] + public async Task Never_follow_on_an_outer_factory_client_restricts_only_its_request_through_an_existing_follower() + { + var primary = new RedirectingPrimary(); + var innerServices = new ServiceCollection(); + innerServices.AddAshlarEgressGuard(); + innerServices.AddHttpClient("inner").ConfigurePrimaryHttpMessageHandler(() => primary); + using var innerProvider = innerServices.BuildServiceProvider(); + var pipeline = innerProvider.GetRequiredService().CreateHandler("inner"); + var outerServices = new ServiceCollection(); + outerServices.AddAshlarEgressGuard(); + outerServices.AddHttpClient("outer").ConfigurePrimaryHttpMessageHandler(() => pipeline).NeverFollowRedirects(); + using var outerProvider = outerServices.BuildServiceProvider(); + using var restricted = outerProvider.GetRequiredService().CreateClient("outer"); + + using var stopped = await restricted.GetAsync("https://first.example/start"); + + stopped.StatusCode.Should().Be(HttpStatusCode.TemporaryRedirect, "the existing inner follower must honor this outer client's no-follow policy"); + primary.Requests.Should().ContainSingle(); + using var ordinary = innerProvider.GetRequiredService().CreateClient("inner"); + using var followed = await ordinary.GetAsync("https://first.example/start"); + followed.StatusCode.Should().Be(HttpStatusCode.OK, "a separate request through the shared inner client keeps its own policy"); + primary.Requests.Should().HaveCount(3); + } + + [Fact] + public async Task Ordinary_clients_still_follow_when_a_shared_primary_was_already_disabled_by_Ashlar() + { + var primary = new RedirectingPrimary(); + using var earlier = EgressHttp.CreateClient(primary, EgressFamilies.Http, "earlier", new EgressGuard("full")); + var services = new ServiceCollection(); + services.AddAshlarEgressGuard(); + services.AddHttpClient("ordinary").ConfigurePrimaryHttpMessageHandler(() => primary); + using var provider = services.BuildServiceProvider(); + using var client = provider.GetRequiredService().CreateClient("ordinary"); + + using var response = await client.GetAsync("https://first.example/start"); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + primary.Requests.Should().Equal("https://first.example/start", "https://second.example/target"); + } + + private sealed class RedirectingPrimary : HttpClientHandler + { + public List Requests { get; } = []; + + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) => + Task.FromResult(Answer(request)); + + protected override HttpResponseMessage Send(HttpRequestMessage request, CancellationToken cancellationToken) => Answer(request); + + private HttpResponseMessage Answer(HttpRequestMessage request) + { + Requests.Add(request.RequestUri!.AbsoluteUri); + var response = new HttpResponseMessage(request.RequestUri.Host == "first.example" + ? HttpStatusCode.TemporaryRedirect : HttpStatusCode.OK) { RequestMessage = request }; + if (response.StatusCode == HttpStatusCode.TemporaryRedirect) + response.Headers.Location = new Uri("https://second.example/target"); + return response; + } + } + + private sealed class Recorder(string site) : IEgressDecisionSink + { + public ConcurrentQueue Decisions { get; } = new(); + + public void Record(EgressDecision decision) + { + if (decision.Site == site) + Decisions.Enqueue(decision); + } + } +} diff --git a/src/Ashlar.Tests.Infrastructure/Tests/Certification/EgressRedirectTwinTests.cs b/src/Ashlar.Tests.Infrastructure/Tests/Certification/EgressRedirectTwinTests.cs index e2a7928f3..92bb92a64 100644 --- a/src/Ashlar.Tests.Infrastructure/Tests/Certification/EgressRedirectTwinTests.cs +++ b/src/Ashlar.Tests.Infrastructure/Tests/Certification/EgressRedirectTwinTests.cs @@ -254,12 +254,13 @@ public async Task Unknown_primary_followed_authority_is_evaluated_after_the_send } [Fact] - public async Task Unknown_primary_logs_a_warning_naming_the_type() + public async Task Unknown_primary_on_AirGapped_logs_a_warning_naming_the_type() { var name = "redirect-unknown-" + Guid.NewGuid().ToString("N"); var capture = new WarningCapture(); var services = new ServiceCollection(); services.AddLogging(b => b.SetMinimumLevel(LogLevel.Warning).AddProvider(capture)); + services.AddSingleton(new EgressGuard("air-gapped")); services.AddAshlarEgressGuard(); services.AddHttpClient(name).ConfigurePrimaryHttpMessageHandler(() => new FollowingUnknownPrimary(new Uri("https://example/landed"))); diff --git a/src/Ashlar.Tests.Infrastructure/Tests/VirtualProduction/EgressApiHostProdStyleTests.cs b/src/Ashlar.Tests.Infrastructure/Tests/VirtualProduction/EgressApiHostProdStyleTests.cs index 1d3f151a5..fd60d32b8 100644 --- a/src/Ashlar.Tests.Infrastructure/Tests/VirtualProduction/EgressApiHostProdStyleTests.cs +++ b/src/Ashlar.Tests.Infrastructure/Tests/VirtualProduction/EgressApiHostProdStyleTests.cs @@ -5,11 +5,16 @@ using Ashlar.Tests.Infrastructure.Helpers; using Ashlar.Tests.Infrastructure.Helpers.VirtualProduction; using FluentAssertions; +using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Hosting.Server; +using Microsoft.AspNetCore.Hosting.Server.Features; +using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc.Testing; using Microsoft.AspNetCore.TestHost; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; using Xunit; namespace Ashlar.Tests.Infrastructure.Tests.VirtualProduction; @@ -32,6 +37,8 @@ namespace Ashlar.Tests.Infrastructure.Tests.VirtualProduction; /// thread that runs Program.cs, so the decision is found by its site and its unique destination host. When the /// exporters' clients come from the factory they also record factory: decisions to the same host; the site /// filter leaves those out. +/// Redirects (SPEC-007 PR 4.3). The ashlar-sns-signing client never follows one: against a +/// loopback server that answers 302, over the factory's own primary handler, it returns the 302. /// Program.cs reads every switch through builder.Configuration, so the values are injected with /// UseSetting only; no process environment variable is touched. /// @@ -92,6 +99,24 @@ public async Task TheSnsSigningClient_FromTheHostFactory_RecordsOneDecision_With decision.Fault.Should().BeNull(); } + [Fact(Timeout = TestTimeouts.HostTouching)] + public async Task TheSnsSigningClient_NeverFollowsARedirect_OverTheFactorysOwnPrimaryHandler() + { + // SPEC-007 PR 4.3: the signing-certificate fetch is checked against its host before the send, so a redirect + // would reach a host nobody checked. A loopback server answers 302; the client returns it and never asks for + // the target. The primary handler is the factory's own (no test default replaces it), so the runtime would + // follow on its own, and the egress redirect follower would follow for it, unless Program.cs turns it off. + await using var server = await LoopbackRedirectServer.StartAsync(); + using var factory = new AshlarApiWebApplicationFactory(); + await AssertHealthyAsync(factory); + + using var client = factory.Services.GetRequiredService().CreateClient(SnsSigningClient); + using var response = await client.GetAsync(new Uri($"http://127.0.0.1:{server.Port}/a")); + + response.StatusCode.Should().Be(HttpStatusCode.Found, "the SNS signing client returns the redirect"); + server.Paths.Should().Equal(new[] { "/a" }, "the redirect target is never asked for"); + } + [Fact(Timeout = TestTimeouts.HostTouching)] public async Task AnOtlpEndpoint_RecordsOneEgTel01Decision_WithoutUserinfoPathOrQuery() { @@ -116,6 +141,53 @@ public async Task AnOtlpEndpoint_RecordsOneEgTel01Decision_WithoutUserinfoPathOr decision.Fault.Should().BeNull(); } + /// A loopback Kestrel: /a answers 302 to /b, anything else 200; it keeps every path asked for. + private sealed class LoopbackRedirectServer : IAsyncDisposable + { + private readonly WebApplication _app; + private readonly ConcurrentQueue _paths; + + private LoopbackRedirectServer(WebApplication app, ConcurrentQueue paths, int port) + { + _app = app; + _paths = paths; + Port = port; + } + + public int Port { get; } + + public IReadOnlyList Paths => _paths.ToArray(); + + public static async Task StartAsync() + { + var paths = new ConcurrentQueue(); + var builder = WebApplication.CreateBuilder(new WebApplicationOptions { EnvironmentName = "Production", ContentRootPath = AppContext.BaseDirectory }); + builder.Logging.ClearProviders(); + builder.WebHost.ConfigureKestrel(kestrel => kestrel.Listen(IPAddress.Loopback, 0)); + var app = builder.Build(); + app.Run(context => + { + paths.Enqueue(context.Request.Path.Value ?? string.Empty); + if (context.Request.Path == "/a") + { + context.Response.StatusCode = StatusCodes.Status302Found; + context.Response.Headers.Location = "/b"; + } + + return Task.CompletedTask; + }); + await app.StartAsync(); + var address = app.Services.GetRequiredService().Features.Get()!.Addresses.Single(); + return new LoopbackRedirectServer(app, paths, new Uri(address).Port); + } + + public async ValueTask DisposeAsync() + { + await _app.StopAsync(); + await _app.DisposeAsync(); + } + } + private static async Task AssertHealthyAsync(WebApplicationFactory factory) { using var client = factory.CreateClient();