using System.Net; using System.Net.Sockets; using AIStudio.Provider; namespace AIStudio.Tools.Web; public sealed class WebPageRetrievalService(HTMLParser htmlParser) { private const int MAX_RESPONSE_BYTES = 5 * 1024 * 1024; // 5MB public async Task RetrieveAsync( Uri url, WebPageRetrievalOptions options, CancellationToken token = default) { var triedOsSso = false; HTMLParserWebPage page; try { page = await htmlParser.LoadWebPageAsync( url, token, options.TimeoutSeconds, async (candidateUrl, validationToken) => await ResolveValidatedUrlAddressesAsync(candidateUrl, options, validationToken), MAX_RESPONSE_BYTES, options.UseOsSso ? ExternalWebAuthenticationMode.OS_DEFAULT_CREDENTIALS : ExternalWebAuthenticationMode.NONE, shouldUseDefaultCredentials: (candidateUrl, addresses) => { var shouldTryOsSso = ShouldTryOsSso(url, candidateUrl, addresses, options); triedOsSso |= shouldTryOsSso; return shouldTryOsSso; }); } catch (OperationCanceledException) when (!token.IsCancellationRequested) { throw new TimeoutException($"Loading the web page timed out after {options.TimeoutSeconds} seconds."); } catch (HttpRequestException exception) { if (FindBlockedException(exception) is { } blockedException) throw blockedException; if (triedOsSso && exception.StatusCode is HttpStatusCode.Unauthorized) { throw new InvalidOperationException( $"Loading the web page failed: The server returned HTTP 401 (Unauthorized) for '{url}'. The host is reachable and AI Studio already tried your operating system's default sign-in, but the server did not accept it or requires an additional browser session/cookies.", exception); } throw new InvalidOperationException($"Loading the web page failed: {exception.Message}", exception); } if (!IsSupportedHtmlContentType(page.ContentType)) throw new InvalidOperationException($"Unsupported content type '{page.ContentType}'. Only HTML pages are supported."); return new RetrievedWebPage { Page = page, ExtractedPage = WebPageContentExtractor.Extract(htmlParser, page.Document, page.FinalUrl), RetrievedAtUtc = DateTimeOffset.UtcNow, }; } private static WebPageAccessBlockedException? FindBlockedException(Exception exception) { if (exception is WebPageAccessBlockedException blockedException) return blockedException; if (exception is AggregateException aggregateException) { foreach (var innerException in aggregateException.InnerExceptions) { if (FindBlockedException(innerException) is { } innerBlockedException) return innerBlockedException; } } return exception.InnerException is null ? null : FindBlockedException(exception.InnerException); } private static async Task> ResolveValidatedUrlAddressesAsync( Uri url, WebPageRetrievalOptions options, CancellationToken token) { if (url is not { Scheme: "http" or "https" }) throw new WebPageAccessBlockedException( "Only HTTP and HTTPS URLs are supported.", WebPageAccessBlockReason.UNSUPPORTED_SCHEME); if (IsBlockedHostName(url.Host)) throw new WebPageAccessBlockedException( "Local web page URLs are not supported.", WebPageAccessBlockReason.LOCAL_HOST_NAME); var addresses = await ResolveHostAddressesAsync(url, token); if (addresses.Count == 0) throw new InvalidOperationException($"The host '{url.Host}' did not resolve to an IP address."); if (addresses.Any(IsNeverAllowedAddress)) throw new WebPageAccessBlockedException( "Local, link-local, multicast, and unspecified network addresses are not supported.", WebPageAccessBlockReason.NEVER_ALLOWED_ADDRESS); if (!addresses.Any(IsNonPublicAddress)) return addresses; if (options.PublicTargetsOnly || options.IsPrivateHostAllowed?.Invoke(url.Host) is not true) throw new WebPageAccessBlockedException( "Private or local-network web page URLs are not supported unless their host is explicitly allowed.", WebPageAccessBlockReason.PRIVATE_HOST_NOT_ALLOWED); if (options.ProviderConfidence >= ConfidenceLevel.HIGH) return addresses; if (options.OnPrivateHostProviderBlockAsync is not null) await options.OnPrivateHostProviderBlockAsync(url, options.ProviderConfidence); throw new WebPageAccessBlockedException( "This private or VPN web page requires a High-confidence provider.", WebPageAccessBlockReason.INSUFFICIENT_PROVIDER_CONFIDENCE); } private static async Task> ResolveHostAddressesAsync(Uri url, CancellationToken token) { if (IPAddress.TryParse(url.Host, out var parsedAddress)) return [NormalizeAddress(parsedAddress)]; try { return (await Dns.GetHostAddressesAsync(url.DnsSafeHost, token)) .Select(NormalizeAddress) .ToList(); } catch (SocketException exception) { throw new InvalidOperationException($"The host '{url.Host}' could not be resolved: {exception.Message}", exception); } } private static bool ShouldTryOsSso( Uri originalUrl, Uri candidateUrl, IReadOnlyList addresses, WebPageRetrievalOptions options) => options.UseOsSso && options.ProviderConfidence >= ConfidenceLevel.HIGH && originalUrl.Scheme.Equals(candidateUrl.Scheme, StringComparison.OrdinalIgnoreCase) && originalUrl.Host.Equals(candidateUrl.Host, StringComparison.OrdinalIgnoreCase) && originalUrl.Port == candidateUrl.Port && !IsBlockedHostName(candidateUrl.Host) && options.IsPrivateHostAllowed?.Invoke(candidateUrl.Host) is true && addresses.Count > 0 && addresses.All(IsNonPublicAddress); private static IPAddress NormalizeAddress(IPAddress address) => address.IsIPv4MappedToIPv6 ? address.MapToIPv4() : address; private static bool IsBlockedHostName(string host) { var normalizedHost = WebHostHelper.Normalize(host); return normalizedHost is "localhost" || normalizedHost.EndsWith(".localhost", StringComparison.Ordinal); } private static bool IsNeverAllowedAddress(IPAddress address) { address = NormalizeAddress(address); if (IPAddress.IsLoopback(address)) return true; if (address.AddressFamily is AddressFamily.InterNetwork) { var bytes = address.GetAddressBytes(); return address.Equals(IPAddress.Any) || bytes[0] is 0 or 127 or >= 224 || (bytes[0] == 169 && bytes[1] == 254); } if (address.AddressFamily is AddressFamily.InterNetworkV6) { return address.Equals(IPAddress.IPv6Any) || address.Equals(IPAddress.IPv6None) || address.Equals(IPAddress.IPv6Loopback) || address.IsIPv6LinkLocal || address.IsIPv6Multicast; } return true; } private static bool IsNonPublicAddress(IPAddress address) { address = NormalizeAddress(address); if (IsNeverAllowedAddress(address)) return true; if (address.AddressFamily is AddressFamily.InterNetwork) { var bytes = address.GetAddressBytes(); return bytes[0] == 10 || (bytes[0] == 100 && bytes[1] is >= 64 and <= 127) || (bytes[0] == 172 && bytes[1] is >= 16 and <= 31) || (bytes[0] == 192 && bytes[1] == 168) || (bytes[0] == 192 && bytes[1] == 0 && bytes[2] == 0) || (bytes[0] == 192 && bytes[1] == 0 && bytes[2] == 2) || (bytes[0] == 198 && bytes[1] is 18 or 19) || (bytes[0] == 198 && bytes[1] == 51 && bytes[2] == 100) || (bytes[0] == 203 && bytes[1] == 0 && bytes[2] == 113); } if (address.AddressFamily is AddressFamily.InterNetworkV6) { var bytes = address.GetAddressBytes(); return (bytes[0] & 0xfe) == 0xfc || address.IsIPv6SiteLocal; } return true; } private static bool IsSupportedHtmlContentType(string? contentType) => string.IsNullOrWhiteSpace(contentType) || contentType.StartsWith("text/html", StringComparison.OrdinalIgnoreCase) || contentType.StartsWith("application/xhtml+xml", StringComparison.OrdinalIgnoreCase); } public sealed class WebPageRetrievalOptions { public required int TimeoutSeconds { get; init; } public bool PublicTargetsOnly { get; init; } public ConfidenceLevel ProviderConfidence { get; init; } = ConfidenceLevel.NONE; public bool UseOsSso { get; init; } public Func? IsPrivateHostAllowed { get; init; } public Func? OnPrivateHostProviderBlockAsync { get; init; } } public sealed class RetrievedWebPage { public required HTMLParserWebPage Page { get; init; } public required ExtractedWebPage ExtractedPage { get; init; } public required DateTimeOffset RetrievedAtUtc { get; init; } }