w4c-workflows-api/w4c-workflows-api.Tests/HttpRequestNodeExecutorEgressTests.cs

315 lines
12 KiB
C#
Raw Permalink Normal View History

2026-09-11 22:02:46 +00:00
using System.Net;
using System.Text;
using System.Text.Json.Nodes;
using w4c_workflows.Models.Credentials;
using w4c_workflows.Models.Nodes;
using w4c_workflows.Services.Credentials;
using w4c_workflows.Services.Nodes;
using w4c_workflows.Services.Nodes.Executors;
using w4c_workflows.Services.Security;
using Xunit;
namespace w4c_workflows.Tests;
/// <summary>
/// Executor-level egress tests: proves the guard runs before any send and on
/// every redirect hop, that redirects are followed manually, and that
/// credentials never cross to another origin.
/// </summary>
public class HttpRequestNodeExecutorEgressTests
{
private static readonly NodeBlueprintCatalog Catalog = NodeTestData.CoreCatalog();
private sealed record SentRequest(HttpMethod Method, Uri Uri, string? Authorization, string? Body);
private sealed class RecordingHandler : HttpMessageHandler
{
private readonly Func<HttpRequestMessage, int, HttpResponseMessage> _responder;
public RecordingHandler(Func<HttpRequestMessage, int, HttpResponseMessage> responder)
=> _responder = responder;
public List<SentRequest> Requests { get; } = new();
protected override async Task<HttpResponseMessage> SendAsync(
HttpRequestMessage request, CancellationToken ct)
{
var body = request.Content == null ? null : await request.Content.ReadAsStringAsync(ct);
Requests.Add(new SentRequest(
request.Method,
request.RequestUri!,
request.Headers.Authorization?.ToString(),
body));
return _responder(request, Requests.Count - 1);
}
}
private sealed class RecordingFactory : IHttpClientFactory
{
private readonly HttpMessageHandler _handler;
public RecordingFactory(HttpMessageHandler handler) => _handler = handler;
public string? LastClientName { get; private set; }
public HttpClient CreateClient(string name)
{
LastClientName = name;
return new HttpClient(_handler, disposeHandler: false);
}
}
private static HttpResponseMessage Json(string body, HttpStatusCode status = HttpStatusCode.OK)
=> new(status) { Content = new StringContent(body, Encoding.UTF8, "application/json") };
private static HttpResponseMessage Redirect(
string location, HttpStatusCode status = HttpStatusCode.Found)
{
var response = new HttpResponseMessage(status)
{
Content = new StringContent("{}", Encoding.UTF8, "application/json"),
};
response.Headers.TryAddWithoutValidation("Location", location);
return response;
}
private static NodeExecutionContext Context(
JsonObject parameters,
IReadOnlyDictionary<string, CredentialData>? credentials = null)
=> new()
{
Blueprint = Catalog.Get("core.httpRequest")!,
Parameters = parameters,
Inputs = new IReadOnlyList<FlowItem>[] { Array.Empty<FlowItem>() },
Credentials = credentials ?? new Dictionary<string, CredentialData>(),
};
private static HttpRequestNodeExecutor Executor(
RecordingHandler handler,
RecordingFactory factory,
EgressGuard guard,
2026-09-13 16:28:47 +00:00
NodeRequestBudget? quota = null)
2026-09-11 22:02:46 +00:00
=> new(factory, new CredentialTypeCatalog(), guard, quota ?? EgressTestData.Quota(), BinaryTestData.Store());
private static EgressGuard PublicEgress(
StubHostAddressResolver? resolver = null,
EgressPolicy? policy = null)
=> EgressTestData.Guard(policy, resolver ?? StubHostAddressResolver.Returning(EgressTestData.PublicAddress));
[Fact]
public async Task Private_literal_url_is_blocked_before_sending()
{
var handler = new RecordingHandler((_, _) => Json("{}"));
var factory = new RecordingFactory(handler);
var outcome = await Executor(handler, factory, PublicEgress()).RunAsync(
Context(new JsonObject { ["url"] = "http://127.0.0.1/admin" }), default);
Assert.False(outcome.Succeeded);
Assert.Equal("egress_blocked", outcome.Failure!.Code);
Assert.Empty(handler.Requests);
}
[Fact]
public async Task Non_http_scheme_is_blocked_before_sending()
{
var handler = new RecordingHandler((_, _) => Json("{}"));
var factory = new RecordingFactory(handler);
var outcome = await Executor(handler, factory, PublicEgress()).RunAsync(
Context(new JsonObject { ["url"] = "ftp://example.com/x" }), default);
Assert.False(outcome.Succeeded);
Assert.Equal("egress_blocked", outcome.Failure!.Code);
Assert.Empty(handler.Requests);
}
[Fact]
public async Task Public_host_is_sent()
{
var handler = new RecordingHandler((_, _) => Json("""{"ok":true}"""));
var factory = new RecordingFactory(handler);
var outcome = await Executor(handler, factory, PublicEgress()).RunAsync(
Context(new JsonObject { ["url"] = "https://example.com/x" }), default);
Assert.True(outcome.Succeeded, outcome.Failure?.Message);
Assert.Single(handler.Requests);
}
[Fact]
public async Task Redirect_to_a_private_host_is_blocked()
{
var handler = new RecordingHandler((_, index) => index == 0
? Redirect("http://127.0.0.1/secret")
: Json("{}"));
var factory = new RecordingFactory(handler);
var outcome = await Executor(handler, factory, PublicEgress()).RunAsync(
Context(new JsonObject { ["url"] = "https://example.com/x" }), default);
Assert.False(outcome.Succeeded);
Assert.Equal("egress_blocked", outcome.Failure!.Code);
Assert.Single(handler.Requests); // the initial request only
}
[Fact]
public async Task Public_redirect_is_followed_and_post_downgrades_to_get()
{
var handler = new RecordingHandler((_, index) => index == 0
? Redirect("https://other.example.com/y")
: Json("""{"done":true}"""));
var factory = new RecordingFactory(handler);
var outcome = await Executor(handler, factory, PublicEgress()).RunAsync(
Context(new JsonObject
{
["method"] = "POST",
["url"] = "https://example.com/x",
["sendBody"] = true,
["bodyContentType"] = "json",
["body"] = new JsonObject { ["a"] = 1 },
}), default);
Assert.True(outcome.Succeeded, outcome.Failure?.Message);
Assert.Equal(2, handler.Requests.Count);
Assert.Equal(HttpMethod.Post, handler.Requests[0].Method);
Assert.NotNull(handler.Requests[0].Body);
Assert.Equal(HttpMethod.Get, handler.Requests[1].Method);
Assert.Null(handler.Requests[1].Body);
Assert.Equal("other.example.com", handler.Requests[1].Uri.Host);
}
[Fact]
public async Task Follow_redirects_false_returns_the_redirect_response()
{
var handler = new RecordingHandler((_, _) => Redirect("https://other.example.com/y"));
var factory = new RecordingFactory(handler);
var outcome = await Executor(handler, factory, PublicEgress()).RunAsync(
Context(new JsonObject
{
["url"] = "https://example.com/x",
["options"] = new JsonObject { ["followRedirects"] = false },
}), default);
Assert.True(outcome.Succeeded, outcome.Failure?.Message);
Assert.Single(handler.Requests);
}
[Fact]
public async Task Exceeding_max_redirects_fails()
{
var handler = new RecordingHandler((_, _) => Redirect("https://example.com/next"));
var factory = new RecordingFactory(handler);
var outcome = await Executor(handler, factory, PublicEgress()).RunAsync(
Context(new JsonObject
{
["url"] = "https://example.com/x",
["options"] = new JsonObject { ["maxRedirects"] = 1 },
}), default);
Assert.False(outcome.Succeeded);
Assert.Equal("too_many_redirects", outcome.Failure!.Code);
Assert.Equal(2, handler.Requests.Count);
}
[Fact]
public async Task Policy_caps_the_node_redirect_setting()
{
var policy = EgressTestData.Policy(o => o.MaxRedirects = 1);
var handler = new RecordingHandler((_, _) => Redirect("https://example.com/next"));
var factory = new RecordingFactory(handler);
var outcome = await Executor(handler, factory, PublicEgress(policy: policy)).RunAsync(
Context(new JsonObject
{
["url"] = "https://example.com/x",
["options"] = new JsonObject { ["maxRedirects"] = 50 },
}), default);
Assert.False(outcome.Succeeded);
Assert.Equal("too_many_redirects", outcome.Failure!.Code);
Assert.Equal(2, handler.Requests.Count);
}
[Fact]
public async Task Cross_origin_redirect_drops_the_credential()
{
var handler = new RecordingHandler((_, index) => index == 0
? Redirect("https://two.example.com/y")
: Json("{}"));
var factory = new RecordingFactory(handler);
var credentials = new Dictionary<string, CredentialData>
{
[HttpRequestNodeExecutor.CredentialAlias] = new(
"httpBasicAuth",
new JsonObject { ["username"] = "ada", ["password"] = "secret" }),
};
var outcome = await Executor(handler, factory, PublicEgress()).RunAsync(
Context(new JsonObject
{
["url"] = "https://one.example.com/x",
["authentication"] = "credential",
}, credentials), default);
Assert.True(outcome.Succeeded, outcome.Failure?.Message);
Assert.NotNull(handler.Requests[0].Authorization);
Assert.Null(handler.Requests[1].Authorization);
}
[Fact]
public async Task Same_origin_redirect_keeps_the_credential()
{
var handler = new RecordingHandler((_, index) => index == 0
? Redirect("/y")
: Json("{}"));
var factory = new RecordingFactory(handler);
var credentials = new Dictionary<string, CredentialData>
{
[HttpRequestNodeExecutor.CredentialAlias] = new(
"httpBasicAuth",
new JsonObject { ["username"] = "ada", ["password"] = "secret" }),
};
var outcome = await Executor(handler, factory, PublicEgress()).RunAsync(
Context(new JsonObject
{
["url"] = "https://one.example.com/x",
["authentication"] = "credential",
}, credentials), default);
Assert.True(outcome.Succeeded, outcome.Failure?.Message);
Assert.NotNull(handler.Requests[1].Authorization);
}
[Fact]
public async Task Ignore_ssl_issues_selects_the_insecure_client()
{
var handler = new RecordingHandler((_, _) => Json("{}"));
var factory = new RecordingFactory(handler);
var outcome = await Executor(handler, factory, PublicEgress()).RunAsync(
Context(new JsonObject
{
["url"] = "https://example.com/x",
["options"] = new JsonObject { ["ignoreSslIssues"] = true },
}), default);
Assert.True(outcome.Succeeded, outcome.Failure?.Message);
Assert.Equal(HttpRequestNodeExecutor.InsecureClientName, factory.LastClientName);
}
[Fact]
public async Task Default_client_is_used_without_ignore_ssl_issues()
{
var handler = new RecordingHandler((_, _) => Json("{}"));
var factory = new RecordingFactory(handler);
var outcome = await Executor(handler, factory, PublicEgress()).RunAsync(
Context(new JsonObject { ["url"] = "https://example.com/x" }), default);
Assert.True(outcome.Succeeded, outcome.Failure?.Message);
Assert.Equal(HttpRequestNodeExecutor.TypeName, factory.LastClientName);
}
}