diff --git a/backend/src/CodeSpace.Api/Startup.cs b/backend/src/CodeSpace.Api/Startup.cs
index 0b8de4926..a1c3feecc 100644
--- a/backend/src/CodeSpace.Api/Startup.cs
+++ b/backend/src/CodeSpace.Api/Startup.cs
@@ -5,6 +5,7 @@
using CodeSpace.Core.Services.Workflows.Llm;
using CodeSpace.Api.Filters;
using CodeSpace.Core.Services.Credentials;
+using CodeSpace.Core.Services.OutboundHttp;
using CodeSpace.Core.Services.Identity;
using CodeSpace.Core.Settings;
using Microsoft.AspNetCore.Diagnostics.HealthChecks;
@@ -70,6 +71,11 @@ public void ConfigureServices(IServiceCollection services)
// generation, no connection-lifetime tuning, no transient resilience. Register the hardened, resilient named
// clients (generous tunable budget + SocketsHttpHandler + retry/Retry-After) — shared with the resilience test.
services.AddLlmHttpClients();
+ // http.request sends to a URL that trigger text or an upstream model can write, so its client reaches only
+ // public addresses or ones committed to OutboundHttp:AllowedInternalDestinations, checked on every redirect hop,
+ // and a cross-origin redirect carries none of the author's headers. The allowlist is parsed here, at boot, so a
+ // malformed entry stops the host instead of failing every http.request call.
+ services.AddGuardedHttpClient(nameof(CodeSpace.Core.Services.Workflows.Nodes.Builtin.HttpRequestNode), Configuration);
// Periodic background work is uniformly IRecurringJob (Rule 14) — there is deliberately no AddHostedService
// here. A hosted service runs its own timer on EVERY pod including the public API, outside the mediator
// pipeline and invisible to the dashboard; a recurring job runs only where a Hangfire server does (the Worker
diff --git a/backend/src/CodeSpace.Api/appsettings.json b/backend/src/CodeSpace.Api/appsettings.json
index 2f8420aad..866bc98fb 100644
--- a/backend/src/CodeSpace.Api/appsettings.json
+++ b/backend/src/CodeSpace.Api/appsettings.json
@@ -63,6 +63,10 @@
"StoreDirectory": "",
"LocalRwxShared": false
},
+ "OutboundHttp": {
+ "_doc": "AllowedInternalDestinations lists the INTERNAL destinations the http.request workflow node may reach: host names (exact match, no subdomains), IP addresses or CIDR ranges, e.g. [\"internal-api.corp\", \"10.20.0.0/16\"]. Everything else that is loopback, private, link-local, CGNAT, unique-local or a cloud metadata address is refused, on every redirect hop, however the address is spelled. Empty by default: a workflow URL can be written by trigger text or a model, so nothing on the worker's own network is reachable until an operator commits it here. Read once at boot; a malformed entry stops the host from starting. The node honours the worker's HTTP(S)_PROXY / NO_PROXY: a direct connection is checked as it is made, a proxied request by resolving its host on the worker before it is sent. The proxy resolves that name again, so a name whose DNS answer changes in between can still reach whatever the proxy can: keep the proxy itself from reaching internal addresses.",
+ "AllowedInternalDestinations": []
+ },
"Shutdown": {
"_doc": "Graceful-shutdown drain budget in seconds. The orchestrator's grace period MUST be at least this or the process is SIGKILLed before in-flight background work drains (k8s: terminationGracePeriodSeconds). 30 matches k8s's own default.",
"DrainSeconds": 30
diff --git a/backend/src/CodeSpace.Core/Services/OutboundHttp/GuardedDestinationHandler.cs b/backend/src/CodeSpace.Core/Services/OutboundHttp/GuardedDestinationHandler.cs
new file mode 100644
index 000000000..7c2cfb41e
--- /dev/null
+++ b/backend/src/CodeSpace.Core/Services/OutboundHttp/GuardedDestinationHandler.cs
@@ -0,0 +1,47 @@
+using System.Net.Sockets;
+
+namespace CodeSpace.Core.Services.OutboundHttp;
+
+///
+/// The per-request half of the destination guard (the connect-time half is ),
+/// run on every hop, redirects included.
+///
+/// A request is addressed to the authority in its URL, the one the guard checks: an author-written Host
+/// header is dropped, because Host — and the TLS SNI the handler derives from it — is what picks the virtual host
+/// behind an address, so an operator who admits one name on a shared ingress would otherwise have admitted every other
+/// name behind it. Browsers' fetch treats Host as a forbidden request header the same way.
+///
+/// A request the operator's proxy will carry has its destination checked here, before it is sent: the socket the
+/// handler opens goes to the proxy, so the connect-time check never sees where the request is going. A refusal has the
+/// shape a connect-time one has — an whose inner exception is the
+/// — so callers handle the two alike.
+///
+public sealed class GuardedDestinationHandler : DelegatingHandler
+{
+ private readonly OutboundDestinationGuard _guard;
+
+ public GuardedDestinationHandler(OutboundDestinationGuard guard) { _guard = guard; }
+
+ protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
+ {
+ request.Headers.Host = null;
+
+ await PermitProxiedDestinationAsync(request.RequestUri!, cancellationToken).ConfigureAwait(false);
+
+ return await base.SendAsync(request, cancellationToken).ConfigureAwait(false);
+ }
+
+ private async Task PermitProxiedDestinationAsync(Uri destination, CancellationToken cancellationToken)
+ {
+ if (_guard.ProxyFor(destination) == null) return;
+
+ try
+ {
+ await _guard.PermitAsync(destination.IdnHost, cancellationToken).ConfigureAwait(false);
+ }
+ catch (Exception ex) when (ex is OutboundDestinationRefusedException or SocketException)
+ {
+ throw new HttpRequestException($"{ex.Message} ({destination.IdnHost}:{destination.Port})", ex);
+ }
+ }
+}
diff --git a/backend/src/CodeSpace.Core/Services/OutboundHttp/GuardedHttpClientRegistration.cs b/backend/src/CodeSpace.Core/Services/OutboundHttp/GuardedHttpClientRegistration.cs
new file mode 100644
index 000000000..aa828b833
--- /dev/null
+++ b/backend/src/CodeSpace.Core/Services/OutboundHttp/GuardedHttpClientRegistration.cs
@@ -0,0 +1,48 @@
+using CodeSpace.Core.Settings.OutboundHttp;
+using Microsoft.Extensions.Configuration;
+using Microsoft.Extensions.DependencyInjection;
+
+namespace CodeSpace.Core.Services.OutboundHttp;
+
+///
+/// Registers a named HttpClient whose every connection goes through , whose every
+/// request goes through , and whose redirects are followed by
+/// — the client for outbound HTTP whose URL a workflow author, a trigger
+/// payload or an upstream model can write. Extracted so the host (Startup), the integration fixture and the tests all
+/// exercise this one registration rather than re-deriving it.
+///
+public static class GuardedHttpClientRegistration
+{
+ ///
+ /// The host's registration: the operator's committed allowlist, parsed HERE — once, while the host is being built —
+ /// so a malformed entry stops the host from starting rather than failing every http.request call at run time; and
+ /// the process's proxy (: HTTP(S)_PROXY / NO_PROXY, or the OS setting).
+ ///
+ public static IServiceCollection AddGuardedHttpClient(this IServiceCollection services, string name, IConfiguration configuration) =>
+ services.AddGuardedHttpClient(name, new OutboundDestinationGuard(new OutboundHttpAllowlistSetting(configuration).Value, HttpClient.DefaultProxy));
+
+ public static IServiceCollection AddGuardedHttpClient(this IServiceCollection services, string name, OutboundDestinationGuard guard)
+ {
+ services.AddHttpClient(name)
+ .ConfigurePrimaryHttpMessageHandler(() => CreatePrimaryHandler(guard))
+ .AddHttpMessageHandler(() => new OriginScopedRedirectHandler())
+ .AddHttpMessageHandler(() => new GuardedDestinationHandler(guard));
+
+ return services;
+ }
+
+ ///
+ /// Redirects off (the handler above follows them). Cookies off: the factory shares one primary handler across every
+ /// client it hands out for its lifetime — across runs and teams — so a handler-owned cookie jar would send one run's
+ /// session on another's requests; the only cookies sent are the ones an author writes. The guard's proxy, whose
+ /// connections the connect callback recognises and dials as the operator's own. HTTP/3 never engages — it bypasses
+ /// the connect callback, but requests default to HTTP/1.1 and are never upgraded.
+ ///
+ private static SocketsHttpHandler CreatePrimaryHandler(OutboundDestinationGuard guard) => new()
+ {
+ AllowAutoRedirect = false,
+ UseCookies = false,
+ Proxy = guard.Proxy,
+ ConnectCallback = guard.ConnectAsync,
+ };
+}
diff --git a/backend/src/CodeSpace.Core/Services/OutboundHttp/OriginScopedRedirectHandler.cs b/backend/src/CodeSpace.Core/Services/OutboundHttp/OriginScopedRedirectHandler.cs
new file mode 100644
index 000000000..ae1fd0d3e
--- /dev/null
+++ b/backend/src/CodeSpace.Core/Services/OutboundHttp/OriginScopedRedirectHandler.cs
@@ -0,0 +1,78 @@
+using System.Net;
+
+namespace CodeSpace.Core.Services.OutboundHttp;
+
+///
+/// Follows redirects for a guarded client in place of the handler's built-in follower, which strips only
+/// Authorization when a redirect changes origin: every other header the author wrote — X-Api-Key,
+/// PRIVATE-TOKEN, Cookie, often resolved from a team secret — went on to whatever host the first one
+/// pointed at. Here a hop that changes scheme, host or port drops EVERY request header; a same-origin hop keeps them,
+/// since that is the origin they were written for. That includes an http→https move on the same host: it arrives
+/// without the author's headers, and the author keeps them by writing the https URL. A redirect that would carry the
+/// request BODY to another origin (a 307/308, or a 300/301/302 of anything but a POST) is not followed at all, because
+/// the body holds the same secrets — a token request's client_secret, a webhook's payload — and re-sending the
+/// request without it would be a different request; the 3xx is returned for the workflow to act on. Each hop connects
+/// through the same primary handler, so the destination guard checks it again.
+///
+/// Followed rather than returned as a 3xx because http.request has always followed redirects, and a workflow
+/// calling an API that moved behind a same-origin or body-less hop (a CDN, a canonical path) would otherwise start
+/// failing. Like the built-in follower: 300/301/302/303/307/308 with a Location are followed; 303, and
+/// 300/301/302 after a POST, continue as a body-less GET; an https→http downgrade or a non-http scheme is not followed,
+/// and the 3xx is returned. Unlike it, at most hops (the built-in allows 50) are followed,
+/// after which the last 3xx is returned as is.
+///
+public sealed class OriginScopedRedirectHandler : DelegatingHandler
+{
+ public const int MaxRedirects = 10;
+
+ protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
+ {
+ var response = await base.SendAsync(request, cancellationToken).ConfigureAwait(false);
+
+ for (var hop = 0; hop < MaxRedirects && ReadFollowableTarget(request, response) is { } target; hop++)
+ {
+ Retarget(request, target, response.StatusCode);
+ response.Dispose();
+
+ response = await base.SendAsync(request, cancellationToken).ConfigureAwait(false);
+ }
+
+ return response;
+ }
+
+ private static Uri? ReadFollowableTarget(HttpRequestMessage request, HttpResponseMessage response)
+ {
+ if (!IsRedirect(response.StatusCode) || response.Headers.Location is not { } location) return null;
+
+ var target = location.IsAbsoluteUri ? location : new Uri(request.RequestUri!, location);
+
+ return IsFollowable(request.RequestUri!, target) && !CarriesBodyAcrossOrigin(request, target, response.StatusCode) ? target : null;
+ }
+
+ private static bool IsRedirect(HttpStatusCode status) => (int)status is 300 or 301 or 302 or 303 or 307 or 308;
+
+ /// https anywhere, or http from http — never a downgrade, never another scheme.
+ private static bool IsFollowable(Uri current, Uri target) => target.Scheme == Uri.UriSchemeHttps || (target.Scheme == Uri.UriSchemeHttp && current.Scheme == Uri.UriSchemeHttp);
+
+ private static bool CarriesBodyAcrossOrigin(HttpRequestMessage request, Uri target, HttpStatusCode status) => request.Content != null && !ContinuesAsGet(status, request.Method) && !IsSameOrigin(request.RequestUri!, target);
+
+ private static void Retarget(HttpRequestMessage request, Uri target, HttpStatusCode status)
+ {
+ if (!IsSameOrigin(request.RequestUri!, target)) request.Headers.Clear();
+
+ if (ContinuesAsGet(status, request.Method)) DropBody(request);
+
+ request.RequestUri = target;
+ }
+
+ private static bool IsSameOrigin(Uri current, Uri target) => current.Scheme == target.Scheme && current.Port == target.Port && string.Equals(current.IdnHost, target.IdnHost, StringComparison.OrdinalIgnoreCase);
+
+ private static bool ContinuesAsGet(HttpStatusCode status, HttpMethod method) => status == HttpStatusCode.SeeOther || ((int)status is 300 or 301 or 302 && method == HttpMethod.Post);
+
+ private static void DropBody(HttpRequestMessage request)
+ {
+ request.Method = HttpMethod.Get;
+ request.Content?.Dispose();
+ request.Content = null;
+ }
+}
diff --git a/backend/src/CodeSpace.Core/Services/OutboundHttp/OutboundDestinationAllowlist.cs b/backend/src/CodeSpace.Core/Services/OutboundHttp/OutboundDestinationAllowlist.cs
new file mode 100644
index 000000000..5624d4d0e
--- /dev/null
+++ b/backend/src/CodeSpace.Core/Services/OutboundHttp/OutboundDestinationAllowlist.cs
@@ -0,0 +1,78 @@
+using System.Net;
+using CodeSpace.Core.Settings.OutboundHttp;
+
+namespace CodeSpace.Core.Services.OutboundHttp;
+
+///
+/// The internal destinations an operator has deliberately opened to workflow-driven outbound HTTP — a host name, an
+/// address, or a CIDR per entry, committed in OutboundHttp:AllowedInternalDestinations. A host entry admits
+/// whatever that exact name resolves to (no implicit subdomains), and only that name's virtual host — the request is
+/// addressed to its URL's authority, never an author-written Host; an address or CIDR entry admits the addresses
+/// inside it, whatever name reached them. Empty by default: nothing internal is reachable until an operator commits it.
+///
+/// Parsing is strict. An entry that is none of the three fails the setting — and so the host's boot — instead of
+/// being skipped, because a skipped entry is a destination the operator believes is open and is not, found only when a
+/// workflow fails.
+///
+public sealed class OutboundDestinationAllowlist
+{
+ public static readonly OutboundDestinationAllowlist Empty = new(new HashSet(StringComparer.OrdinalIgnoreCase), Array.Empty());
+
+ private readonly IReadOnlySet _hosts;
+ private readonly IReadOnlyList _networks;
+
+ private OutboundDestinationAllowlist(IReadOnlySet hosts, IReadOnlyList networks)
+ {
+ _hosts = hosts;
+ _networks = networks;
+ }
+
+ public static OutboundDestinationAllowlist Parse(IEnumerable entries)
+ {
+ var hosts = new HashSet(StringComparer.OrdinalIgnoreCase);
+ var networks = new List();
+
+ foreach (var entry in entries.Where(e => !string.IsNullOrWhiteSpace(e)).Select(e => e.Trim()))
+ {
+ if (TryParseNetwork(entry) is { } network)
+ {
+ networks.Add(network);
+ continue;
+ }
+
+ hosts.Add(ParseHostName(entry));
+ }
+
+ return new OutboundDestinationAllowlist(hosts, networks);
+ }
+
+ /// Whether , reached through , is an operator-admitted destination.
+ public bool Admits(string host, IPAddress address)
+ {
+ var normalized = address.IsIPv4MappedToIPv6 ? address.MapToIPv4() : address;
+
+ return _hosts.Contains(host) || _networks.Any(network => network.Contains(normalized));
+ }
+
+ private static IPNetwork? TryParseNetwork(string entry)
+ {
+ if (IPAddress.TryParse(entry, out var address)) return new IPNetwork(address, address.GetAddressBytes().Length * 8);
+
+ if (!entry.Contains('/')) return null;
+
+ if (IPNetwork.TryParse(entry, out var network)) return network;
+
+ throw Malformed(entry);
+ }
+
+ private static string ParseHostName(string entry)
+ {
+ if (Uri.CheckHostName(entry) != UriHostNameType.Dns)
+ throw Malformed(entry);
+
+ return entry;
+ }
+
+ private static InvalidOperationException Malformed(string entry) =>
+ new($"'{entry}' in {OutboundHttpAllowlistSetting.ConfigurationKey} is not a host name, an IP address or a CIDR range (for example internal-api.corp, 10.1.2.3 or 10.0.0.0/8). Fix or remove the entry; it is refused rather than ignored so a destination you believe is open is not silently closed.");
+}
diff --git a/backend/src/CodeSpace.Core/Services/OutboundHttp/OutboundDestinationGuard.cs b/backend/src/CodeSpace.Core/Services/OutboundHttp/OutboundDestinationGuard.cs
new file mode 100644
index 000000000..54b9ab3b0
--- /dev/null
+++ b/backend/src/CodeSpace.Core/Services/OutboundHttp/OutboundDestinationGuard.cs
@@ -0,0 +1,100 @@
+using System.Net;
+using System.Net.Sockets;
+
+namespace CodeSpace.Core.Services.OutboundHttp;
+
+///
+/// The destination check for workflow-driven outbound HTTP. As a it
+/// resolves the host the handler is about to connect to, keeps only the addresses that are public or operator-admitted,
+/// and dials EXACTLY those — so the check and the connection can never disagree about where the socket goes.
+///
+/// Checking at connect time rather than on the URL is what makes it hold. Every spelling of an address
+/// (2130706433, 0x7f000001, 127.1, [::ffff:127.0.0.1]) has already been reduced to the
+/// address being dialled; every redirect hop opens its own connection through this same callback; and a name whose
+/// DNS answer changes between a check and the connect (rebinding) has no gap to exploit, because there is one
+/// resolution and it is the one dialled. A name answering with both public and internal addresses is dialled at its
+/// public ones only.
+///
+/// The operator's proxy (; in production , i.e.
+/// HTTP(S)_PROXY / NO_PROXY or the OS setting — what the node's client honoured before this guard existed) is kept, so a
+/// worker that reaches the internet only through it still can. A connection TO that proxy is the operator's own
+/// infrastructure and is dialled unchecked; the destination of a request it carries is invisible here, so
+/// checks it per request through before it is sent. That
+/// check resolves the name on the worker — a name the worker cannot resolve fails as a lookup failure, since it cannot
+/// be judged — and the proxy resolves it again, so a name whose answer changes in between is the one window this guard
+/// does not close; the proxy's own egress rules are the backstop there. A destination the proxy bypasses connects
+/// directly, through this callback, with no such window.
+///
+public sealed class OutboundDestinationGuard
+{
+ private readonly OutboundDestinationAllowlist _allowlist;
+ private readonly Func> _resolve;
+
+ /// Name resolution; the system resolver unless a test stubs it.
+ public OutboundDestinationGuard(OutboundDestinationAllowlist allowlist, IWebProxy proxy, Func>? resolve = null)
+ {
+ _allowlist = allowlist;
+ _resolve = resolve ?? Dns.GetHostAddressesAsync;
+ Proxy = proxy;
+ }
+
+ /// The proxy the guarded handler sends through; applies it.
+ public IWebProxy Proxy { get; }
+
+ public async ValueTask ConnectAsync(SocketsHttpConnectionContext context, CancellationToken cancellationToken)
+ {
+ var endpoint = context.DnsEndPoint;
+
+ var addresses = IsProxyEndpoint(endpoint, context.InitialRequestMessage.RequestUri)
+ ? await ResolveAsync(endpoint.Host, cancellationToken).ConfigureAwait(false)
+ : await PermitAsync(endpoint.Host, cancellationToken).ConfigureAwait(false);
+
+ return await DialAsync(addresses, endpoint.Port, cancellationToken).ConfigureAwait(false);
+ }
+
+ /// The addresses may be dialled at, resolved now; throws when none is public or admitted.
+ public async Task PermitAsync(string host, CancellationToken cancellationToken) => Permit(host, await ResolveAsync(host, cancellationToken).ConfigureAwait(false));
+
+ /// The subset of that may be dialled at; throws when nothing is left.
+ public IPAddress[] Permit(string host, IReadOnlyList resolved)
+ {
+ var permitted = resolved.Where(address => PublicAddress.IsPublic(address) || _allowlist.Admits(host, address)).ToArray();
+
+ if (permitted.Length == 0)
+ throw new OutboundDestinationRefusedException(host);
+
+ return permitted;
+ }
+
+ /// The proxy a request to is sent through, or null when it connects directly — the same lookup makes.
+ public Uri? ProxyFor(Uri destination) => Proxy.IsBypassed(destination) ? null : Proxy.GetProxy(destination);
+
+ /// Whether this connection is the handler opening one to the proxy that carries its request, rather than to a destination.
+ private bool IsProxyEndpoint(DnsEndPoint endpoint, Uri? request) => request != null && ProxyFor(request) is { } proxy && proxy.Port == endpoint.Port && string.Equals(proxy.IdnHost, endpoint.Host, StringComparison.OrdinalIgnoreCase);
+
+ /// An address literal (bracketed IPv6 included) is its own resolution; a name goes to DNS. A lookup failure propagates as it always did, so an unknown host still reads as one.
+ private async Task ResolveAsync(string host, CancellationToken cancellationToken)
+ {
+ if (IPAddress.TryParse(host, out var literal)) return new[] { literal };
+
+ return await _resolve(host, cancellationToken).ConfigureAwait(false);
+ }
+
+ /// The handler's own default dial, restricted to the given addresses: a dual-mode socket tries each in order until one connects.
+ private static async ValueTask DialAsync(IPAddress[] addresses, int port, CancellationToken cancellationToken)
+ {
+ var socket = new Socket(SocketType.Stream, ProtocolType.Tcp) { NoDelay = true };
+
+ try
+ {
+ await socket.ConnectAsync(addresses, port, cancellationToken).ConfigureAwait(false);
+
+ return new NetworkStream(socket, ownsSocket: true);
+ }
+ catch
+ {
+ socket.Dispose();
+ throw;
+ }
+ }
+}
diff --git a/backend/src/CodeSpace.Core/Services/OutboundHttp/OutboundDestinationRefusedException.cs b/backend/src/CodeSpace.Core/Services/OutboundHttp/OutboundDestinationRefusedException.cs
new file mode 100644
index 000000000..f29e06060
--- /dev/null
+++ b/backend/src/CodeSpace.Core/Services/OutboundHttp/OutboundDestinationRefusedException.cs
@@ -0,0 +1,23 @@
+using CodeSpace.Core.Settings.OutboundHttp;
+using CodeSpace.Messages.Failures;
+
+namespace CodeSpace.Core.Services.OutboundHttp;
+
+///
+/// A guarded connection refused because its host resolved to no address the guard may dial. Thrown from the handler's
+/// connect callback, so the caller sees it as the inner exception of the the request
+/// fails with. The message names the host and the setting that admits it, never the addresses it resolved to: it is
+/// shown to the run viewer, and echoing an internal address back would make the guard a resolver for the worker's DNS.
+///
+public sealed class OutboundDestinationRefusedException : Exception, IFailure
+{
+ public OutboundDestinationRefusedException(string host)
+ : base($"Refused to connect to '{host}': it does not resolve to a public address. Workflow HTTP calls may not reach the worker's loopback, link-local, private or metadata addresses; an operator admits an intentional internal destination by committing its host or CIDR to {OutboundHttpAllowlistSetting.ConfigurationKey}.")
+ {
+ }
+
+ /// Well-formed and understood, but blocked by the destination rule: the URL or the operator's allowlist must change, and a retry cannot help.
+ public FailureKind Kind => FailureKind.Unprocessable;
+
+ public string Code => FailureCodes.OutboundDestinationRefused;
+}
diff --git a/backend/src/CodeSpace.Core/Services/OutboundHttp/PublicAddress.cs b/backend/src/CodeSpace.Core/Services/OutboundHttp/PublicAddress.cs
new file mode 100644
index 000000000..3c164daef
--- /dev/null
+++ b/backend/src/CodeSpace.Core/Services/OutboundHttp/PublicAddress.cs
@@ -0,0 +1,55 @@
+using System.Net;
+using System.Net.Sockets;
+using CodeSpace.Core.Services.Agents.Sandbox.Isolation;
+
+namespace CodeSpace.Core.Services.OutboundHttp;
+
+///
+/// Whether an address is a GLOBALLY ROUTABLE destination — the only kind workflow-driven outbound HTTP may dial
+/// without an operator saying otherwise. Everything else reaches the worker itself: its loopback services, its cloud
+/// metadata endpoint (169.254.169.254, fd00:ec2::254, Aliyun's 100.100.100.200), or the network it sits on.
+///
+/// IPv4 reuses the sandbox egress predicate (), so the
+/// two outbound surfaces cannot drift apart. IPv6 is global unicast (2000::/3) minus documentation and Teredo;
+/// that excludes ::, ::1, link-local, site-local, unique-local and multicast in one test. An IPv6 address
+/// that EMBEDS an IPv4 one — v4-mapped, v4-compatible, NAT64 64:ff9b::/96, 6to4 2002::/16 — is judged by
+/// the IPv4 address inside it: a dual-stack socket dials ::ffff:127.0.0.1 straight to 127.0.0.1, and an
+/// IPv6-only cluster reaches public IPv4 hosts through NAT64, which must keep working.
+///
+public static class PublicAddress
+{
+ private static readonly byte[] MappedPrefix = { 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0xff, 0xff };
+ private static readonly byte[] CompatiblePrefix = { 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 };
+ private static readonly byte[] Nat64Prefix = { 0, 0x64, 0xff, 0x9b, 0, 0, 0, 0, 0, 0, 0, 0 };
+ private static readonly byte[] SixToFourPrefix = { 0x20, 0x02 };
+ private static readonly byte[] DocumentationPrefix = { 0x20, 0x01, 0x0d, 0xb8 };
+ private static readonly byte[] TeredoPrefix = { 0x20, 0x01, 0, 0 };
+
+ public static bool IsPublic(IPAddress address) => address.AddressFamily switch
+ {
+ AddressFamily.InterNetwork => EgressHostResolver.IsGloballyRoutableIpv4(address),
+ AddressFamily.InterNetworkV6 => IsPublicIpv6(address.GetAddressBytes()),
+ _ => false,
+ };
+
+ private static bool IsPublicIpv6(byte[] bytes)
+ {
+ if (EmbeddedIpv4(bytes) is { } embedded) return EgressHostResolver.IsGloballyRoutableIpv4(embedded);
+
+ return IsGlobalUnicast(bytes) && !StartsWith(bytes, DocumentationPrefix) && !StartsWith(bytes, TeredoPrefix);
+ }
+
+ /// The IPv4 address an IPv6 address carries, when it is one of the forms that route to that IPv4 address; null otherwise. :: and ::1 fall in the v4-compatible block and come out as 0.0.0.0 / 0.0.0.1, which the IPv4 check refuses.
+ private static IPAddress? EmbeddedIpv4(byte[] bytes)
+ {
+ if (StartsWith(bytes, MappedPrefix) || StartsWith(bytes, CompatiblePrefix) || StartsWith(bytes, Nat64Prefix)) return new IPAddress(bytes[12..16]);
+
+ if (StartsWith(bytes, SixToFourPrefix)) return new IPAddress(bytes[2..6]);
+
+ return null;
+ }
+
+ private static bool IsGlobalUnicast(byte[] bytes) => (bytes[0] & 0xe0) == 0x20;
+
+ private static bool StartsWith(byte[] bytes, byte[] prefix) => bytes.AsSpan().StartsWith(prefix);
+}
diff --git a/backend/src/CodeSpace.Core/Services/Workflows/Nodes/Builtin/HttpRequestNode.cs b/backend/src/CodeSpace.Core/Services/Workflows/Nodes/Builtin/HttpRequestNode.cs
index ac8683300..e0ae4285f 100644
--- a/backend/src/CodeSpace.Core/Services/Workflows/Nodes/Builtin/HttpRequestNode.cs
+++ b/backend/src/CodeSpace.Core/Services/Workflows/Nodes/Builtin/HttpRequestNode.cs
@@ -2,6 +2,7 @@
using System.Net.Http.Headers;
using System.Text;
using System.Text.Json;
+using CodeSpace.Core.Services.OutboundHttp;
using CodeSpace.Messages.Enums;
using Microsoft.Extensions.Logging;
@@ -25,6 +26,13 @@ namespace CodeSpace.Core.Services.Workflows.Nodes.Builtin;
/// ok — true iff 2xx
/// body — response body (parsed JSON when parseJson and content-type matches, else string)
/// headers — response headers as object
+///
+/// The URL is whatever its template resolves to — often trigger text or an upstream node's output — so the node's
+/// client is the guarded one (): it reaches only public addresses or ones an
+/// operator committed to OutboundHttp:AllowedInternalDestinations, directly or through the worker's
+/// HTTP(S)_PROXY; it sends no cookies but the ones the author writes, and no author Host header; a redirect to
+/// another origin — an http→https move on the same host included — carries none of the author's headers; and a
+/// redirect that would carry the body to another origin is returned as the node's 3xx rather than followed.
///
public sealed class HttpRequestNode : INodeRuntime
{
@@ -43,7 +51,7 @@ public sealed class HttpRequestNode : INodeRuntime
Category = "Tools",
Kind = NodeKind.Regular,
IconKey = "globe",
- Description = "Make an HTTP request to any URL. Universal escape hatch when no bespoke node exists.",
+ Description = "Make an HTTP request to any public URL. Universal escape hatch when no bespoke node exists. Internal addresses (loopback, private, cloud metadata) are refused unless an operator allowlists them. A redirect to another origin, http→https included, arrives without your headers — write the final URL to keep them — and one that would send your body to another origin comes back as the 3xx.",
// HTTP requests with mutating verbs (POST/PUT/PATCH/DELETE) are side-effecting. GET
// is technically safe to retry, but the manifest is a node-level marker and the
// operator can't promise which verb their template will pick. Mark the whole node
@@ -153,6 +161,11 @@ public async Task RunAsync(NodeRunContext context, CancellationToken
// (Observability already emitted external_call.failed; re-translate to NodeResult.Fail.)
return NodeResult.Fail($"HTTP request timed out after {timeoutSeconds}s.");
}
+ catch (HttpRequestException ex) when (ex.InnerException is OutboundDestinationRefusedException)
+ {
+ // Refused by destination policy: deterministic, so a retry policy must not spend attempts on it.
+ return NodeResult.Fail($"HTTP request failed: {ex.Message}", retryable: false);
+ }
catch (HttpRequestException ex)
{
return NodeResult.Fail($"HTTP request failed: {ex.Message}");
diff --git a/backend/src/CodeSpace.Core/Settings/OutboundHttp/OutboundHttpAllowlistSetting.cs b/backend/src/CodeSpace.Core/Settings/OutboundHttp/OutboundHttpAllowlistSetting.cs
new file mode 100644
index 000000000..8fc42ce88
--- /dev/null
+++ b/backend/src/CodeSpace.Core/Settings/OutboundHttp/OutboundHttpAllowlistSetting.cs
@@ -0,0 +1,26 @@
+using CodeSpace.Core.Services.OutboundHttp;
+using Microsoft.Extensions.Configuration;
+
+namespace CodeSpace.Core.Settings.OutboundHttp;
+
+///
+/// The operator's committed allowlist of INTERNAL destinations workflow-driven outbound HTTP (the http.request
+/// node) may reach — host names, addresses and CIDR ranges that the destination guard would otherwise refuse as
+/// loopback, private, link-local or otherwise non-public. Read once, while the host is being built
+/// (GuardedHttpClientRegistration.AddGuardedHttpClient), so a malformed entry stops the host from starting. It is
+/// a list in appsettings, changed by committing a value, not a switch: the guard itself cannot be turned off.
+///
+/// The key's literal value is pinned by a unit test: renaming it would silently empty every deployment's
+/// allowlist that used the old name, and their intentional internal targets would start failing.
+///
+public class OutboundHttpAllowlistSetting : IConfigurationSetting
+{
+ public const string ConfigurationKey = "OutboundHttp:AllowedInternalDestinations";
+
+ public OutboundHttpAllowlistSetting(IConfiguration configuration)
+ {
+ Value = OutboundDestinationAllowlist.Parse(configuration.GetSection(ConfigurationKey).Get() ?? Array.Empty());
+ }
+
+ public OutboundDestinationAllowlist Value { get; set; }
+}
diff --git a/backend/src/CodeSpace.Messages/Failures/FailureCodes.cs b/backend/src/CodeSpace.Messages/Failures/FailureCodes.cs
index a9f1214f3..390642e98 100644
--- a/backend/src/CodeSpace.Messages/Failures/FailureCodes.cs
+++ b/backend/src/CodeSpace.Messages/Failures/FailureCodes.cs
@@ -69,6 +69,9 @@ public static class FailureCodes
public const string RerunUpstreamNotReusable = "rerun_upstream_not_reusable";
public const string PackImportFailed = "pack_import_failed";
+ /// A workflow's outbound HTTP call was refused at connect time because its host resolves only to loopback, private, link-local, metadata or other non-public addresses. Remedy: point the call at a public destination, or have an operator commit the host or CIDR to OutboundHttp:AllowedInternalDestinations — a retry cannot help.
+ public const string OutboundDestinationRefused = "outbound_destination_refused";
+
/// A settled cell's outputs were redacted for persistence and their encrypted recovery payload is not readable, so the originals exist nowhere. Remedy: re-run the workflow from that node — a resume can only ever offer the redaction placeholder.
public const string WorkflowOutputsUnrecoverable = "workflow_outputs_unrecoverable";
diff --git a/backend/tests/CodeSpace.E2ETests/Workflows/HttpRequestClientHostE2ETests.cs b/backend/tests/CodeSpace.E2ETests/Workflows/HttpRequestClientHostE2ETests.cs
new file mode 100644
index 000000000..f295a4c84
--- /dev/null
+++ b/backend/tests/CodeSpace.E2ETests/Workflows/HttpRequestClientHostE2ETests.cs
@@ -0,0 +1,63 @@
+using CodeSpace.Core.Services.OutboundHttp;
+using CodeSpace.Core.Services.Workflows.Nodes.Builtin;
+using CodeSpace.Core.Settings.OutboundHttp;
+using CodeSpace.E2ETests.Infrastructure;
+using CodeSpace.IntegrationTests.Workflows.Infrastructure;
+using Microsoft.AspNetCore.Hosting;
+using Microsoft.Extensions.Configuration;
+using Microsoft.Extensions.DependencyInjection;
+using Shouldly;
+
+namespace CodeSpace.E2ETests.Workflows;
+
+///
+/// HIGH fidelity (Rule 12): the http.request client as the REAL host registers it — Program.CreateHostBuilder +
+/// Startup, the API's committed appsettings.json, real sockets against a loopback responder. Every other
+/// tier builds its own registration, so none of them would notice the host losing its AddGuardedHttpClient line
+/// (a merge-conflict casualty in a busy file): CreateClient(nameof(HttpRequestNode)) would fall back to the
+/// default client — redirects followed, no address check — and every other test would stay green. These pin that
+/// registration, the committed empty allowlist, and that the allowlist is read while the host is being built.
+///
+[Trait("Category", "E2E")]
+[Trait("Surface", "Http")]
+public sealed class HttpRequestClientHostE2ETests : IClassFixture
+{
+ private const string MetadataPath = "/latest/meta-data/iam/security-credentials/role";
+
+ private readonly WebhookApiFactory _factory;
+
+ public HttpRequestClientHostE2ETests(WebhookApiFactory factory) { _factory = factory; }
+
+ [Theory]
+ [InlineData("127.0.0.1")]
+ [InlineData("localhost")]
+ public async Task The_hosts_http_request_client_refuses_the_workers_loopback(string host)
+ {
+ await using var metadata = LoopbackResponder.Start(_ => LoopbackResponder.Ok("{\"Token\":\"FAKE-LOOPBACK-SECRET\"}"));
+ using var client = _factory.Services.GetRequiredService().CreateClient(nameof(HttpRequestNode));
+ client.Timeout = TimeSpan.FromSeconds(10);
+
+ var ex = await Should.ThrowAsync(() => client.GetAsync($"http://{host}:{metadata.Port}{MetadataPath}"));
+
+ ex.InnerException.ShouldBeOfType(customMessage: $"the host's '{nameof(HttpRequestNode)}' client did not refuse http://{host} through the destination guard ({ex.Message}). Check Startup.ConfigureServices still calls AddGuardedHttpClient for it, and that CodeSpace.Api/appsettings.json commits an empty {OutboundHttpAllowlistSetting.ConfigurationKey}.");
+ metadata.Accepted.ShouldBe(0, $"no socket may reach the worker's loopback through http://{host}");
+ }
+
+ [Fact]
+ public void A_malformed_committed_entry_stops_the_host_from_starting()
+ {
+ // Built WITHOUT the fixture's database: the host must fail while its services are being registered, before
+ // anything would touch one.
+ using var unbooted = new WebhookApiFactory();
+ using var factory = unbooted.WithWebHostBuilder(builder => builder.ConfigureAppConfiguration((_, configuration) => configuration.AddInMemoryCollection(new Dictionary { [$"{OutboundHttpAllowlistSetting.ConfigurationKey}:0"] = "internal-api.corp:8080" })));
+
+ var ex = Should.Throw(() => factory.Services);
+
+ Causes(ex).ShouldContain(cause => cause is InvalidOperationException && cause.Message.Contains("internal-api.corp:8080"), customMessage: $"the host must refuse to start on a malformed {OutboundHttpAllowlistSetting.ConfigurationKey} entry, naming it — otherwise it is found only when every http.request call fails at run time. Got: {ex}");
+ }
+
+ private static IEnumerable Causes(Exception ex)
+ {
+ for (Exception? cause = ex; cause != null; cause = cause.InnerException) yield return cause;
+ }
+}
diff --git a/backend/tests/CodeSpace.IntegrationTests/Infrastructure/PostgresFixture.cs b/backend/tests/CodeSpace.IntegrationTests/Infrastructure/PostgresFixture.cs
index 0cc358224..c470b79d5 100644
--- a/backend/tests/CodeSpace.IntegrationTests/Infrastructure/PostgresFixture.cs
+++ b/backend/tests/CodeSpace.IntegrationTests/Infrastructure/PostgresFixture.cs
@@ -3,6 +3,7 @@
using Autofac.Extensions.DependencyInjection;
using CodeSpace.Core;
using CodeSpace.Core.Persistence.Db;
+using CodeSpace.Core.Services.OutboundHttp;
using CodeSpace.Core.Settings;
using CodeSpace.IntegrationTests.Settings;
using MediatR;
@@ -198,6 +199,9 @@ private Autofac.IContainer BuildIocContainer(string connectionString)
// is unaffected; a longer timeout is inert for any client that never makes a real slow call.
services.AddHttpClient(nameof(CodeSpace.Core.Services.Workflows.Llm.Anthropic.AnthropicClient), c => c.Timeout = TimeSpan.FromSeconds(180));
services.AddHttpClient(nameof(CodeSpace.Core.Services.Workflows.Llm.OpenAi.OpenAiClient), c => c.Timeout = TimeSpan.FromSeconds(180));
+ // The production http.request client, so every engine test that runs the node goes through the destination
+ // guard. The suite's committed allowlist (appsettings.json) admits 127.0.0.1 for its loopback responders.
+ services.AddGuardedHttpClient(nameof(CodeSpace.Core.Services.Workflows.Nodes.Builtin.HttpRequestNode), configuration);
var builder = new ContainerBuilder();
builder.Populate(services);
diff --git a/backend/tests/CodeSpace.IntegrationTests/Workflows/HttpRequestDestinationFlowTests.cs b/backend/tests/CodeSpace.IntegrationTests/Workflows/HttpRequestDestinationFlowTests.cs
new file mode 100644
index 000000000..9355a2042
--- /dev/null
+++ b/backend/tests/CodeSpace.IntegrationTests/Workflows/HttpRequestDestinationFlowTests.cs
@@ -0,0 +1,210 @@
+using System.Net;
+using System.Net.Sockets;
+using System.Text.Json;
+using Autofac;
+using CodeSpace.Core.Persistence.Db;
+using CodeSpace.Core.Services.OutboundHttp;
+using CodeSpace.Core.Services.Workflows.Engine;
+using CodeSpace.Core.Services.Workflows.Nodes.Builtin;
+using CodeSpace.Core.Settings.OutboundHttp;
+using CodeSpace.IntegrationTests.Infrastructure;
+using CodeSpace.IntegrationTests.Workflows.Infrastructure;
+using CodeSpace.Messages.Constants;
+using CodeSpace.Messages.Dtos.Workflows;
+using CodeSpace.Messages.Enums;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.Configuration;
+using Microsoft.Extensions.DependencyInjection;
+using Shouldly;
+
+namespace CodeSpace.IntegrationTests.Workflows;
+
+///
+/// http.request's destination guard through the whole engine: a workflow created by the mediator, a run seeded with
+/// an untrusted trigger payload, resolving {{trigger.body}} into
+/// the URL, the real sending through the PRODUCTION guarded-client registration, and the
+/// outcome read back from workflow_run_node and the ledger. The responders are raw loopback sockets that count
+/// accepted connections, standing in for a metadata endpoint or an admin port on the worker.
+///
+/// Each test runs the engine in a child scope whose carries its own committed
+/// allowlist (the fixture's root admits 127.0.0.1 for the suite's other loopback-calling tests), so a refusal here is
+/// the guard's and not an artefact of the suite's configuration. The proxy rows build their guard with a loopback
+/// responder as the operator's proxy and a stubbed resolver, so no lookup or connection leaves the machine.
+///
+[Collection(PostgresCollection.Name)]
+[Trait("Category", "Integration")]
+public class HttpRequestDestinationFlowTests
+{
+ private const string MetadataPath = "/latest/meta-data/iam/security-credentials/role";
+ private const string FakeCredential = "{\"AccessKeyId\":\"FAKE-AKID\",\"Token\":\"FAKE-LOOPBACK-SECRET\"}";
+
+ private readonly PostgresFixture _fixture;
+
+ public HttpRequestDestinationFlowTests(PostgresFixture fixture) { _fixture = fixture; }
+
+ [Theory]
+ [InlineData("127.0.0.1")]
+ [InlineData("localhost")]
+ [InlineData("2130706433")]
+ [InlineData("[::1]")]
+ [InlineData("[::ffff:127.0.0.1]")]
+ public async Task A_loopback_url_built_from_trigger_text_fails_the_node_without_reaching_loopback(string host)
+ {
+ await using var metadata = LoopbackResponder.Start(_ => LoopbackResponder.Ok(FakeCredential));
+
+ var runId = await RunHttpWorkflowAsync("{{trigger.body}}", $"http://{host}:{metadata.Port}{MetadataPath}");
+
+ var node = await ReadCallNodeAsync(runId);
+ node.Status.ShouldBe(NodeStatus.Failure);
+ node.Error.ShouldNotBeNull().ShouldContain("Refused to connect");
+ node.OutputsJson.ShouldNotContain("FAKE-LOOPBACK-SECRET");
+
+ (await ReadExternalCallErrorAsync(runId)).ShouldContain("Refused to connect", customMessage: "the ledger's external_call.failed must say the destination was refused, so the run-detail page explains the failure");
+ metadata.Accepted.ShouldBe(0, $"no socket may reach the loopback responder through http://{host}");
+ }
+
+ [Fact]
+ public async Task A_redirect_to_loopback_from_an_allowlisted_host_fails_the_node_at_the_redirect_hop()
+ {
+ await using var metadata = LoopbackResponder.Start(_ => LoopbackResponder.Ok(FakeCredential));
+ await using var front = LoopbackResponder.Start(_ => LoopbackResponder.Redirect($"http://127.0.0.1:{metadata.Port}{MetadataPath}"));
+
+ var runId = await RunHttpWorkflowAsync($"http://localhost:{front.Port}/{{{{trigger.body}}}}", "feature-x", "localhost");
+
+ var node = await ReadCallNodeAsync(runId);
+ node.Status.ShouldBe(NodeStatus.Failure);
+ node.Error.ShouldNotBeNull().ShouldContain("Refused to connect to '127.0.0.1'");
+
+ front.Requests.Count.ShouldBe(1, "the allowlisted first hop was made");
+ metadata.Accepted.ShouldBe(0, "the redirect target is loopback and not allowlisted — no socket may reach it");
+ }
+
+ [Fact]
+ public async Task An_allowlisted_internal_destination_still_answers_in_full()
+ {
+ await using var service = LoopbackResponder.Start(_ => LoopbackResponder.Ok(FakeCredential));
+
+ var runId = await RunHttpWorkflowAsync("{{trigger.body}}", $"http://127.0.0.1:{service.Port}{MetadataPath}", "127.0.0.0/8");
+
+ var node = await ReadCallNodeAsync(runId);
+ node.Status.ShouldBe(NodeStatus.Success, node.Error);
+
+ var outputs = JsonDocument.Parse(node.OutputsJson).RootElement;
+ outputs.GetProperty("status").GetInt32().ShouldBe(200);
+ outputs.GetProperty("body").GetProperty("Token").GetString().ShouldBe("FAKE-LOOPBACK-SECRET", "an operator-admitted destination's response reaches the node outputs as before");
+ service.Accepted.ShouldBe(1);
+ }
+
+ [Fact]
+ public async Task A_public_destination_reached_through_the_operators_proxy_answers_in_full()
+ {
+ // A worker whose only route out is HTTP(S)_PROXY: the public-resolving name is checked on the worker, the proxy
+ // fetches it, and the answer reaches the node outputs. 203.0.113.10 is TEST-NET-3 — public to the guard, routed
+ // nowhere — so nothing here could reach a real host even if the proxy were bypassed.
+ await using var proxy = LoopbackResponder.Start(_ => LoopbackResponder.Ok("{\"via\":\"operator-proxy\"}"));
+
+ var runId = await RunHttpWorkflowAsync("{{trigger.body}}", "http://api.example.test/v1/items", ProxiedGuard(proxy));
+
+ var node = await ReadCallNodeAsync(runId);
+ node.Status.ShouldBe(NodeStatus.Success, node.Error);
+ JsonDocument.Parse(node.OutputsJson).RootElement.GetProperty("body").GetProperty("via").GetString().ShouldBe("operator-proxy");
+ proxy.Requests.Single().ShouldStartWith("GET http://api.example.test/v1/items HTTP/1.1");
+ }
+
+ [Fact]
+ public async Task An_internal_destination_the_proxy_would_carry_fails_the_node_before_the_proxy_is_asked()
+ {
+ await using var proxy = LoopbackResponder.Start(_ => LoopbackResponder.Ok(FakeCredential));
+
+ var runId = await RunHttpWorkflowAsync("{{trigger.body}}", $"http://metadata.example.test{MetadataPath}", ProxiedGuard(proxy));
+
+ var node = await ReadCallNodeAsync(runId);
+ node.Status.ShouldBe(NodeStatus.Failure);
+ node.Error.ShouldNotBeNull().ShouldContain("Refused to connect to 'metadata.example.test'");
+ node.OutputsJson.ShouldNotContain("FAKE-LOOPBACK-SECRET");
+ proxy.Accepted.ShouldBe(0, "the proxy must never be asked to fetch the metadata address");
+ }
+
+ // ─── Helpers ────────────────────────────────────────────────────────────────
+
+ /// The production registration from a committed allowlist of , as the host builds it.
+ private Task RunHttpWorkflowAsync(string urlTemplate, string triggerBody, params string[] allowed) => RunHttpWorkflowAsync(urlTemplate, triggerBody, services => services.AddGuardedHttpClient(nameof(HttpRequestNode), CommittedAllowlist(allowed)));
+
+ private Task RunHttpWorkflowAsync(string urlTemplate, string triggerBody, OutboundDestinationGuard guard) => RunHttpWorkflowAsync(urlTemplate, triggerBody, services => services.AddGuardedHttpClient(nameof(HttpRequestNode), guard));
+
+ private async Task RunHttpWorkflowAsync(string urlTemplate, string triggerBody, Action registerGuardedClient)
+ {
+ var (teamId, userId) = await WorkflowsTestSeed.SeedTeamAsync(_fixture);
+ var workflowId = await CreateHttpWorkflowAsync(teamId, userId, urlTemplate);
+ var runId = await WorkflowsTestSeed.SeedManualRunAsync(_fixture, workflowId, teamId, payloadJson: JsonSerializer.Serialize(new { body = triggerBody }));
+
+ var services = new ServiceCollection();
+ registerGuardedClient(services);
+
+ await using var guarded = services.BuildServiceProvider();
+ using var scope = _fixture.BeginScope(b => b.RegisterInstance(guarded.GetRequiredService()).As());
+
+ await scope.Resolve().ExecuteRunAsync(runId, CancellationToken.None);
+
+ return runId;
+ }
+
+ private static IConfiguration CommittedAllowlist(string[] allowed) => new ConfigurationBuilder()
+ .AddInMemoryCollection(allowed.Select((entry, i) => KeyValuePair.Create($"{OutboundHttpAllowlistSetting.ConfigurationKey}:{i}", (string?)entry)))
+ .Build();
+
+ /// Nothing allowlisted; as the operator's HTTP(S)_PROXY; two stubbed names, one public and one the metadata address.
+ private static OutboundDestinationGuard ProxiedGuard(LoopbackResponder proxy) => new(OutboundDestinationAllowlist.Empty, new WebProxy($"http://127.0.0.1:{proxy.Port}"), (host, _) => host switch
+ {
+ "api.example.test" => Task.FromResult(new[] { IPAddress.Parse("203.0.113.10") }),
+ "metadata.example.test" => Task.FromResult(new[] { IPAddress.Parse("169.254.169.254") }),
+ _ => Task.FromException(new SocketException((int)SocketError.HostNotFound)),
+ });
+
+ private async Task CreateHttpWorkflowAsync(Guid teamId, Guid userId, string urlTemplate)
+ {
+ using var scope = _fixture.BeginScopeAs(userId, teamId, Roles.Admin);
+ var mediator = scope.Resolve();
+
+ var def = new WorkflowDefinition
+ {
+ SchemaVersion = 1,
+ Nodes = new List
+ {
+ new() { Id = "start", TypeKey = "trigger.pr.opened", Config = WorkflowsTestSeed.EmptyJson(), Inputs = WorkflowsTestSeed.EmptyJson() },
+ new() { Id = "call", TypeKey = "http.request", Config = WorkflowsTestSeed.Json("""{"timeoutSeconds":5}"""), Inputs = WorkflowsTestSeed.Json(JsonSerializer.Serialize(new { url = urlTemplate, method = "GET" })) },
+ new() { Id = "end", TypeKey = "builtin.terminal", Config = WorkflowsTestSeed.EmptyJson(), Inputs = WorkflowsTestSeed.EmptyJson() },
+ },
+ Edges = new List
+ {
+ new() { From = "start", To = "call" },
+ new() { From = "call", To = "end" },
+ },
+ };
+
+ return await mediator.Send(new CodeSpace.Messages.Commands.Workflows.CreateWorkflowCommand
+ {
+ Name = "http-destination-" + Guid.NewGuid().ToString("N")[..8],
+ Description = null,
+ Definition = def,
+ Activations = new List(),
+ Enabled = true,
+ });
+ }
+
+ private async Task ReadCallNodeAsync(Guid runId)
+ {
+ using var scope = _fixture.BeginScope();
+
+ return await scope.Resolve().WorkflowRunNode.AsNoTracking().SingleAsync(n => n.RunId == runId && n.NodeId == "call");
+ }
+
+ private async Task ReadExternalCallErrorAsync(Guid runId)
+ {
+ using var scope = _fixture.BeginScope();
+
+ var failed = await scope.Resolve().WorkflowRunRecord.AsNoTracking().SingleAsync(r => r.RunId == runId && r.RecordType == WorkflowRunRecordTypes.ExternalCallFailed);
+
+ return JsonDocument.Parse(failed.PayloadJson).RootElement.GetProperty("error").GetString() ?? "";
+ }
+}
diff --git a/backend/tests/CodeSpace.IntegrationTests/Workflows/Infrastructure/LoopbackResponder.cs b/backend/tests/CodeSpace.IntegrationTests/Workflows/Infrastructure/LoopbackResponder.cs
new file mode 100644
index 000000000..fd1c92261
--- /dev/null
+++ b/backend/tests/CodeSpace.IntegrationTests/Workflows/Infrastructure/LoopbackResponder.cs
@@ -0,0 +1,128 @@
+using System.Collections.Concurrent;
+using System.Net;
+using System.Net.Sockets;
+using System.Text;
+
+namespace CodeSpace.IntegrationTests.Workflows.Infrastructure;
+
+///
+/// A raw HTTP/1.1 responder on 127.0.0.1 AND ::1 at one ephemeral port, standing in for a service on the worker's own
+/// loopback (a metadata endpoint, an admin port). It counts ACCEPTED CONNECTIONS, not just requests, so a test can
+/// prove the guard refused before any socket reached it — a refused-at-connect destination and one that answered
+/// would otherwise look alike from the client. It accepts any Host header, so every spelling of loopback reaches it.
+///
+public sealed class LoopbackResponder : IAsyncDisposable
+{
+ private readonly List _listeners;
+ private readonly Func _respond;
+ private readonly CancellationTokenSource _stop = new();
+ private readonly ConcurrentQueue _requests = new();
+ private int _accepted;
+
+ private LoopbackResponder(List listeners, Func respond)
+ {
+ _listeners = listeners;
+ _respond = respond;
+ Port = ((IPEndPoint)listeners[0].LocalEndpoint).Port;
+
+ foreach (var listener in listeners) _ = AcceptLoopAsync(listener);
+ }
+
+ public int Port { get; }
+
+ public int Accepted => Volatile.Read(ref _accepted);
+
+ /// Each request's head (request line + headers) in arrival order.
+ public IReadOnlyCollection Requests => _requests;
+
+ /// Start a responder whose reply to each request head is 's raw HTTP response.
+ public static LoopbackResponder Start(Func respond)
+ {
+ var v4 = new TcpListener(IPAddress.Loopback, 0);
+ v4.Start();
+
+ return new LoopbackResponder(new List { v4 }.Concat(TryListenIpv6(((IPEndPoint)v4.LocalEndpoint).Port)).ToList(), respond);
+ }
+
+ public static string Ok(string body) => $"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {Encoding.UTF8.GetByteCount(body)}\r\nConnection: close\r\n\r\n{body}";
+
+ public static string Redirect(string location) => $"HTTP/1.1 302 Found\r\nLocation: {location}\r\nContent-Length: 0\r\nConnection: close\r\n\r\n";
+
+ /// The IPv6 twin on the same port, when this host has IPv6 loopback. Without it a [::1] request would fail with "connection refused" even if the guard let it through — hiding the very bug the test looks for.
+ private static IEnumerable TryListenIpv6(int port)
+ {
+ if (!Socket.OSSupportsIPv6) return Array.Empty();
+
+ try
+ {
+ var v6 = new TcpListener(IPAddress.IPv6Loopback, port);
+ v6.Start();
+ return new[] { v6 };
+ }
+ catch (SocketException)
+ {
+ return Array.Empty();
+ }
+ }
+
+ private async Task AcceptLoopAsync(TcpListener listener)
+ {
+ while (!_stop.IsCancellationRequested)
+ {
+ TcpClient client;
+
+ try { client = await listener.AcceptTcpClientAsync(_stop.Token).ConfigureAwait(false); }
+ catch (Exception) { return; }
+
+ Interlocked.Increment(ref _accepted);
+ _ = ServeAsync(client);
+ }
+ }
+
+ private async Task ServeAsync(TcpClient client)
+ {
+ using var _ = client;
+
+ try
+ {
+ var stream = client.GetStream();
+ var head = await ReadHeadAsync(stream).ConfigureAwait(false);
+
+ _requests.Enqueue(head);
+
+ await stream.WriteAsync(Encoding.UTF8.GetBytes(_respond(head)), _stop.Token).ConfigureAwait(false);
+ }
+ catch (Exception)
+ {
+ // A client that hung up mid-request is not this responder's failure to report.
+ }
+ }
+
+ private async Task ReadHeadAsync(NetworkStream stream)
+ {
+ var received = new StringBuilder();
+ var buffer = new byte[8192];
+
+ while (!received.ToString().Contains("\r\n\r\n"))
+ {
+ var read = await stream.ReadAsync(buffer, _stop.Token).ConfigureAwait(false);
+
+ if (read == 0) break;
+
+ received.Append(Encoding.ASCII.GetString(buffer, 0, read));
+ }
+
+ return received.ToString();
+ }
+
+ public ValueTask DisposeAsync()
+ {
+ _stop.Cancel();
+
+ foreach (var listener in _listeners) listener.Stop();
+
+ _stop.Dispose();
+
+ return ValueTask.CompletedTask;
+ }
+}
diff --git a/backend/tests/CodeSpace.IntegrationTests/appsettings.json b/backend/tests/CodeSpace.IntegrationTests/appsettings.json
index 9930e4e8d..302b12e49 100644
--- a/backend/tests/CodeSpace.IntegrationTests/appsettings.json
+++ b/backend/tests/CodeSpace.IntegrationTests/appsettings.json
@@ -3,6 +3,10 @@
"AdminConnectionString": "Host=localhost;Port=5432;Username=postgres;Password=123456;Database=postgres",
"TestDatabaseNamePrefix": "codespace_test_"
},
+ "OutboundHttp": {
+ "_doc": "The suite's http.request tests call loopback responders on 127.0.0.1, which the destination guard refuses unless an operator admits them. This is that admission for the test host only; the guard's own tests run under their own allowlists.",
+ "AllowedInternalDestinations": [ "127.0.0.1" ]
+ },
"Logging": {
"LogLevel": {
"Default": "Warning",
diff --git a/backend/tests/CodeSpace.UnitTests/Architecture/FailureTaxonomyTests.cs b/backend/tests/CodeSpace.UnitTests/Architecture/FailureTaxonomyTests.cs
index 50e78035a..9b30a0c89 100644
--- a/backend/tests/CodeSpace.UnitTests/Architecture/FailureTaxonomyTests.cs
+++ b/backend/tests/CodeSpace.UnitTests/Architecture/FailureTaxonomyTests.cs
@@ -84,6 +84,7 @@ public void The_wire_codes_are_pinned()
FailureCodes.RerunBlockedUnsupportedNode.ShouldBe("rerun_blocked_unsupported_node");
FailureCodes.RerunUpstreamNotReusable.ShouldBe("rerun_upstream_not_reusable");
FailureCodes.PackImportFailed.ShouldBe("pack_import_failed");
+ FailureCodes.OutboundDestinationRefused.ShouldBe("outbound_destination_refused");
FailureCodes.WorkflowOutputsUnrecoverable.ShouldBe("workflow_outputs_unrecoverable");
FailureCodes.StorageCredentialInvalid.ShouldBe("storage_credential_invalid");
FailureCodes.StorageCredentialConflict.ShouldBe("storage_credential_conflict");
diff --git a/backend/tests/CodeSpace.UnitTests/OutboundHttp/GuardedHttpClientTests.cs b/backend/tests/CodeSpace.UnitTests/OutboundHttp/GuardedHttpClientTests.cs
new file mode 100644
index 000000000..0178a0f14
--- /dev/null
+++ b/backend/tests/CodeSpace.UnitTests/OutboundHttp/GuardedHttpClientTests.cs
@@ -0,0 +1,324 @@
+using System.Net;
+using System.Net.Sockets;
+using System.Text.Json;
+using CodeSpace.Core.Services.OutboundHttp;
+using CodeSpace.Core.Services.Workflows.Nodes;
+using CodeSpace.Core.Services.Workflows.Nodes.Builtin;
+using CodeSpace.Core.Services.Workflows.Runtime;
+using CodeSpace.Core.Settings.OutboundHttp;
+using CodeSpace.Messages.Enums;
+using Microsoft.Extensions.Configuration;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Logging.Abstractions;
+using Shouldly;
+
+namespace CodeSpace.UnitTests.OutboundHttp;
+
+///
+/// The PRODUCTION registration () driven over real sockets against loopback
+/// responders — the shapes the SSRF audit proved reachable through http.request, now refused. Each refusal also asserts
+/// the responder ACCEPTED NO CONNECTION: a test that only checked for a failed request would pass just as well if the
+/// guard let the connection through and the request then failed for some other reason.
+///
+/// Each guard is built here with an explicit proxy — none, or a loopback responder playing the operator's proxy —
+/// so a developer's own HTTP(S)_PROXY cannot change what these assert. Names other than localhost go to a stubbed
+/// resolver (), so no lookup leaves the process.
+///
+[Trait("Category", "Unit")]
+public class GuardedHttpClientTests
+{
+ private const string MetadataPath = "/latest/meta-data/iam/security-credentials/role";
+ private const string FakeCredential = "{\"AccessKeyId\":\"FAKE-AKID\",\"Token\":\"FAKE-LOOPBACK-SECRET\"}";
+
+ [Theory]
+ [InlineData("localhost")]
+ [InlineData("127.0.0.1")]
+ [InlineData("2130706433")] // decimal
+ [InlineData("0x7f000001")] // hex
+ [InlineData("127.1")] // short form
+ [InlineData("0177.0.0.1")] // octal
+ [InlineData("0.0.0.0")] // "this host" — connects to loopback
+ [InlineData("[::1]")]
+ [InlineData("[::]")]
+ [InlineData("[::ffff:127.0.0.1]")] // v4-mapped
+ [InlineData("[0:0:0:0:0:ffff:7f00:1]")] // v4-mapped, long hex form
+ public async Task Every_spelling_of_loopback_is_refused_before_a_connection_is_made(string host)
+ {
+ await using var metadata = LoopbackResponder.Start(_ => LoopbackResponder.Ok(FakeCredential));
+ using var factory = GuardedFactory();
+
+ var ex = await Should.ThrowAsync(() => factory.Client.GetAsync($"http://{host}:{metadata.Port}{MetadataPath}"));
+
+ ex.InnerException.ShouldBeOfType($"http://{host} must be refused by the destination guard, not fail some other way: {ex.Message}");
+ metadata.Accepted.ShouldBe(0, $"no socket may reach the loopback responder through http://{host}");
+ }
+
+ [Theory]
+ [InlineData("127.0.0.0/8", "127.0.0.1")] // CIDR entry
+ [InlineData("localhost", "localhost")] // host entry: admits ::1 and 127.0.0.1 — the dial tries both
+ public async Task An_allowlisted_internal_destination_answers_in_full(string entry, string host)
+ {
+ await using var service = LoopbackResponder.Start(_ => LoopbackResponder.Ok(FakeCredential));
+ using var factory = GuardedFactory(entry);
+
+ using var response = await factory.Client.GetAsync($"http://{host}:{service.Port}{MetadataPath}");
+
+ response.IsSuccessStatusCode.ShouldBeTrue();
+ (await response.Content.ReadAsStringAsync()).ShouldBe(FakeCredential);
+ }
+
+ [Fact]
+ public async Task A_redirect_from_an_admitted_host_to_loopback_is_refused_at_the_redirect_hop()
+ {
+ // The audit's P3: the author pins a host, the host answers 302 to the worker's loopback. The first hop is
+ // admitted (the operator allowlisted that host by name); the second must be refused when it connects.
+ await using var metadata = LoopbackResponder.Start(_ => LoopbackResponder.Ok(FakeCredential));
+ await using var front = LoopbackResponder.Start(_ => LoopbackResponder.Redirect($"http://127.0.0.1:{metadata.Port}{MetadataPath}"));
+ using var factory = GuardedFactory("localhost");
+
+ var ex = await Should.ThrowAsync(() => factory.Client.GetAsync($"http://localhost:{front.Port}/feature-x"));
+
+ ex.InnerException.ShouldBeOfType(ex.Message);
+ front.Requests.Count.ShouldBe(1, "the admitted first hop was made");
+ metadata.Accepted.ShouldBe(0, "the redirect target is loopback and not allowlisted — no socket may reach it");
+ }
+
+ [Fact]
+ public async Task A_cross_origin_redirect_delivers_none_of_the_author_headers()
+ {
+ await using var target = LoopbackResponder.Start(_ => LoopbackResponder.Ok("{\"ok\":true}"));
+ await using var front = LoopbackResponder.Start(_ => LoopbackResponder.Redirect($"http://127.0.0.1:{target.Port}/data"));
+ using var factory = GuardedFactory("localhost", "127.0.0.1");
+
+ using var request = new HttpRequestMessage(HttpMethod.Get, $"http://localhost:{front.Port}/start");
+ request.Headers.TryAddWithoutValidation("X-Api-Key", "FAKE-TEAM-API-KEY");
+ request.Headers.TryAddWithoutValidation("PRIVATE-TOKEN", "FAKE-PRIVATE-TOKEN");
+ request.Headers.TryAddWithoutValidation("Cookie", "sid=FAKE-COOKIE");
+
+ using var response = await factory.Client.SendAsync(request);
+
+ response.IsSuccessStatusCode.ShouldBeTrue();
+ front.Requests.Single().ShouldContain("X-Api-Key: FAKE-TEAM-API-KEY", customMessage: "the origin the author wrote the headers for receives them");
+
+ var redirected = target.Requests.Single();
+ redirected.ShouldNotContain("FAKE-TEAM-API-KEY");
+ redirected.ShouldNotContain("FAKE-PRIVATE-TOKEN");
+ redirected.ShouldNotContain("FAKE-COOKIE");
+ }
+
+ [Fact]
+ public async Task The_http_request_node_refuses_a_loopback_url_built_from_trigger_text()
+ {
+ // The audit's P1, end to end through the real node: the URL is the untrusted {{trigger.body}}, resolved by the
+ // engine's own VariableResolver.
+ await using var metadata = LoopbackResponder.Start(_ => LoopbackResponder.Ok(FakeCredential));
+ using var factory = GuardedFactory();
+
+ var result = await new HttpRequestNode(factory.Factory).RunAsync(Context(new { url = "{{trigger.body}}", method = "GET" }, $"http://127.0.0.1:{metadata.Port}{MetadataPath}"), CancellationToken.None);
+
+ result.Status.ShouldBe(NodeStatus.Failure);
+ result.Error.ShouldNotBeNull().ShouldContain("Refused to connect to '127.0.0.1'");
+ result.Retryable.ShouldBeFalse("a refused destination is deterministic; retrying it only burns attempts");
+ result.Outputs.ShouldNotContainKey("body", "nothing from the loopback responder may become a node output");
+ metadata.Accepted.ShouldBe(0);
+ }
+
+ [Fact]
+ public async Task A_cookie_one_run_was_given_is_never_sent_by_another()
+ {
+ // Every CreateClient(nameof(HttpRequestNode)) in the process shares one primary handler for its lifetime, across
+ // runs and teams. With the handler's own cookie jar, run B's call carried the session run A had just been given.
+ await using var service = LoopbackResponder.Start(head => head.StartsWith("GET /login", StringComparison.Ordinal) ? LoopbackResponder.Ok("{}", "Set-Cookie: sid=FAKE-TEAM-A-SESSION; Path=/") : LoopbackResponder.Ok("{}"));
+ using var factory = GuardedFactory("localhost");
+
+ var teamA = await new HttpRequestNode(factory.Factory).RunAsync(Context(new { url = $"http://localhost:{service.Port}/login", method = "GET" }), CancellationToken.None);
+ var teamB = await new HttpRequestNode(factory.Factory).RunAsync(Context(new { url = $"http://localhost:{service.Port}/me", method = "GET" }), CancellationToken.None);
+ var authored = await new HttpRequestNode(factory.Factory).RunAsync(Context(new { url = $"http://localhost:{service.Port}/mine", method = "GET", headers = new Dictionary { ["Cookie"] = "sid=FAKE-AUTHORED" } }), CancellationToken.None);
+
+ new[] { teamA.Status, teamB.Status, authored.Status }.ShouldAllBe(status => status == NodeStatus.Success);
+ RequestTo(service, "/me").ShouldNotContain("Cookie:", customMessage: "a cookie set for one run must never ride along on another run's request");
+ RequestTo(service, "/mine").ShouldContain("Cookie: sid=FAKE-AUTHORED", customMessage: "a Cookie header the author wrote still reaches the origin it was written for");
+ RequestTo(service, "/mine").ShouldNotContain("FAKE-TEAM-A-SESSION");
+ }
+
+ [Fact]
+ public async Task An_author_host_header_cannot_reach_another_virtual_host_behind_an_admitted_name()
+ {
+ // An operator admits ONE name. Host (and the TLS SNI the handler derives from it) is what picks the virtual host
+ // behind that address, so an author-written Host would reach every other vhost on a shared ingress.
+ await using var ingress = LoopbackResponder.Start(_ => LoopbackResponder.Ok("{}"));
+ using var factory = GuardedFactory("localhost");
+
+ var result = await new HttpRequestNode(factory.Factory).RunAsync(Context(new { url = $"http://localhost:{ingress.Port}/admin", method = "GET", headers = new Dictionary { ["Host"] = "secret-admin.internal" } }), CancellationToken.None);
+
+ result.Status.ShouldBe(NodeStatus.Success, result.Error);
+ RequestTo(ingress, "/admin").ShouldContain($"Host: localhost:{ingress.Port}", customMessage: "the request is addressed to the authority the guard checked");
+ RequestTo(ingress, "/admin").ShouldNotContain("secret-admin.internal");
+ }
+
+ [Fact]
+ public async Task A_cross_origin_307_returns_the_redirect_instead_of_sending_the_body_on()
+ {
+ // The audit's body twin of the header leak: a token endpoint answers 307 to another origin, and a 307 re-sends
+ // the body — the client_secret the author wrote for the first origin, often from a team secret.
+ await using var collector = LoopbackResponder.Start(_ => LoopbackResponder.Ok("{}"));
+ await using var front = LoopbackResponder.Start(_ => LoopbackResponder.Redirect($"http://127.0.0.1:{collector.Port}/collect", 307));
+ using var factory = GuardedFactory("localhost", "127.0.0.1");
+
+ var result = await new HttpRequestNode(factory.Factory).RunAsync(Context(new { url = $"http://localhost:{front.Port}/token", method = "POST", body = new { client_secret = "FAKE-BODY-SECRET" } }), CancellationToken.None);
+
+ result.Status.ShouldBe(NodeStatus.Success, result.Error);
+ result.Outputs["status"].GetInt32().ShouldBe(307, "the redirect comes back to the workflow, which can follow it deliberately");
+ collector.Accepted.ShouldBe(0, "no socket may carry the author's body to the redirect's origin");
+ }
+
+ [Theory]
+ [InlineData("::1", "v4-first.example.test", "127.0.0.1", "::1")]
+ [InlineData("127.0.0.0/8", "v6-first.example.test", "::1", "127.0.0.1")]
+ public async Task A_mixed_answer_is_dialled_only_at_the_addresses_the_guard_permits(string allowed, string host, string refused, string permitted)
+ {
+ // The DNS-rebinding shape at socket level: one name answering with an address the guard refuses AND one it
+ // admits, the refused one FIRST. Both listen on the same port, so only the dial decides where the socket goes —
+ // a guard that checked the answer but dialled all of it would land on the refused address.
+ await using var service = LoopbackResponder.Start(_ => LoopbackResponder.Ok("{}"));
+
+ if (!service.ListensOnIpv6) return; // a host without IPv6 loopback cannot stage a two-family answer
+
+ using var factory = GuardedFactory(new OutboundDestinationGuard(OutboundDestinationAllowlist.Parse(new[] { allowed }), Direct, TestDns));
+
+ using var response = await factory.Client.GetAsync($"http://{host}:{service.Port}/");
+
+ response.IsSuccessStatusCode.ShouldBeTrue();
+ service.AcceptedOn(IPAddress.Parse(refused)).ShouldBe(0, $"{host} answered {refused} first, and the guard refuses it — no socket may be dialled there");
+ service.AcceptedOn(IPAddress.Parse(permitted)).ShouldBe(1);
+ }
+
+ [Fact]
+ public async Task A_public_destination_is_reached_through_the_operators_proxy()
+ {
+ // A worker that reaches the internet only through HTTP(S)_PROXY. The proxy's own address is internal (loopback
+ // here) and is dialled as the operator's proxy; the public destination behind it answers. 203.0.113.10 is
+ // TEST-NET-3: public to the guard and routed nowhere, so a regression that dialled it directly reaches no host.
+ await using var proxy = LoopbackResponder.Start(_ => LoopbackResponder.Ok("{\"via\":\"operator-proxy\"}"));
+ using var factory = GuardedFactory(ProxiedGuard(proxy));
+
+ using var response = await factory.Client.GetAsync("http://api.example.test/v1/items");
+
+ (await response.Content.ReadAsStringAsync()).ShouldBe("{\"via\":\"operator-proxy\"}");
+ proxy.Requests.Single().ShouldStartWith("GET http://api.example.test/v1/items HTTP/1.1", customMessage: "the proxy is asked for the destination itself");
+ }
+
+ [Theory]
+ [InlineData("http://metadata.example.test/latest/meta-data/")]
+ [InlineData("https://internal.example.test/admin")] // would be a CONNECT tunnel
+ [InlineData("http://127.0.0.1:8080/admin")]
+ [InlineData("http://[::ffff:10.0.0.5]/admin")]
+ public async Task A_request_the_proxy_would_carry_to_an_internal_address_is_refused_before_it_is_sent(string url)
+ {
+ // Through a proxy the connect-time check sees only the proxy, so the destination is checked before the request
+ // leaves: the proxy must never be asked to fetch it.
+ await using var proxy = LoopbackResponder.Start(_ => LoopbackResponder.Ok("{}"));
+ using var factory = GuardedFactory(ProxiedGuard(proxy));
+
+ var ex = await Should.ThrowAsync(() => factory.Client.GetAsync(url));
+
+ ex.InnerException.ShouldBeOfType(ex.Message);
+ proxy.Accepted.ShouldBe(0, $"the proxy must not be asked for {url}");
+ }
+
+ [Fact]
+ public async Task A_redirect_through_the_proxy_to_an_internal_address_is_refused_at_that_hop()
+ {
+ await using var proxy = LoopbackResponder.Start(_ => LoopbackResponder.Redirect("http://metadata.example.test/latest/meta-data/"));
+ using var factory = GuardedFactory(ProxiedGuard(proxy));
+
+ var ex = await Should.ThrowAsync(() => factory.Client.GetAsync("http://api.example.test/feature-x"));
+
+ ex.InnerException.ShouldBeOfType(ex.Message);
+ proxy.Requests.Count.ShouldBe(1, "only the public first hop went through the proxy");
+ }
+
+ [Fact]
+ public void A_malformed_committed_entry_fails_the_registration_not_the_first_request()
+ {
+ // The host registers the client from configuration at boot, so a bad entry stops the host from starting instead
+ // of failing every http.request — public ones included — at run time.
+ var configuration = new ConfigurationBuilder().AddInMemoryCollection(new Dictionary { [$"{OutboundHttpAllowlistSetting.ConfigurationKey}:0"] = "internal-api.corp:8080" }).Build();
+
+ var ex = Should.Throw(() => new ServiceCollection().AddGuardedHttpClient(nameof(HttpRequestNode), configuration));
+
+ ex.Message.ShouldContain("internal-api.corp:8080");
+ }
+
+ private static string RequestTo(LoopbackResponder responder, string path) => responder.Requests.Single(head => head.StartsWith($"GET {path} ", StringComparison.Ordinal));
+
+ private static NodeRunContext Context(object inputs, string triggerBody = "")
+ {
+ var raw = JsonSerializer.SerializeToElement(inputs);
+ var scope = new NodeRunScope { Trigger = new Dictionary { ["body"] = JsonSerializer.SerializeToElement(triggerBody) } };
+ var empty = JsonSerializer.SerializeToElement(new { });
+
+ return new NodeRunContext
+ {
+ Inputs = VariableResolver.ResolveBag(raw, scope),
+ Config = new Dictionary { ["timeoutSeconds"] = JsonSerializer.SerializeToElement(5) },
+ RawConfig = empty,
+ RawInputs = raw,
+ Scope = scope,
+ Logger = NullLogger.Instance,
+ Observability = NodeObservability.NoOp,
+ };
+ }
+
+ /// No proxy address, so bypasses every destination and each request connects directly through the guard.
+ private static readonly IWebProxy Direct = new WebProxy();
+
+ /// The names these tests resolve. 203.0.113.10 is TEST-NET-3: public to the guard, routed nowhere.
+ private static readonly Dictionary StubbedNames = new()
+ {
+ ["api.example.test"] = new[] { "203.0.113.10" },
+ ["metadata.example.test"] = new[] { "169.254.169.254" },
+ ["internal.example.test"] = new[] { "10.0.0.5" },
+ ["v4-first.example.test"] = new[] { "127.0.0.1", "::1" },
+ ["v6-first.example.test"] = new[] { "::1", "127.0.0.1" },
+ };
+
+ private static Task TestDns(string host, CancellationToken _) => StubbedNames.TryGetValue(host, out var answer) ? Task.FromResult(answer.Select(IPAddress.Parse).ToArray()) : Task.FromException(new SocketException((int)SocketError.HostNotFound));
+
+ /// A guard whose operator proxy is , as HTTP(S)_PROXY would name it, with nothing allowlisted.
+ private static OutboundDestinationGuard ProxiedGuard(LoopbackResponder proxy) => new(OutboundDestinationAllowlist.Empty, new WebProxy($"http://127.0.0.1:{proxy.Port}"), TestDns);
+
+ /// The production registration over a guard admitting , connecting directly and resolving through the system resolver.
+ private static GuardedClientFactory GuardedFactory(params string[] allowed) => GuardedFactory(new OutboundDestinationGuard(OutboundDestinationAllowlist.Parse(allowed), Direct));
+
+ private static GuardedClientFactory GuardedFactory(OutboundDestinationGuard guard)
+ {
+ var services = new ServiceCollection();
+ services.AddGuardedHttpClient(nameof(HttpRequestNode), guard);
+
+ return new GuardedClientFactory(services.BuildServiceProvider());
+ }
+
+ private sealed class GuardedClientFactory : IDisposable
+ {
+ private readonly ServiceProvider _provider;
+
+ public GuardedClientFactory(ServiceProvider provider)
+ {
+ _provider = provider;
+ Factory = provider.GetRequiredService();
+ Client = Factory.CreateClient(nameof(HttpRequestNode));
+ Client.Timeout = TimeSpan.FromSeconds(10);
+ }
+
+ public IHttpClientFactory Factory { get; }
+
+ public HttpClient Client { get; }
+
+ public void Dispose()
+ {
+ Client.Dispose();
+ _provider.Dispose();
+ }
+ }
+}
diff --git a/backend/tests/CodeSpace.UnitTests/OutboundHttp/LoopbackResponder.cs b/backend/tests/CodeSpace.UnitTests/OutboundHttp/LoopbackResponder.cs
new file mode 100644
index 000000000..4e8232a3d
--- /dev/null
+++ b/backend/tests/CodeSpace.UnitTests/OutboundHttp/LoopbackResponder.cs
@@ -0,0 +1,137 @@
+using System.Collections.Concurrent;
+using System.Net;
+using System.Net.Sockets;
+using System.Text;
+
+namespace CodeSpace.UnitTests.OutboundHttp;
+
+///
+/// A raw HTTP/1.1 responder on 127.0.0.1 AND ::1 at one ephemeral port, standing in for a service on the worker's own
+/// loopback (a metadata endpoint, an admin port) or for an operator's proxy. It counts ACCEPTED CONNECTIONS, not just
+/// requests, and per listening address, so a test can prove the guard refused before any socket reached it — a
+/// refused-at-connect destination and one that answered would otherwise look alike from the client — and which of the
+/// two addresses a dial landed on. It accepts any Host header, so every spelling of loopback reaches it.
+///
+internal sealed class LoopbackResponder : IAsyncDisposable
+{
+ private readonly List _listeners;
+ private readonly Func _respond;
+ private readonly CancellationTokenSource _stop = new();
+ private readonly ConcurrentQueue _requests = new();
+ private readonly int[] _accepted;
+
+ private LoopbackResponder(List listeners, Func respond)
+ {
+ _listeners = listeners;
+ _respond = respond;
+ _accepted = new int[listeners.Count];
+ Port = ((IPEndPoint)listeners[0].LocalEndpoint).Port;
+
+ for (var i = 0; i < listeners.Count; i++) _ = AcceptLoopAsync(listeners[i], i);
+ }
+
+ public int Port { get; }
+
+ public int Accepted => Enumerable.Range(0, _accepted.Length).Sum(i => Volatile.Read(ref _accepted[i]));
+
+ /// Connections accepted on the listener bound to ( or ).
+ public int AcceptedOn(IPAddress address) => Volatile.Read(ref _accepted[_listeners.FindIndex(listener => ((IPEndPoint)listener.LocalEndpoint).Address.Equals(address))]);
+
+ /// Whether the ::1 twin is listening — false on a host without IPv6 loopback.
+ public bool ListensOnIpv6 => _listeners.Count > 1;
+
+ /// Each request's head (request line + headers) in arrival order.
+ public IReadOnlyCollection Requests => _requests;
+
+ /// Start a responder whose reply to each request head is 's raw HTTP response.
+ public static LoopbackResponder Start(Func respond)
+ {
+ var v4 = new TcpListener(IPAddress.Loopback, 0);
+ v4.Start();
+
+ return new LoopbackResponder(new List { v4 }.Concat(TryListenIpv6(((IPEndPoint)v4.LocalEndpoint).Port)).ToList(), respond);
+ }
+
+ /// A 200 with a JSON body; is one more raw header line (a Set-Cookie, say).
+ public static string Ok(string body, string? extraHeader = null) => $"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\n{(extraHeader == null ? "" : extraHeader + "\r\n")}Content-Length: {Encoding.UTF8.GetByteCount(body)}\r\nConnection: close\r\n\r\n{body}";
+
+ public static string Redirect(string location, int status = 302) => $"HTTP/1.1 {status} Redirect\r\nLocation: {location}\r\nContent-Length: 0\r\nConnection: close\r\n\r\n";
+
+ /// The IPv6 twin on the same port, when this host has IPv6 loopback. Without it a [::1] request would fail with "connection refused" even if the guard let it through — hiding the very bug the test looks for.
+ private static IEnumerable TryListenIpv6(int port)
+ {
+ if (!Socket.OSSupportsIPv6) return Array.Empty();
+
+ try
+ {
+ var v6 = new TcpListener(IPAddress.IPv6Loopback, port);
+ v6.Start();
+ return new[] { v6 };
+ }
+ catch (SocketException)
+ {
+ return Array.Empty();
+ }
+ }
+
+ private async Task AcceptLoopAsync(TcpListener listener, int index)
+ {
+ while (!_stop.IsCancellationRequested)
+ {
+ TcpClient client;
+
+ try { client = await listener.AcceptTcpClientAsync(_stop.Token).ConfigureAwait(false); }
+ catch (Exception) { return; }
+
+ Interlocked.Increment(ref _accepted[index]);
+ _ = ServeAsync(client);
+ }
+ }
+
+ private async Task ServeAsync(TcpClient client)
+ {
+ using var _ = client;
+
+ try
+ {
+ var stream = client.GetStream();
+ var head = await ReadHeadAsync(stream).ConfigureAwait(false);
+
+ _requests.Enqueue(head);
+
+ await stream.WriteAsync(Encoding.UTF8.GetBytes(_respond(head)), _stop.Token).ConfigureAwait(false);
+ }
+ catch (Exception)
+ {
+ // A client that hung up mid-request is not this responder's failure to report.
+ }
+ }
+
+ private async Task ReadHeadAsync(NetworkStream stream)
+ {
+ var received = new StringBuilder();
+ var buffer = new byte[8192];
+
+ while (!received.ToString().Contains("\r\n\r\n"))
+ {
+ var read = await stream.ReadAsync(buffer, _stop.Token).ConfigureAwait(false);
+
+ if (read == 0) break;
+
+ received.Append(Encoding.ASCII.GetString(buffer, 0, read));
+ }
+
+ return received.ToString();
+ }
+
+ public ValueTask DisposeAsync()
+ {
+ _stop.Cancel();
+
+ foreach (var listener in _listeners) listener.Stop();
+
+ _stop.Dispose();
+
+ return ValueTask.CompletedTask;
+ }
+}
diff --git a/backend/tests/CodeSpace.UnitTests/OutboundHttp/OriginScopedRedirectHandlerTests.cs b/backend/tests/CodeSpace.UnitTests/OutboundHttp/OriginScopedRedirectHandlerTests.cs
new file mode 100644
index 000000000..034e21c27
--- /dev/null
+++ b/backend/tests/CodeSpace.UnitTests/OutboundHttp/OriginScopedRedirectHandlerTests.cs
@@ -0,0 +1,169 @@
+using System.Net;
+using System.Text;
+using CodeSpace.Core.Services.OutboundHttp;
+using Shouldly;
+
+namespace CodeSpace.UnitTests.OutboundHttp;
+
+///
+/// The redirect follower in isolation, over a scripted inner handler that records every hop it is asked to send. The
+/// built-in follower forwarded every author header except Authorization to a different origin; these pin that
+/// a cross-origin hop now carries none of them, and that the rest of the built-in behaviour (method rewrite, no
+/// https→http downgrade, a bounded hop count) is unchanged.
+///
+[Trait("Category", "Unit")]
+public class OriginScopedRedirectHandlerTests
+{
+ [Theory]
+ [InlineData(HttpStatusCode.Found, "http://front.example.test/a", "http://other.example.test/b")] // host changes
+ [InlineData(HttpStatusCode.Found, "http://front.example.test/a", "http://front.example.test:8080/b")] // port changes
+ [InlineData(HttpStatusCode.Found, "http://front.example.test/a", "https://front.example.test/b")] // scheme changes — an http→https move on the same host too: the author re-points the URL at https to keep them
+ [InlineData(HttpStatusCode.TemporaryRedirect, "http://front.example.test/a", "http://other.example.test/b")] // a body-less 307 is still followed
+ public async Task A_cross_origin_redirect_carries_none_of_the_author_headers(HttpStatusCode status, string from, string to)
+ {
+ var inner = new ScriptedHandler(Redirect(status, to), Ok());
+
+ using var response = await Send(inner, AuthoredRequest(HttpMethod.Get, from));
+
+ response.StatusCode.ShouldBe(HttpStatusCode.OK);
+ inner.Hops[1].Uri.ShouldBe(new Uri(to));
+ inner.Hops[1].Headers.ShouldBeEmpty("a hop to a different origin must not carry X-Api-Key, PRIVATE-TOKEN, Cookie or Authorization");
+ }
+
+ [Fact]
+ public async Task A_same_origin_redirect_keeps_the_author_headers()
+ {
+ var inner = new ScriptedHandler(Redirect(HttpStatusCode.MovedPermanently, "/v2/items"), Ok());
+
+ using var response = await Send(inner, AuthoredRequest(HttpMethod.Get, "http://api.example.test/v1/items"));
+
+ response.StatusCode.ShouldBe(HttpStatusCode.OK);
+ inner.Hops[1].Uri.ShouldBe(new Uri("http://api.example.test/v2/items"), "a relative Location resolves against the current URL");
+ inner.Hops[1].Headers.Keys.ShouldBe(new[] { "Authorization", "Cookie", "PRIVATE-TOKEN", "X-Api-Key" }, ignoreOrder: true);
+ }
+
+ [Theory]
+ [InlineData(HttpStatusCode.SeeOther, "POST", "GET", false)]
+ [InlineData(HttpStatusCode.Found, "POST", "GET", false)]
+ [InlineData(HttpStatusCode.MovedPermanently, "POST", "GET", false)]
+ [InlineData(HttpStatusCode.SeeOther, "PUT", "GET", false)]
+ [InlineData(HttpStatusCode.TemporaryRedirect, "POST", "POST", true)]
+ [InlineData(HttpStatusCode.PermanentRedirect, "PUT", "PUT", true)]
+ [InlineData(HttpStatusCode.Found, "PUT", "PUT", true)]
+ public async Task A_redirect_keeps_or_rewrites_the_method_like_the_builtin_follower(HttpStatusCode status, string method, string expectedMethod, bool keepsBody)
+ {
+ var inner = new ScriptedHandler(Redirect(status, "/next"), Ok());
+ var request = AuthoredRequest(new HttpMethod(method), "http://api.example.test/start");
+ request.Content = new StringContent("{\"a\":1}", Encoding.UTF8, "application/json");
+
+ using var response = await Send(inner, request);
+
+ inner.Hops[1].Method.ShouldBe(expectedMethod);
+ (inner.Hops[1].Body != null).ShouldBe(keepsBody);
+ }
+
+ [Theory]
+ [InlineData(HttpStatusCode.TemporaryRedirect, "POST")]
+ [InlineData(HttpStatusCode.PermanentRedirect, "PUT")]
+ [InlineData(HttpStatusCode.Found, "PUT")] // 300/301/302 keep the body for every method but POST
+ [InlineData(HttpStatusCode.MovedPermanently, "PATCH")]
+ public async Task A_redirect_that_would_carry_the_body_to_another_origin_is_returned_unfollowed(HttpStatusCode status, string method)
+ {
+ // Dropping the headers is not enough when the method keeps its body: an OAuth token request or a webhook POST
+ // carries its secret there. Re-sending it without the body would be a different request, so the 3xx comes back.
+ var inner = new ScriptedHandler(Redirect(status, "http://collector.example.test/collect"), Ok());
+ var request = AuthoredRequest(new HttpMethod(method), "http://api.example.test/token");
+ request.Content = new StringContent("{\"client_secret\":\"FAKE-BODY-SECRET\"}", Encoding.UTF8, "application/json");
+
+ using var response = await Send(inner, request);
+
+ response.StatusCode.ShouldBe(status);
+ inner.Hops.Count.ShouldBe(1, "the body never reaches the other origin");
+ }
+
+ [Theory]
+ [InlineData("https://api.example.test/a", "http://api.example.test/b")] // downgrade
+ [InlineData("http://api.example.test/a", "ftp://api.example.test/b")] // not http
+ [InlineData("http://api.example.test/a", "file:///etc/passwd")] // not http
+ public async Task A_redirect_the_builtin_follower_refuses_is_returned_unfollowed(string from, string to)
+ {
+ var inner = new ScriptedHandler(Redirect(HttpStatusCode.Found, to));
+
+ using var response = await Send(inner, AuthoredRequest(HttpMethod.Get, from));
+
+ response.StatusCode.ShouldBe(HttpStatusCode.Found);
+ inner.Hops.Count.ShouldBe(1);
+ }
+
+ [Fact]
+ public async Task A_redirect_loop_stops_after_the_hop_limit_and_returns_the_last_redirect()
+ {
+ var inner = new ScriptedHandler(Enumerable.Repeat(Redirect(HttpStatusCode.Found, "/again"), 50).ToArray());
+
+ using var response = await Send(inner, AuthoredRequest(HttpMethod.Get, "http://api.example.test/start"));
+
+ response.StatusCode.ShouldBe(HttpStatusCode.Found);
+ inner.Hops.Count.ShouldBe(OriginScopedRedirectHandler.MaxRedirects + 1, "the first request plus MaxRedirects follow-ups, then stop");
+ }
+
+ [Fact]
+ public async Task A_response_that_is_not_a_redirect_is_returned_as_is()
+ {
+ var inner = new ScriptedHandler(() => new HttpResponseMessage(HttpStatusCode.NotModified));
+
+ using var response = await Send(inner, AuthoredRequest(HttpMethod.Get, "http://api.example.test/x"));
+
+ response.StatusCode.ShouldBe(HttpStatusCode.NotModified);
+ inner.Hops.Count.ShouldBe(1);
+ }
+
+ private static async Task Send(ScriptedHandler inner, HttpRequestMessage request)
+ {
+ using var invoker = new HttpMessageInvoker(new OriginScopedRedirectHandler { InnerHandler = inner });
+
+ return await invoker.SendAsync(request, CancellationToken.None);
+ }
+
+ private static HttpRequestMessage AuthoredRequest(HttpMethod method, string url)
+ {
+ var request = new HttpRequestMessage(method, url);
+
+ request.Headers.TryAddWithoutValidation("X-Api-Key", "FAKE-TEAM-API-KEY");
+ request.Headers.TryAddWithoutValidation("PRIVATE-TOKEN", "FAKE-PRIVATE-TOKEN");
+ request.Headers.TryAddWithoutValidation("Cookie", "sid=FAKE-COOKIE");
+ request.Headers.TryAddWithoutValidation("Authorization", "Bearer FAKE-BEARER");
+
+ return request;
+ }
+
+ private static Func Redirect(HttpStatusCode status, string location) => () =>
+ {
+ var response = new HttpResponseMessage(status);
+ response.Headers.Location = new Uri(location, UriKind.RelativeOrAbsolute);
+ return response;
+ };
+
+ private static Func Ok() => () => new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent("ok") };
+
+ private sealed record Hop(Uri Uri, string Method, Dictionary Headers, string? Body);
+
+ /// Answers each hop with the next scripted response, recording what the hop carried.
+ private sealed class ScriptedHandler : HttpMessageHandler
+ {
+ private readonly Queue> _responses;
+
+ public ScriptedHandler(params Func[] responses) { _responses = new Queue>(responses); }
+
+ public List Hops { get; } = new();
+
+ protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
+ {
+ var headers = request.Headers.ToDictionary(h => h.Key, h => string.Join(",", h.Value));
+ var body = request.Content == null ? null : await request.Content.ReadAsStringAsync(cancellationToken);
+
+ Hops.Add(new Hop(request.RequestUri!, request.Method.Method, headers, body));
+
+ return _responses.Dequeue()();
+ }
+ }
+}
diff --git a/backend/tests/CodeSpace.UnitTests/OutboundHttp/OutboundDestinationAllowlistTests.cs b/backend/tests/CodeSpace.UnitTests/OutboundHttp/OutboundDestinationAllowlistTests.cs
new file mode 100644
index 000000000..6b5c21d45
--- /dev/null
+++ b/backend/tests/CodeSpace.UnitTests/OutboundHttp/OutboundDestinationAllowlistTests.cs
@@ -0,0 +1,98 @@
+using System.Net;
+using CodeSpace.Core.Services.OutboundHttp;
+using CodeSpace.Core.Settings.OutboundHttp;
+using Microsoft.Extensions.Configuration;
+using Shouldly;
+
+namespace CodeSpace.UnitTests.OutboundHttp;
+
+///
+/// The operator's committed allowlist is the only way an internal destination becomes reachable, so its parsing is
+/// strict: an entry that is neither a host name, an address nor a CIDR fails the setting rather than being skipped,
+/// because a skipped entry is a destination the operator believes is admitted and is not.
+///
+[Trait("Category", "Unit")]
+public class OutboundDestinationAllowlistTests
+{
+ [Fact]
+ public void ConfigurationKey_ConstantNamePinned()
+ {
+ // Renaming this key silently empties every deployment's allowlist that pinned the old name, and their
+ // intentional internal targets start failing. Hard-pin it so a rename is a visible decision.
+ OutboundHttpAllowlistSetting.ConfigurationKey.ShouldBe("OutboundHttp:AllowedInternalDestinations");
+ }
+
+ [Theory]
+ [InlineData("internal-api.corp", "internal-api.corp", "10.1.2.3")] // host entry admits whatever the name resolves to
+ [InlineData("Internal-API.corp", "internal-api.corp", "10.1.2.3")] // host names compare case-insensitively
+ [InlineData("10.0.0.0/8", "anything.corp", "10.200.0.7")] // CIDR entry admits an address inside it
+ [InlineData("10.1.2.3", "10.1.2.3", "10.1.2.3")] // a bare address is a single-address network
+ [InlineData("fd00::/8", "v6.corp", "fd12::5")] // IPv6 CIDR
+ [InlineData("::1", "[::1]", "::1")] // bare IPv6 address
+ [InlineData("10.0.0.0/8", "[::ffff:10.1.2.3]", "::ffff:10.1.2.3")] // a v4-mapped address matches its IPv4 network
+ public void An_allowlisted_destination_is_admitted(string entry, string host, string address)
+ {
+ var allowlist = OutboundDestinationAllowlist.Parse(new[] { entry });
+
+ allowlist.Admits(host, IPAddress.Parse(address)).ShouldBeTrue($"'{entry}' must admit {host} → {address}");
+ }
+
+ [Theory]
+ [InlineData("internal-api.corp", "other.corp", "10.1.2.3")] // a different host name
+ [InlineData("internal-api.corp", "api.internal-api.corp", "10.1.2.3")] // no implicit subdomains
+ [InlineData("10.0.0.0/8", "anything.corp", "11.0.0.1")] // outside the CIDR
+ [InlineData("10.1.2.3", "10.1.2.4", "10.1.2.4")] // a neighbour of a single address
+ [InlineData("127.0.0.1", "[::1]", "::1")] // IPv4 loopback does not admit IPv6 loopback
+ public void A_destination_outside_the_allowlist_is_not_admitted(string entry, string host, string address)
+ {
+ var allowlist = OutboundDestinationAllowlist.Parse(new[] { entry });
+
+ allowlist.Admits(host, IPAddress.Parse(address)).ShouldBeFalse($"'{entry}' must not admit {host} → {address}");
+ }
+
+ [Fact]
+ public void The_empty_allowlist_admits_nothing()
+ {
+ OutboundDestinationAllowlist.Empty.Admits("localhost", IPAddress.Loopback).ShouldBeFalse();
+ }
+
+ [Theory]
+ [InlineData("not a host")]
+ [InlineData("http://internal.corp")]
+ [InlineData("10.0.0.0/99")]
+ [InlineData("internal.corp:8080")]
+ public void A_malformed_entry_fails_the_setting(string entry)
+ {
+ var ex = Should.Throw(() => OutboundDestinationAllowlist.Parse(new[] { entry }));
+
+ ex.Message.ShouldContain(entry);
+ ex.Message.ShouldContain(OutboundHttpAllowlistSetting.ConfigurationKey);
+ }
+
+ [Fact]
+ public void The_setting_reads_the_committed_array_once()
+ {
+ var configuration = new ConfigurationBuilder()
+ .AddInMemoryCollection(new Dictionary
+ {
+ [$"{OutboundHttpAllowlistSetting.ConfigurationKey}:0"] = " internal-api.corp ",
+ [$"{OutboundHttpAllowlistSetting.ConfigurationKey}:1"] = "",
+ [$"{OutboundHttpAllowlistSetting.ConfigurationKey}:2"] = "192.168.10.0/24",
+ })
+ .Build();
+
+ var allowlist = new OutboundHttpAllowlistSetting(configuration).Value;
+
+ allowlist.Admits("internal-api.corp", IPAddress.Parse("10.9.9.9")).ShouldBeTrue("a trimmed host entry is admitted");
+ allowlist.Admits("printer.lan", IPAddress.Parse("192.168.10.40")).ShouldBeTrue("the CIDR entry is admitted");
+ allowlist.Admits("localhost", IPAddress.Loopback).ShouldBeFalse("a blank entry admits nothing");
+ }
+
+ [Fact]
+ public void An_absent_setting_is_the_empty_allowlist()
+ {
+ var allowlist = new OutboundHttpAllowlistSetting(new ConfigurationBuilder().Build()).Value;
+
+ allowlist.Admits("localhost", IPAddress.Loopback).ShouldBeFalse();
+ }
+}
diff --git a/backend/tests/CodeSpace.UnitTests/OutboundHttp/OutboundDestinationGuardTests.cs b/backend/tests/CodeSpace.UnitTests/OutboundHttp/OutboundDestinationGuardTests.cs
new file mode 100644
index 000000000..510050926
--- /dev/null
+++ b/backend/tests/CodeSpace.UnitTests/OutboundHttp/OutboundDestinationGuardTests.cs
@@ -0,0 +1,77 @@
+using System.Net;
+using CodeSpace.Core.Services.OutboundHttp;
+using CodeSpace.Core.Settings.OutboundHttp;
+using CodeSpace.Messages.Failures;
+using Shouldly;
+
+namespace CodeSpace.UnitTests.OutboundHttp;
+
+///
+/// The guard's decision over a host's resolved addresses, with the resolution stubbed. That the guard then dials
+/// exactly the addresses returns — so what it keeps here is what a
+/// connection can reach — is pinned at socket level by
+/// .
+///
+[Trait("Category", "Unit")]
+public class OutboundDestinationGuardTests
+{
+ [Fact]
+ public void A_name_resolving_to_public_addresses_is_dialled_at_those_addresses()
+ {
+ var resolved = Addresses("93.184.216.34", "2606:2800:220:1:248:1893:25c8:1946");
+
+ var permitted = Guard(OutboundDestinationAllowlist.Empty).Permit("api.example.test", resolved);
+
+ permitted.ShouldBe(resolved);
+ }
+
+ [Fact]
+ public void A_mixed_answer_is_dialled_only_at_its_public_addresses()
+ {
+ // The DNS-rebinding shape: one name answering with both a public and an internal address. Only the public one
+ // may be dialled — the connection cannot land on the internal one however the answers are ordered.
+ var permitted = Guard(OutboundDestinationAllowlist.Empty).Permit("rebind.example.test", Addresses("127.0.0.1", "93.184.216.34", "169.254.169.254"));
+
+ permitted.ShouldBe(Addresses("93.184.216.34"));
+ }
+
+ [Theory]
+ [InlineData("metadata.example.test", "169.254.169.254")]
+ [InlineData("localhost", "::1", "127.0.0.1")]
+ [InlineData("lan.example.test", "10.0.0.5", "fd00::5")]
+ [InlineData("nothing.example.test")]
+ public void A_name_resolving_only_to_internal_addresses_is_refused(string host, params string[] resolved)
+ {
+ var ex = Should.Throw(() => Guard(OutboundDestinationAllowlist.Empty).Permit(host, Addresses(resolved)));
+
+ ex.Message.ShouldContain(host);
+ ex.Message.ShouldContain(OutboundHttpAllowlistSetting.ConfigurationKey, customMessage: "the refusal must name the setting that admits an intentional internal target");
+ ((IFailure)ex).Kind.ShouldBe(FailureKind.Unprocessable);
+ ((IFailure)ex).Code.ShouldBe(FailureCodes.OutboundDestinationRefused);
+ }
+
+ [Fact]
+ public void The_refusal_does_not_disclose_the_internal_addresses_a_name_resolved_to()
+ {
+ // The message reaches the run viewer; echoing 10.20.30.40 back would turn http.request into a resolver for the
+ // worker's internal DNS.
+ var ex = Should.Throw(() => Guard(OutboundDestinationAllowlist.Empty).Permit("db.internal.test", Addresses("10.20.30.40")));
+
+ ex.Message.ShouldNotContain("10.20.30.40");
+ }
+
+ [Theory]
+ [InlineData("internal-api.corp", "internal-api.corp", "10.1.2.3")]
+ [InlineData("127.0.0.0/8", "localhost", "127.0.0.1")]
+ [InlineData("fd00::/8", "v6.corp", "fd00::7")]
+ public void An_allowlisted_internal_destination_is_dialled(string entry, string host, string address)
+ {
+ var permitted = Guard(OutboundDestinationAllowlist.Parse(new[] { entry })).Permit(host, Addresses(address));
+
+ permitted.ShouldBe(Addresses(address));
+ }
+
+ private static OutboundDestinationGuard Guard(OutboundDestinationAllowlist allowlist) => new(allowlist, new WebProxy());
+
+ private static IPAddress[] Addresses(params string[] addresses) => addresses.Select(IPAddress.Parse).ToArray();
+}
diff --git a/backend/tests/CodeSpace.UnitTests/OutboundHttp/PublicAddressTests.cs b/backend/tests/CodeSpace.UnitTests/OutboundHttp/PublicAddressTests.cs
new file mode 100644
index 000000000..c82ebce4b
--- /dev/null
+++ b/backend/tests/CodeSpace.UnitTests/OutboundHttp/PublicAddressTests.cs
@@ -0,0 +1,63 @@
+using System.Net;
+using CodeSpace.Core.Services.OutboundHttp;
+using Shouldly;
+
+namespace CodeSpace.UnitTests.OutboundHttp;
+
+///
+/// Pins which resolved addresses a guarded client may dial. Every refused row is a way to reach the worker's own
+/// loopback, its cloud metadata service, or its LAN; the IPv6 rows include each form that EMBEDS an IPv4 address,
+/// because a dual-stack socket connects ::ffff:127.0.0.1 straight to 127.0.0.1.
+///
+[Trait("Category", "Unit")]
+public class PublicAddressTests
+{
+ [Theory]
+ [InlineData("127.0.0.1")] // loopback
+ [InlineData("127.255.255.254")] // the whole of 127/8
+ [InlineData("0.0.0.0")] // "this host" — connects to loopback on Linux and macOS
+ [InlineData("10.1.2.3")] // RFC 1918
+ [InlineData("172.16.0.1")] // RFC 1918
+ [InlineData("192.168.1.1")] // RFC 1918
+ [InlineData("169.254.169.254")] // link-local: AWS/GCP/Azure metadata
+ [InlineData("100.100.100.200")] // CGNAT range: Aliyun metadata
+ [InlineData("100.64.0.1")] // CGNAT
+ [InlineData("198.18.0.1")] // benchmarking, holds the sandbox's per-run /30s
+ [InlineData("224.0.0.1")] // multicast
+ [InlineData("255.255.255.255")] // broadcast
+ [InlineData("::")] // unspecified
+ [InlineData("::1")] // IPv6 loopback
+ [InlineData("::ffff:127.0.0.1")] // v4-mapped loopback
+ [InlineData("::ffff:169.254.169.254")] // v4-mapped metadata
+ [InlineData("::ffff:10.0.0.1")] // v4-mapped RFC 1918
+ [InlineData("::127.0.0.1")] // v4-compatible loopback
+ [InlineData("64:ff9b::7f00:1")] // NAT64 of 127.0.0.1
+ [InlineData("64:ff9b::a9fe:a9fe")] // NAT64 of 169.254.169.254
+ [InlineData("2002:7f00:1::1")] // 6to4 of 127.0.0.1
+ [InlineData("2002:a9fe:a9fe::1")] // 6to4 of 169.254.169.254
+ [InlineData("fe80::1")] // link-local
+ [InlineData("fec0::1")] // deprecated site-local
+ [InlineData("fc00::1")] // unique local
+ [InlineData("fd00:ec2::254")] // unique local: AWS metadata over IPv6
+ [InlineData("ff02::1")] // multicast
+ [InlineData("2001:db8::1")] // documentation
+ [InlineData("2001::1")] // Teredo, which carries an obfuscated IPv4
+ public void A_non_public_address_is_refused(string address)
+ {
+ PublicAddress.IsPublic(IPAddress.Parse(address)).ShouldBeFalse($"{address} reaches the worker's own loopback, metadata or network");
+ }
+
+ [Theory]
+ [InlineData("93.184.216.34")]
+ [InlineData("8.8.8.8")]
+ [InlineData("1.1.1.1")]
+ [InlineData("2606:4700:4700::1111")]
+ [InlineData("2001:4860:4860::8888")]
+ [InlineData("::ffff:8.8.8.8")] // v4-mapped public stays public
+ [InlineData("64:ff9b::808:808")] // NAT64 of a public address: how an IPv6-only cluster reaches IPv4 hosts
+ [InlineData("2002:808:808::1")] // 6to4 of a public address
+ public void A_public_address_is_allowed(string address)
+ {
+ PublicAddress.IsPublic(IPAddress.Parse(address)).ShouldBeTrue($"{address} is a globally routable destination");
+ }
+}