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); /// /// Issues one logical request, following redirects by hand so every hop is /// egress-vetted. Credentials/body are dropped on a cross-host redirect. /// private async Task SendPageAsync( NodeExecutionContext context, HttpClient client, CancellationTokenSource timeout, int timeoutMs, Uri initialUri, HttpMethod method, bool sendBody, JsonNode? body, IReadOnlyDictionary 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(), 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 /// Resolves a Location header against the current URL and keeps it HTTP(S). 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; } } }