198 lines
7.2 KiB
C#
198 lines
7.2 KiB
C#
using System.Net;
|
|
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.Binary;
|
|
using w4c_workflows.Services.Security;
|
|
|
|
namespace w4c_workflows.Services.Nodes.Executors;
|
|
|
|
// Sending half of the HTTP Request node: one logical request with hand-followed,
|
|
// per-hop-vetted redirects. See HttpRequestNodeExecutor.cs for the contract.
|
|
|
|
public sealed partial class HttpRequestNodeExecutor
|
|
{
|
|
private sealed record PageResult(
|
|
int Status,
|
|
string Body,
|
|
byte[] Bytes,
|
|
JsonNode? Parsed,
|
|
string? ContentType,
|
|
string? SuggestedFileName,
|
|
NodeExecutionOutcome? Failure);
|
|
|
|
/// <summary>
|
|
/// Issues one logical request, following redirects by hand so every hop is
|
|
/// egress-vetted. Credentials/body are dropped on a cross-host redirect.
|
|
/// </summary>
|
|
private async Task<PageResult> SendPageAsync(
|
|
NodeExecutionContext context,
|
|
HttpClient client,
|
|
CancellationTokenSource timeout,
|
|
int timeoutMs,
|
|
Uri initialUri,
|
|
HttpMethod method,
|
|
bool sendBody,
|
|
JsonNode? body,
|
|
IReadOnlyDictionary<string, string> headerOverrides,
|
|
bool sendCredential,
|
|
bool hasCredential,
|
|
CredentialData? credential,
|
|
bool followRedirects,
|
|
int maxRedirects,
|
|
CancellationToken ct)
|
|
{
|
|
var currentUri = initialUri;
|
|
var currentMethod = method;
|
|
var currentHost = initialUri.DnsSafeHost;
|
|
var bodyEnabled = sendBody;
|
|
var credEnabled = sendCredential;
|
|
var redirectsFollowed = 0;
|
|
HttpResponseMessage? response = null;
|
|
|
|
while (true)
|
|
{
|
|
var verdict = await _egress.AuthorizeAsync(currentUri, ct);
|
|
if (!verdict.Allowed)
|
|
{
|
|
response?.Dispose();
|
|
return PageFailure(verdict.Reason ?? "blocked by the egress policy", "egress_blocked");
|
|
}
|
|
|
|
if (!_quota.TryReserveRequest(context.State, out var requestsUsed))
|
|
{
|
|
response?.Dispose();
|
|
return PageFailure(
|
|
$"outbound request quota exceeded ({_quota.MaxRequestsPerRun} per run)",
|
|
"quota_exceeded",
|
|
$"this run has already issued {requestsUsed - 1} requests");
|
|
}
|
|
|
|
using var request = new HttpRequestMessage(currentMethod, currentUri);
|
|
ApplyHeaders(context, request, headerOverrides);
|
|
|
|
if (credEnabled && hasCredential)
|
|
{
|
|
var authError = CredentialInjector.Apply(credential!, _credentialTypes, request);
|
|
if (authError != null)
|
|
{
|
|
response?.Dispose();
|
|
return PageFailure(authError, "missing_credential");
|
|
}
|
|
}
|
|
|
|
if (bodyEnabled)
|
|
{
|
|
var bodyError = await ApplyBodyAsync(context, body, request, ct);
|
|
if (bodyError != null)
|
|
{
|
|
response?.Dispose();
|
|
return PageFailure(bodyError, "invalid_parameter");
|
|
}
|
|
}
|
|
|
|
try
|
|
{
|
|
// Headers-only completion so the body is streamed under the size
|
|
// cap below instead of being buffered by the transport first.
|
|
response = await client.SendAsync(
|
|
request, HttpCompletionOption.ResponseHeadersRead, timeout.Token);
|
|
}
|
|
catch (OperationCanceledException) when (ct.IsCancellationRequested)
|
|
{
|
|
throw;
|
|
}
|
|
catch (OperationCanceledException)
|
|
{
|
|
return PageFailure($"request timed out after {timeoutMs} ms", "timeout");
|
|
}
|
|
catch (HttpRequestException ex)
|
|
{
|
|
return PageFailure(ex.Message, "request_failed");
|
|
}
|
|
|
|
var location = response.Headers.Location;
|
|
if (!followRedirects || location == null || !IsRedirect(response.StatusCode))
|
|
break;
|
|
|
|
if (redirectsFollowed >= maxRedirects)
|
|
{
|
|
response.Dispose();
|
|
return PageFailure(
|
|
$"exceeded the maximum of {maxRedirects} redirects", "too_many_redirects");
|
|
}
|
|
|
|
if (!TryResolveRedirect(currentUri, location, out var nextUri))
|
|
{
|
|
response.Dispose();
|
|
return PageFailure(
|
|
$"invalid redirect location '{location}'", "invalid_redirect");
|
|
}
|
|
|
|
// 307/308 preserve method and body; the others downgrade to GET.
|
|
if (response.StatusCode != HttpStatusCode.TemporaryRedirect
|
|
&& response.StatusCode != HttpStatusCode.PermanentRedirect
|
|
&& currentMethod != HttpMethod.Head)
|
|
{
|
|
currentMethod = HttpMethod.Get;
|
|
bodyEnabled = false;
|
|
}
|
|
|
|
var nextHost = nextUri.DnsSafeHost;
|
|
if (!string.Equals(nextHost, currentHost, StringComparison.OrdinalIgnoreCase))
|
|
credEnabled = false;
|
|
|
|
response.Dispose();
|
|
response = null;
|
|
currentUri = nextUri;
|
|
currentHost = nextHost;
|
|
redirectsFollowed++;
|
|
}
|
|
|
|
using (response!)
|
|
{
|
|
var status = (int)response.StatusCode;
|
|
var read = await HttpBodyReader.ReadAsync(response, _quota.MaxResponseBytes, timeout.Token);
|
|
if (read.TooLarge)
|
|
return PageFailure(
|
|
$"response exceeded the {_quota.MaxResponseBytes}-byte limit", "response_too_large");
|
|
|
|
var bodyText = read.Body!;
|
|
var contentType = response.Content.Headers.ContentType?.MediaType;
|
|
var fileName = ResolveFileName(response, currentUri);
|
|
return new PageResult(
|
|
status, bodyText, read.Bytes!, NodeValueAccess.TryParseJson(bodyText), contentType, fileName, null);
|
|
}
|
|
}
|
|
|
|
private static PageResult PageFailure(string message, string? code, string? description = null)
|
|
=> new(
|
|
0, string.Empty, Array.Empty<byte>(), null, null, null,
|
|
NodeExecutionOutcome.Failed(message, code, description));
|
|
|
|
// ------------------------------------------------------------------ redirects
|
|
|
|
private static bool IsRedirect(HttpStatusCode status)
|
|
=> status is HttpStatusCode.MovedPermanently // 301
|
|
or HttpStatusCode.Found // 302
|
|
or HttpStatusCode.SeeOther // 303
|
|
or HttpStatusCode.TemporaryRedirect // 307
|
|
or HttpStatusCode.PermanentRedirect; // 308
|
|
|
|
/// <summary>Resolves a Location header against the current URL and keeps it HTTP(S).</summary>
|
|
private static bool TryResolveRedirect(Uri current, Uri location, out Uri next)
|
|
{
|
|
next = null!;
|
|
try
|
|
{
|
|
next = location.IsAbsoluteUri ? location : new Uri(current, location);
|
|
return next.Scheme is "http" or "https";
|
|
}
|
|
catch (UriFormatException)
|
|
{
|
|
return false;
|
|
}
|
|
}
|
|
}
|