using System.Net; using System.Net.Http.Headers; using System.Net.Sockets; using System.Text; using System.Text.Encodings.Web; using System.Text.Json; using BuildFlow.Shared.Domain.Common; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using Workflow.Application.BuildFlow.CustomFunctions.Services; using Workflow.Application.Notifications; using Workflow.Domain.Entities; namespace BuildFlow.Host.Services.Integration; /// /// HTTP 自定义函数执行器实现。 /// 复用 Webhook 的 IHttpClientFactory("Webhook" 命名客户端)和 SSRF 白名单防护。 /// 对 URL/Header/Body 中的 $input.xxx 占位符做变量替换后发起请求, /// 非 2xx 响应、SSRF 拦截、超时均抛 BusinessException。 /// public class HttpFunctionExecutor( IHttpClientFactory httpClientFactory, IOptions options, ILogger logger) : IHttpFunctionExecutor { private static readonly JsonSerializerOptions JsonOptions = new() { Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping, }; private readonly NotificationOptions _options = options.Value; public async Task ExecuteAsync(CustomFunction function, string inputJson, CancellationToken ct) { if (string.IsNullOrWhiteSpace(function.HttpUrl)) { throw new BusinessException($"HTTP 函数 {function.DisplayName} 未配置目标 URL"); } var url = ReplaceVariables(function.HttpUrl, inputJson); if (!IsHostAllowed(url)) { logger.LogWarning("HTTP 函数 {FunctionId} URL {Url} 被拒绝(SSRF 白名单拦截)", function.Id, url); throw new BusinessException($"HTTP 函数目标地址不在允许的白名单内:{new Uri(url).Host}"); } var client = httpClientFactory.CreateClient("Webhook"); using var request = new HttpRequestMessage( new HttpMethod(string.IsNullOrWhiteSpace(function.HttpMethod) ? "POST" : function.HttpMethod.ToUpperInvariant()), url); // 请求头 if (!string.IsNullOrWhiteSpace(function.HttpHeaders)) { var headersJson = ReplaceVariables(function.HttpHeaders, inputJson); var headers = JsonSerializer.Deserialize>(headersJson) ?? new Dictionary(); foreach (var (key, value) in headers) { request.Headers.TryAddWithoutValidation(key, value); } } // 请求体 if (!string.IsNullOrWhiteSpace(function.HttpBodyTemplate)) { var body = ReplaceVariables(function.HttpBodyTemplate, inputJson); request.Content = new StringContent(body, Encoding.UTF8, "application/json"); } // 执行请求 var response = await client.SendAsync(request, ct); var responseBody = await response.Content.ReadAsStringAsync(ct); if (!response.IsSuccessStatusCode) { logger.LogWarning("HTTP 函数 {FunctionId} 调用 {Url} 返回非成功状态 {Status}", function.Id, url, (int)response.StatusCode); throw new BusinessException( $"HTTP 函数调用失败:{(int)response.StatusCode} {response.StatusCode}" + (string.IsNullOrEmpty(responseBody) ? "" : $"\n{Truncate(responseBody, 500)}")); } // 尝试将响应体解析为 JSON;若不是合法 JSON 则包装为 { result: "" } try { using var doc = JsonDocument.Parse(responseBody); return JsonSerializer.Serialize(doc.RootElement, JsonOptions); } catch (JsonException) { return JsonSerializer.Serialize(new { result = responseBody }, JsonOptions); } } /// /// 替换模板中的 $input.xxx 占位符为 inputJson 中对应路径的值。 /// 占位符出现在字符串中会被原地替换为 JSON 字面量(去掉外层引号的 JSON token)。 /// private static string ReplaceVariables(string template, string inputJson) { if (string.IsNullOrEmpty(template)) return template; using var input = string.IsNullOrWhiteSpace(inputJson) ? JsonDocument.Parse("{}") : JsonDocument.Parse(inputJson); var result = new StringBuilder(template); var i = 0; while ((i = result.ToString().IndexOf("$input.", i, StringComparison.Ordinal)) >= 0) { // 提取 $input. 后的路径(支持字母数字下划线和点) var end = i + "$input.".Length; while (end < result.Length && (char.IsLetterOrDigit(result[end]) || result[end] == '_' || result[end] == '.')) { end++; } var path = result.ToString(i + "$input.".Length, end - i - "$input.".Length); var value = ReadPath(input.RootElement, path); var literal = value switch { { } v when v.ValueKind == JsonValueKind.String => v.GetString() ?? "", { } v when v.ValueKind == JsonValueKind.Number => v.GetRawText(), { } v when v.ValueKind == JsonValueKind.True => "true", { } v when v.ValueKind == JsonValueKind.False => "false", { } v when v.ValueKind == JsonValueKind.Null => "null", { } v when v.ValueKind == JsonValueKind.Object => v.GetRawText(), { } v when v.ValueKind == JsonValueKind.Array => v.GetRawText(), _ => "", }; result.Remove(i, end - i); result.Insert(i, literal); i += literal.Length; } return result.ToString(); } private static JsonElement? ReadPath(JsonElement root, string path) { var current = root; foreach (var segment in path.Split('.', StringSplitOptions.RemoveEmptyEntries)) { if (current.ValueKind != JsonValueKind.Object || !current.TryGetProperty(segment, out current)) { return null; } } return current; } /// SSRF 防护:校验 URL 主机在白名单内,拒绝 localhost 与私有/内网 IP。 internal bool IsHostAllowed(string url) { if (_options.Webhook.AllowedHosts is null || _options.Webhook.AllowedHosts.Length == 0) { return false; } if (!Uri.TryCreate(url, UriKind.Absolute, out var uri)) { return false; } var host = uri.Host; if (string.Equals(host, "localhost", StringComparison.OrdinalIgnoreCase)) { return false; } if (IPAddress.TryParse(host, out var ip)) { if (IPAddress.IsLoopback(ip)) return false; if (ip.AddressFamily == AddressFamily.InterNetwork) { var bytes = ip.GetAddressBytes(); if (bytes[0] == 10) return false; if (bytes[0] == 172 && bytes[1] >= 16 && bytes[1] <= 31) return false; if (bytes[0] == 192 && bytes[1] == 168) return false; } } return _options.Webhook.AllowedHosts .Any(allowed => string.Equals(allowed, host, StringComparison.OrdinalIgnoreCase)); } private static string Truncate(string text, int maxLength) => text.Length <= maxLength ? text : $"{text[..maxLength]}..."; }