Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
f352a19
test(egress): pin PR 4.3b redirect hardening regressions
IanFrelinger Oct 8, 2026
4ea1051
test(egress): preserve shared primary state and bound warnings
IanFrelinger Oct 8, 2026
455a7ce
fix(egress): harden redirect boundaries and factory binding
IanFrelinger Oct 8, 2026
a599890
test(egress): pin nested mixed-route redirect policy
IanFrelinger Oct 8, 2026
f4429d1
fix(egress): retain P2 when wrapping shared factory pipelines
IanFrelinger Oct 8, 2026
2aac602
docs(egress): record redirect boundaries and transport limits
IanFrelinger Oct 8, 2026
c4200d7
fix(egress): use concrete decider return type
IanFrelinger Oct 8, 2026
02818ca
test(egress): pin per-client no-follow on shared primaries
IanFrelinger Oct 8, 2026
59d1d6f
test(egress): cover synchronous per-client no-follow sends
IanFrelinger Oct 8, 2026
f8da07c
fix(egress): retain per-client no-follow through shared handlers
IanFrelinger Oct 8, 2026
6f43901
docs(egress): describe no-follow binding constructor input
IanFrelinger Oct 8, 2026
c78d593
test(egress): share an already-started mediated primary
IanFrelinger Oct 8, 2026
da43746
test(egress): add strict TRX mutation batch evidence runner
IanFrelinger Oct 8, 2026
480a7b1
fix(egress): retain disabled settings on started shared primaries
IanFrelinger Oct 8, 2026
831a2f5
test(egress): retain portable mutation-runner regression fixtures
IanFrelinger Oct 8, 2026
955c03e
test(egress): prepare causal redirect mutation manifest
IanFrelinger Oct 8, 2026
a9a4e88
test(egress): pin xUnit failure diagnostics in mutation TRX
IanFrelinger Oct 8, 2026
e4d3280
fix(egress): recognize verified xUnit failure diagnostics
IanFrelinger Oct 8, 2026
9785b5c
test(egress): bind redirect limit mutation to async theory
IanFrelinger Oct 8, 2026
19b3438
Merge master into 4.3b; fix Bedrock sdk.client inventory pin
cursoragent Oct 9, 2026
c8438ab
Merge master (4.10b) into 4.3b; fix Windows loopback HTTPS cert
cursoragent Oct 9, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 13 additions & 15 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
8 changes: 1 addition & 7 deletions application/src/Ashlar.API/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -170,13 +170,7 @@

builder.Services.AddSingleton<IAshlarIngressAccessor, HttpAshlarIngressAccessor>();
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<ISnsSignatureVerifier, SnsRsaSignatureVerifier>();
builder.Services.AddRateLimiter(static o =>
{
Expand Down
2 changes: 1 addition & 1 deletion ci/cert-gate-assertions.md

Large diffs are not rendered by default.

6 changes: 3 additions & 3 deletions ci/egress-inventory.tsv
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
8 changes: 1 addition & 7 deletions commercial/src/Ashlar.Commercial.Fleet.Host/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -77,13 +77,7 @@

builder.Services.AddSingleton<IAshlarIngressAccessor, HttpAshlarIngressAccessor>();
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<ISnsSignatureVerifier, SnsRsaSignatureVerifier>();
builder.Services.AddRateLimiter(static o =>
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -22,6 +27,8 @@ namespace Ashlar.Commercial.Tests.Fleet.Host;
/// proves Fleet.Host binds that client NAME: one send through it is one <c>factory:mesh-lab-worker-executor</c>
/// decision. It does not enable the MeshLab worker, whose registration only runs when
/// <c>Ashlar:MeshLab:WorkerExecutor:Enabled</c> is true; the factory builds a client by name either way.</para>
/// <para><b>Redirects</b> (SPEC-007 PR 4.3). The <c>ashlar-sns-signing</c> client never follows one: against a
/// loopback server that answers 302, over the factory's own primary handler, it returns the 302.</para>
/// <para><b>Isolation.</b> 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.</para>
Expand All @@ -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()
Expand Down Expand Up @@ -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<string>();
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<IServer>().Features.Get<IServerAddressesFeature>()!.Addresses.Single()).Port;

await using var factory = new WebApplicationFactory<FleetHostProgram>().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<IHttpClientFactory>().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();
}

/// <summary>Keeps the decisions whose destination names this test's unique host.</summary>
private sealed class DecisionRecorder : IEgressDecisionSink
{
Expand Down
Loading
Loading