using System.Diagnostics; using System.Net; using System.Text; using System.Text.Json; using SGU.AuthBroker.Core.Authentication; using SGU.AuthBroker.Core.Identity; using SGU.AuthBroker.Core.Profiles; using SGU.AuthBroker.Options; namespace SGU.AuthBroker.Services; public sealed class NtlmCredentialValidator : INtlmCredentialValidator { private readonly NtlmOptions options; private readonly ILogger logger; private readonly Func handlerFactory; public NtlmCredentialValidator( BrokerOptions options, ILogger logger) : this(options, logger, CreateHandler) { } internal NtlmCredentialValidator( BrokerOptions options, ILogger logger, Func handlerFactory) { this.options = options.Ntlm; this.logger = logger; this.handlerFactory = handlerFactory; } public async Task ValidateAsync( UserIdentity identity, string password, CancellationToken cancellationToken) { using IDisposable? logScope = logger.BeginScope( "InstitutionalUser={InstitutionalUser}; InstitutionalRole={InstitutionalRole}", identity.UserName, identity.Role); Uri authenticationUri = new( new Uri(options.Endpoint, UriKind.Absolute), options.AuthenticationPath); HashSet allowedHosts = new( this.options.AllowedRedirectHosts, StringComparer.OrdinalIgnoreCase); NetworkCredential credential = new(identity.UserName, password, this.options.Domain); CredentialCache credentialCache = new(); CookieContainer cookieContainer = new(); using HttpMessageHandler handler = handlerFactory(credentialCache, cookieContainer); using HttpClient client = new(handler) { Timeout = Timeout.InfiniteTimeSpan, DefaultRequestVersion = HttpVersion.Version11, DefaultVersionPolicy = HttpVersionPolicy.RequestVersionExact }; client.DefaultRequestHeaders.UserAgent.ParseAdd("SGU-AuthBroker/1.0"); try { (NtlmValidationResult? authenticationFailure, Uri? continuationUri) = await ValidateCredentialsAsync( client, authenticationUri, allowedHosts, credentialCache, credential, cancellationToken).ConfigureAwait(false); if (authenticationFailure is not null) { return authenticationFailure; } InstitutionalProfile? profile = await TryFetchProfileAsync( client, identity, allowedHosts, continuationUri, cancellationToken).ConfigureAwait(false); return NtlmValidationResult.Valid(profile); } finally { credential.Password = string.Empty; } } private async Task<(NtlmValidationResult? Failure, Uri? ContinuationUri)> ValidateCredentialsAsync( HttpClient client, Uri authenticationUri, HashSet allowedHosts, CredentialCache credentialCache, NetworkCredential credential, CancellationToken cancellationToken) { if (!IsAllowedHttpsUri(authenticationUri, allowedHosts)) { return (NtlmValidationResult.Unavailable("NTLM_AUTH_ENDPOINT_REJECTED"), null); } using CancellationTokenSource timeout = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); timeout.CancelAfter(TimeSpan.FromSeconds(options.TimeoutSeconds)); Stopwatch elapsed = Stopwatch.StartNew(); try { Uri current = authenticationUri; for (int hop = 0; hop <= options.MaxRedirects; hop++) { if (!IsAllowedHttpsUri(current, allowedHosts)) { return (NtlmValidationResult.Unavailable("NTLM_AUTH_ENDPOINT_REJECTED"), null); } using HttpRequestMessage probeRequest = new(HttpMethod.Get, current); using HttpResponseMessage probeResponse = await client .SendAsync(probeRequest, HttpCompletionOption.ResponseHeadersRead, timeout.Token) .ConfigureAwait(false); int probeStatus = (int)probeResponse.StatusCode; if (probeStatus is >= 300 and < 400) { Uri? redirect = ResolveAllowedRedirect(current, probeResponse, allowedHosts); if (redirect is null) { return (NtlmValidationResult.Unavailable("NTLM_AUTH_REDIRECT_REJECTED"), null); } logger.LogDebug( "SGU authentication discovery followed redirect hop {Hop} to {Path}.", hop + 1, redirect.AbsolutePath); current = redirect; continue; } if (probeResponse.StatusCode == HttpStatusCode.Unauthorized) { if (!OffersNtlmChallenge(probeResponse)) { logger.LogWarning( "SGU returned 401 without an NTLM challenge at {Path}.", current.AbsolutePath); return (NtlmValidationResult.Unavailable("NTLM_CHALLENGE_MISSING"), null); } await DrainResponseAsync(probeResponse, timeout.Token).ConfigureAwait(false); AddCredential(current, credentialCache, credential); (NtlmValidationResult? failure, Uri? continuationUri) = await AuthenticateChallengedEndpointAsync( client, current, allowedHosts, timeout.Token).ConfigureAwait(false); if (failure is not null) { return (failure, null); } logger.LogInformation( BrokerEventIds.SguAuthenticationAccepted, "SGU accepted credentials after an explicit NTLM challenge in {ElapsedMilliseconds} ms.", elapsed.ElapsedMilliseconds); return (null, continuationUri); } if (probeResponse.StatusCode == HttpStatusCode.Forbidden) { return (NtlmValidationResult.Unavailable("NTLM_CHALLENGE_REJECTED"), null); } if (probeStatus is >= 200 and < 300) { logger.LogWarning( "SGU authentication discovery reached {StatusCode} at {Path} without an NTLM challenge; credentials were not accepted.", probeStatus, current.AbsolutePath); return (NtlmValidationResult.Unavailable("NTLM_CHALLENGE_MISSING"), null); } return probeStatus is 429 or >= 500 ? (NtlmValidationResult.Unavailable("NTLM_UPSTREAM_ERROR"), null) : (NtlmValidationResult.Unavailable("NTLM_UNEXPECTED_RESPONSE"), null); } return (NtlmValidationResult.Unavailable("NTLM_REDIRECT_LIMIT"), null); } catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested) { logger.LogWarning( BrokerEventIds.SguAuthenticationTimeout, "SGU NTLM authentication timed out after {ElapsedMilliseconds} ms.", elapsed.ElapsedMilliseconds); return (NtlmValidationResult.Unavailable("NTLM_TIMEOUT"), null); } catch (HttpRequestException exception) { logger.LogWarning( BrokerEventIds.SguAuthenticationNetworkFailure, exception, "SGU NTLM authentication failed after {ElapsedMilliseconds} ms.", elapsed.ElapsedMilliseconds); return (NtlmValidationResult.Unavailable(), null); } } private static async Task<(NtlmValidationResult? Failure, Uri? ContinuationUri)> AuthenticateChallengedEndpointAsync( HttpClient client, Uri challengeUri, HashSet allowedHosts, CancellationToken cancellationToken) { using HttpRequestMessage authenticationRequest = new(HttpMethod.Get, challengeUri); using HttpResponseMessage authenticationResponse = await client .SendAsync(authenticationRequest, HttpCompletionOption.ResponseHeadersRead, cancellationToken) .ConfigureAwait(false); if (authenticationResponse.StatusCode is HttpStatusCode.Unauthorized or HttpStatusCode.Forbidden) { return (NtlmValidationResult.Invalid(), null); } int statusCode = (int)authenticationResponse.StatusCode; if (statusCode is >= 200 and < 300) { await DrainResponseAsync(authenticationResponse, cancellationToken).ConfigureAwait(false); return (null, null); } if (statusCode is >= 300 and < 400) { Uri? continuationUri = ResolveAllowedRedirect( challengeUri, authenticationResponse, allowedHosts); if (continuationUri is null) { return (NtlmValidationResult.Unavailable("NTLM_AUTH_REDIRECT_REJECTED"), null); } await DrainResponseAsync(authenticationResponse, cancellationToken).ConfigureAwait(false); return (null, continuationUri); } return statusCode is 429 or >= 500 ? (NtlmValidationResult.Unavailable("NTLM_UPSTREAM_ERROR"), null) : (NtlmValidationResult.Unavailable("NTLM_UNEXPECTED_RESPONSE"), null); } private async Task TryFetchProfileAsync( HttpClient client, UserIdentity identity, HashSet allowedHosts, Uri? continuationUri, CancellationToken cancellationToken) { Stopwatch elapsed = Stopwatch.StartNew(); try { Uri originalProfileUri = GetProfileUri(identity.Role); Uri menuUri = GetProfileUri(InstitutionalRole.Professor); Uri current = continuationUri ?? originalProfileUri; bool bootstrappingSession = continuationUri is not null; bool retriedAfterSessionBootstrap = continuationUri is not null; using CancellationTokenSource timeout = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); timeout.CancelAfter(TimeSpan.FromSeconds(options.ProfileTimeoutSeconds)); for (int hop = 0; hop <= options.MaxRedirects; hop++) { if (!IsAllowedHttpsUri(current, allowedHosts)) { return null; } using HttpRequestMessage request = new(HttpMethod.Get, current); using HttpResponseMessage response = await client .SendAsync(request, HttpCompletionOption.ResponseHeadersRead, timeout.Token) .ConfigureAwait(false); int statusCode = (int)response.StatusCode; logger.LogDebug( "SGU profile response for role {Role} returned HTTP {StatusCode} at hop {Hop} after {ElapsedMilliseconds} ms.", identity.Role, statusCode, hop, elapsed.ElapsedMilliseconds); if (statusCode is >= 300 and < 400) { Uri? redirect = ResolveAllowedRedirect(current, response, allowedHosts); if (redirect is null) { logger.LogWarning( "SGU profile redirect was rejected for role {Role} at hop {Hop}.", identity.Role, hop + 1); return null; } await DrainResponseAsync(response, timeout.Token).ConfigureAwait(false); current = redirect; continue; } if (statusCode is >= 200 and < 300) { if (bootstrappingSession) { await DrainResponseAsync(response, timeout.Token).ConfigureAwait(false); bootstrappingSession = false; current = originalProfileUri; logger.LogInformation( "SGU post-authentication session bootstrap completed for role {Role}; requesting the original profile page.", identity.Role); continue; } if (identity.Role != InstitutionalRole.Professor && !retriedAfterSessionBootstrap && HasSamePath(current, menuUri)) { await DrainResponseAsync(response, timeout.Token).ConfigureAwait(false); retriedAfterSessionBootstrap = true; current = originalProfileUri; logger.LogInformation( "SGU ASP.NET session bootstrap completed for role {Role}; retrying the original profile page.", identity.Role); continue; } InstitutionalProfile? profile = await TryReadProfileAsync( response, identity, timeout.Token).ConfigureAwait(false); bool mayEnrichStaffProfile = (identity.Role == InstitutionalRole.Administrative && string.Equals( profile?.EmployeeNumber, identity.NumericId, StringComparison.Ordinal)) || (identity.Role == InstitutionalRole.Professor && profile is not null); if (mayEnrichStaffProfile) { profile = await TryEnrichStaffProfileAsync( client, profile!, identity, allowedHosts, timeout.Token, cancellationToken, elapsed).ConfigureAwait(false); } if (profile is null) { logger.LogWarning( BrokerEventIds.ProfileHtmlUnexpected, "SGU returned a profile page for role {Role}, but no supported profile fields were found after {ElapsedMilliseconds} ms.", identity.Role, elapsed.ElapsedMilliseconds); } else { logger.LogInformation( BrokerEventIds.ProfileEnrichmentCompleted, "SGU profile enrichment completed for role {Role} with {ProfileFieldCount} supported fields in {ElapsedMilliseconds} ms.", identity.Role, CountProfileFields(profile), elapsed.ElapsedMilliseconds); } return profile; } logger.LogWarning( "SGU profile request for role {Role} returned HTTP {StatusCode} after {ElapsedMilliseconds} ms.", identity.Role, statusCode, elapsed.ElapsedMilliseconds); return null; } logger.LogWarning( "SGU profile request for role {Role} exceeded the redirect limit after {ElapsedMilliseconds} ms.", identity.Role, elapsed.ElapsedMilliseconds); } catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) { throw; } catch (OperationCanceledException) { logger.LogWarning( BrokerEventIds.ProfileEnrichmentTimeout, "SGU profile request for role {Role} timed out after {ElapsedMilliseconds} ms.", identity.Role, elapsed.ElapsedMilliseconds); } catch (Exception exception) { logger.LogWarning( BrokerEventIds.ProfileEnrichmentFailure, exception, "SGU profile enrichment failed for role {Role} after {ElapsedMilliseconds} ms.", identity.Role, elapsed.ElapsedMilliseconds); } return null; } private async Task TryEnrichStaffProfileAsync( HttpClient client, InstitutionalProfile baseProfile, UserIdentity identity, HashSet allowedHosts, CancellationToken timeoutToken, CancellationToken requestCancellationToken, Stopwatch elapsed) { InstitutionalProfile profile = baseProfile; List<(string Path, Func Parser)> pages = []; if (identity.Role == InstitutionalRole.Professor) { pages.Add(( options.ProfessorPayrollProfilePath, html => SguProfileParser.ParseProfessorPayroll(html, identity.NumericId))); } pages.Add(( options.AdministrativePersonalProfilePath, SguProfileParser.ParseAdministrativePersonal)); foreach ((string path, Func parser) in pages) { try { string? html = await TryFetchAdditionalProfilePageAsync( client, GetProfileUri(path), allowedHosts, timeoutToken).ConfigureAwait(false); if (html is null) { logger.LogWarning( BrokerEventIds.ProfilePageUnavailable, "Optional SGU profile page {Path} did not return usable HTML for role {Role}; preserving fields already collected.", path, identity.Role); continue; } InstitutionalProfile? pageProfile = parser(html); if (pageProfile is null) { logger.LogWarning( BrokerEventIds.ProfileHtmlUnexpected, "Optional SGU profile page {Path} returned HTML without its supported field IDs for role {Role}; preserving fields already collected.", path, identity.Role); continue; } profile = profile.Overlay(pageProfile); } catch (OperationCanceledException) when (!requestCancellationToken.IsCancellationRequested) { logger.LogWarning( BrokerEventIds.ProfileEnrichmentTimeout, "SGU optional staff profile enrichment for role {Role} reached its total timeout after {ElapsedMilliseconds} ms; preserving fields already collected.", identity.Role, elapsed.ElapsedMilliseconds); return profile; } catch (Exception exception) { logger.LogWarning( BrokerEventIds.ProfileEnrichmentFailure, exception, "An optional SGU staff profile page for role {Role} failed after {ElapsedMilliseconds} ms; preserving fields already collected.", identity.Role, elapsed.ElapsedMilliseconds); } } return await TryEnrichStaffLocationAsync( client, profile, identity, allowedHosts, timeoutToken, requestCancellationToken, elapsed).ConfigureAwait(false); } private async Task TryEnrichStaffLocationAsync( HttpClient client, InstitutionalProfile profile, UserIdentity identity, HashSet allowedHosts, CancellationToken timeoutToken, CancellationToken requestCancellationToken, Stopwatch elapsed) { string path = options.AdministrativeLocationProfilePath; Uri locationPageUri = GetProfileUri(path); try { string? html = await TryFetchAdditionalProfilePageAsync( client, locationPageUri, allowedHosts, timeoutToken).ConfigureAwait(false); if (html is null) { logger.LogWarning( BrokerEventIds.ProfilePageUnavailable, "Optional SGU profile page {Path} did not return usable HTML for role {Role}; preserving fields already collected.", path, identity.Role); return profile; } InstitutionalProfile? staticLocation = SguProfileParser.ParseAdministrativeLocation(html); if (staticLocation is null) { logger.LogWarning( BrokerEventIds.ProfileHtmlUnexpected, "Optional SGU profile page {Path} returned HTML without its supported field IDs for role {Role}; preserving fields already collected.", path, identity.Role); return profile; } profile = profile.Overlay(staticLocation); if (string.IsNullOrWhiteSpace(staticLocation.PostalCode)) { return profile; } string? directionJson = await TryPostProfilePageMethodAsync( client, GetAdministrativeLocationMethodUri("GetDireccion"), locationPageUri, new Dictionary { ["CodigoPostal"] = staticLocation.PostalCode }, allowedHosts, timeoutToken).ConfigureAwait(false); if (directionJson is null) { return profile; } SguAdministrativeLocationSelection? selection = SguProfileParser.ParseAdministrativeLocationSelection( directionJson, staticLocation.PostalCode); if (selection is null) { logger.LogWarning( BrokerEventIds.ProfileHtmlUnexpected, "SGU location method GetDireccion returned an unexpected payload for role {Role}; preserving the static address fields.", identity.Role); return profile; } string? localitiesJson = null; if (!string.IsNullOrWhiteSpace(selection.StateId)) { localitiesJson = await TryPostProfilePageMethodAsync( client, GetAdministrativeLocationMethodUri("GetLocalidadListado"), locationPageUri, new Dictionary { ["pIdEstado"] = selection.StateId }, allowedHosts, timeoutToken).ConfigureAwait(false); } string? neighborhoodsJson = await TryPostProfilePageMethodAsync( client, GetAdministrativeLocationMethodUri("GetColoniasListado"), locationPageUri, new Dictionary { ["pIdEstado"] = string.Empty, ["pLocalidad"] = string.Empty, ["CodigoPostal"] = selection.PostalCode ?? staticLocation.PostalCode }, allowedHosts, timeoutToken).ConfigureAwait(false); InstitutionalProfile? resolvedLocation = SguProfileParser.ParseAdministrativeLocation( html, selection, localitiesJson, neighborhoodsJson); return profile.Overlay(resolvedLocation); } catch (OperationCanceledException) when (!requestCancellationToken.IsCancellationRequested) { logger.LogWarning( BrokerEventIds.ProfileEnrichmentTimeout, "SGU optional staff location enrichment for role {Role} reached its total timeout after {ElapsedMilliseconds} ms; preserving fields already collected.", identity.Role, elapsed.ElapsedMilliseconds); } catch (Exception exception) { logger.LogWarning( BrokerEventIds.ProfileEnrichmentFailure, exception, "SGU optional staff location enrichment failed for role {Role} after {ElapsedMilliseconds} ms; preserving fields already collected.", identity.Role, elapsed.ElapsedMilliseconds); } return profile; } private async Task TryFetchAdditionalProfilePageAsync( HttpClient client, Uri requestedUri, HashSet allowedHosts, CancellationToken cancellationToken) { Uri current = requestedUri; for (int hop = 0; hop <= options.MaxRedirects; hop++) { if (!IsAllowedHttpsUri(current, allowedHosts)) { return null; } using HttpRequestMessage request = new(HttpMethod.Get, current); using HttpResponseMessage response = await client .SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken) .ConfigureAwait(false); int statusCode = (int)response.StatusCode; if (statusCode is >= 300 and < 400) { Uri? redirect = ResolveAllowedRedirect(current, response, allowedHosts); if (redirect is null) { return null; } await DrainResponseAsync(response, cancellationToken).ConfigureAwait(false); current = redirect; continue; } if (statusCode is >= 200 and < 300) { return await ReadLimitedStringAsync( response.Content, options.MaxProfileBytes, cancellationToken).ConfigureAwait(false); } logger.LogWarning( BrokerEventIds.ProfilePageUnavailable, "Optional SGU profile page {Path} returned HTTP {StatusCode}.", requestedUri.AbsolutePath, statusCode); return null; } logger.LogWarning( BrokerEventIds.ProfilePageUnavailable, "Optional SGU profile page {Path} exceeded the redirect limit.", requestedUri.AbsolutePath); return null; } private async Task TryPostProfilePageMethodAsync( HttpClient client, Uri requestedUri, Uri referrerUri, IReadOnlyDictionary payload, HashSet allowedHosts, CancellationToken cancellationToken) { if (!IsAllowedHttpsUri(requestedUri, allowedHosts) || !IsAllowedHttpsUri(referrerUri, allowedHosts)) { return null; } using HttpRequestMessage request = new(HttpMethod.Post, requestedUri); request.Headers.Referrer = referrerUri; request.Content = new StringContent( JsonSerializer.Serialize(payload), Encoding.UTF8, "application/json"); using HttpResponseMessage response = await client .SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken) .ConfigureAwait(false); int statusCode = (int)response.StatusCode; if (statusCode is >= 200 and < 300) { return await ReadLimitedStringAsync( response.Content, options.MaxProfileBytes, cancellationToken).ConfigureAwait(false); } logger.LogWarning( BrokerEventIds.ProfilePageUnavailable, "Optional SGU profile method {Path} returned HTTP {StatusCode}.", requestedUri.AbsolutePath, statusCode); return null; } private static void AddCredential( Uri uri, CredentialCache credentialCache, NetworkCredential credential) { string authority = uri.GetLeftPart(UriPartial.Authority); credentialCache.Add(new Uri(authority + "/"), "NTLM", credential); } private static bool OffersNtlmChallenge(HttpResponseMessage response) => response.Headers.WwwAuthenticate.Any(value => string.Equals(value.Scheme, "NTLM", StringComparison.OrdinalIgnoreCase)); private static Uri? ResolveAllowedRedirect( Uri current, HttpResponseMessage response, HashSet allowedHosts) { Uri? location = response.Headers.Location; Uri? redirect = location is null ? null : location.IsAbsoluteUri ? location : new Uri(current, location); return redirect is not null && IsAllowedHttpsUri(redirect, allowedHosts) ? redirect : null; } private static HttpMessageHandler CreateHandler( CredentialCache credentialCache, CookieContainer cookieContainer) => new HttpClientHandler { AllowAutoRedirect = false, AutomaticDecompression = DecompressionMethods.All, CheckCertificateRevocationList = true, CookieContainer = cookieContainer, Credentials = credentialCache, MaxConnectionsPerServer = 4, MaxResponseHeadersLength = 64, PreAuthenticate = false, UseCookies = true, UseDefaultCredentials = false, UseProxy = false }; private static bool HasSamePath(Uri left, Uri right) => string.Equals( left.AbsolutePath.TrimEnd('/'), right.AbsolutePath.TrimEnd('/'), StringComparison.OrdinalIgnoreCase); private static int CountProfileFields(InstitutionalProfile profile) => new[] { profile.EmployeeNumber, profile.DisplayName, profile.GivenName, profile.Surname, profile.Email, profile.EmployeeType, profile.JobTitle, profile.Department, profile.StreetAddress, profile.City, profile.State, profile.PostalCode }.Count(value => !string.IsNullOrWhiteSpace(value)); private static async Task DrainResponseAsync( HttpResponseMessage response, CancellationToken cancellationToken) { const int maximumDrainBytes = 64 * 1024; if (response.Content.Headers.ContentLength is > maximumDrainBytes) { return; } await using Stream stream = await response.Content .ReadAsStreamAsync(cancellationToken) .ConfigureAwait(false); byte[] buffer = new byte[8192]; int total = 0; while (total <= maximumDrainBytes) { int read = await stream .ReadAsync(buffer.AsMemory(), cancellationToken) .ConfigureAwait(false); if (read == 0) { return; } total += read; } } private Uri GetProfileUri(InstitutionalRole role) { Uri endpoint = new(options.Endpoint, UriKind.Absolute); string path = role switch { InstitutionalRole.Administrative => options.AdministrativeProfilePath, InstitutionalRole.Student => options.StudentProfilePath, InstitutionalRole.Professor => options.MenuProfilePath, _ => throw new ArgumentOutOfRangeException(nameof(role), role, null) }; return new Uri(endpoint, path); } private Uri GetProfileUri(string path) => new(new Uri(options.Endpoint, UriKind.Absolute), path); private Uri GetAdministrativeLocationMethodUri(string methodName) { Uri pageUri = GetProfileUri(options.AdministrativeLocationProfilePath); return new Uri($"{pageUri.GetLeftPart(UriPartial.Path).TrimEnd('/')}/{methodName}"); } private async Task TryReadProfileAsync( HttpResponseMessage response, UserIdentity identity, CancellationToken timeoutToken) { string html = await ReadLimitedStringAsync( response.Content, options.MaxProfileBytes, timeoutToken).ConfigureAwait(false); InstitutionalProfile? profile; switch (identity.Role) { case InstitutionalRole.Administrative: profile = SguProfileParser.ParseAdministrative(html, identity.NumericId); break; case InstitutionalRole.Student: profile = SguProfileParser.ParseStudent(html, identity.NumericId); break; case InstitutionalRole.Professor: return SguProfileParser.ParseMenu(html); default: return null; } if (profile is not null) { return profile; } logger.LogWarning( BrokerEventIds.ProfileHtmlUnexpected, "The primary SGU profile HTML did not contain the supported field IDs for role {Role}; attempting the menu-name fallback.", identity.Role); return SguProfileParser.ParseMenu(html); } private static async Task ReadLimitedStringAsync( HttpContent content, int maximumBytes, CancellationToken cancellationToken) { if (content.Headers.ContentLength is long contentLength && contentLength > maximumBytes) { throw new InvalidDataException("The SGU profile response exceeded the configured limit."); } await using Stream stream = await content .ReadAsStreamAsync(cancellationToken) .ConfigureAwait(false); using MemoryStream buffer = new(Math.Min(maximumBytes, 64 * 1024)); byte[] chunk = new byte[8192]; while (true) { int read = await stream .ReadAsync(chunk.AsMemory(), cancellationToken) .ConfigureAwait(false); if (read == 0) { break; } if (buffer.Length + read > maximumBytes) { throw new InvalidDataException("The SGU profile response exceeded the configured limit."); } buffer.Write(chunk, 0, read); } return SguHtmlDecoder.Decode( buffer.GetBuffer().AsSpan(0, checked((int)buffer.Length)), content.Headers.ContentType?.CharSet); } private static bool IsAllowedHttpsUri(Uri uri, HashSet allowedHosts) => uri.Scheme == Uri.UriSchemeHttps && string.IsNullOrEmpty(uri.UserInfo) && allowedHosts.Contains(uri.IdnHost); }