315 lines
12 KiB
C#
315 lines
12 KiB
C#
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,
|
|
NodeRequestBudget? quota = null)
|
|
=> 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);
|
|
}
|
|
}
|