Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions backend/src/CodeSpace.Api/Startup.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions backend/src/CodeSpace.Api/appsettings.json
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
using System.Net.Sockets;

namespace CodeSpace.Core.Services.OutboundHttp;

/// <summary>
/// The per-request half of the destination guard (the connect-time half is <see cref="OutboundDestinationGuard.ConnectAsync"/>),
/// run on every hop, redirects included.
///
/// <para>A request is addressed to the authority in its URL, the one the guard checks: an author-written <c>Host</c>
/// header is dropped, because <c>Host</c> — 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' <c>fetch</c> treats <c>Host</c> as a forbidden request header the same way.</para>
///
/// <para>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 <see cref="HttpRequestException"/> whose inner exception is the
/// <see cref="OutboundDestinationRefusedException"/> — so callers handle the two alike.</para>
/// </summary>
public sealed class GuardedDestinationHandler : DelegatingHandler
{
private readonly OutboundDestinationGuard _guard;

public GuardedDestinationHandler(OutboundDestinationGuard guard) { _guard = guard; }

protected override async Task<HttpResponseMessage> 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);
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
using CodeSpace.Core.Settings.OutboundHttp;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;

namespace CodeSpace.Core.Services.OutboundHttp;

/// <summary>
/// Registers a named HttpClient whose every connection goes through <see cref="OutboundDestinationGuard"/>, whose every
/// request goes through <see cref="GuardedDestinationHandler"/>, and whose redirects are followed by
/// <see cref="OriginScopedRedirectHandler"/> — 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.
/// </summary>
public static class GuardedHttpClientRegistration
{
/// <summary>
/// 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 (<see cref="HttpClient.DefaultProxy"/>: HTTP(S)_PROXY / NO_PROXY, or the OS setting).
/// </summary>
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;
}

/// <summary>
/// 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.
/// </summary>
private static SocketsHttpHandler CreatePrimaryHandler(OutboundDestinationGuard guard) => new()
{
AllowAutoRedirect = false,
UseCookies = false,
Proxy = guard.Proxy,
ConnectCallback = guard.ConnectAsync,
};
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
using System.Net;

namespace CodeSpace.Core.Services.OutboundHttp;

/// <summary>
/// Follows redirects for a guarded client in place of the handler's built-in follower, which strips only
/// <c>Authorization</c> when a redirect changes origin: every other header the author wrote — <c>X-Api-Key</c>,
/// <c>PRIVATE-TOKEN</c>, <c>Cookie</c>, 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 <c>client_secret</c>, 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.
///
/// <para>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 <c>Location</c> 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 <see cref="MaxRedirects"/> hops (the built-in allows 50) are followed,
/// after which the last 3xx is returned as is.</para>
/// </summary>
public sealed class OriginScopedRedirectHandler : DelegatingHandler
{
public const int MaxRedirects = 10;

protected override async Task<HttpResponseMessage> 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;

/// <summary>https anywhere, or http from http — never a downgrade, never another scheme.</summary>
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;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
using System.Net;
using CodeSpace.Core.Settings.OutboundHttp;

namespace CodeSpace.Core.Services.OutboundHttp;

/// <summary>
/// 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 <c>OutboundHttp:AllowedInternalDestinations</c>. 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 <c>Host</c>; 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.
///
/// <para>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.</para>
/// </summary>
public sealed class OutboundDestinationAllowlist
{
public static readonly OutboundDestinationAllowlist Empty = new(new HashSet<string>(StringComparer.OrdinalIgnoreCase), Array.Empty<IPNetwork>());

private readonly IReadOnlySet<string> _hosts;
private readonly IReadOnlyList<IPNetwork> _networks;

private OutboundDestinationAllowlist(IReadOnlySet<string> hosts, IReadOnlyList<IPNetwork> networks)
{
_hosts = hosts;
_networks = networks;
}

public static OutboundDestinationAllowlist Parse(IEnumerable<string> entries)
{
var hosts = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
var networks = new List<IPNetwork>();

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);
}

/// <summary>Whether <paramref name="address"/>, reached through <paramref name="host"/>, is an operator-admitted destination.</summary>
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.");
}
Loading
Loading