w4c-workflows-api/Services/Nodes/Executors/HttpRequestNodeExecutor.Sending.cs
2026-09-13 19:28:47 +03:00

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