Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -739,6 +739,7 @@ public HttpListenerContext EndGetContext(IAsyncResult asyncResult)
NegotiateAuthentication? sessionContext = null;
bool keepSessionContext = false;
string? contextPackage = null;
ExtendedProtectionPolicy? contextExtendedProtectionPolicy = null;
AuthenticationSchemes headerScheme = AuthenticationSchemes.None;
AuthenticationSchemes authenticationScheme = AuthenticationSchemes;
ExtendedProtectionPolicy extendedProtectionPolicy = _extendedProtectionPolicy;
Expand All @@ -756,6 +757,7 @@ public HttpListenerContext EndGetContext(IAsyncResult asyncResult)
{
sessionContext = disconnectResult.Session;
contextPackage = disconnectResult.SessionPackage;
contextExtendedProtectionPolicy = disconnectResult.SessionExtendedProtectionPolicy;
}

httpContext = new HttpListenerContext(session, memoryBlob);
Expand Down Expand Up @@ -881,7 +883,8 @@ public HttpListenerContext EndGetContext(IAsyncResult asyncResult)
if (NetEventSource.Log.IsEnabled()) NetEventSource.Info(this, $"context: {sessionContext} for connectionId: {connectionId}");

string package = headerScheme == AuthenticationSchemes.Ntlm ? NegotiationInfoClass.NTLM : NegotiationInfoClass.Negotiate;
if (sessionContext is null || sessionContext.IsAuthenticated || contextPackage != package)
if (sessionContext is null || sessionContext.IsAuthenticated || contextPackage != package ||
!AreExtendedProtectionPoliciesEquivalent(contextExtendedProtectionPolicy, extendedProtectionPolicy))
{
Comment on lines 885 to 888
sessionContext?.Dispose();

Expand Down Expand Up @@ -1110,6 +1113,7 @@ public HttpListenerContext EndGetContext(IAsyncResult asyncResult)

disconnectResult.Session = sessionContext;
disconnectResult.SessionPackage = contextPackage;
disconnectResult.SessionExtendedProtectionPolicy = extendedProtectionPolicy;
// Prevent finally from disposing the context
sessionContext = null;
}
Expand Down Expand Up @@ -1149,6 +1153,7 @@ public HttpListenerContext EndGetContext(IAsyncResult asyncResult)
{
disconnectResult.Session = null;
disconnectResult.SessionPackage = null;
disconnectResult.SessionExtendedProtectionPolicy = null;
}

sessionContext?.Dispose();
Expand Down Expand Up @@ -1244,6 +1249,60 @@ private ExtendedProtectionPolicy GetAuthenticationExtendedProtectionPolicy(Exten
_defaultServiceNames.ServiceNames.Merge("HTTP/localhost"));
}

// Returns true only if the stored policy (used to create an existing NegotiateAuthentication
// context) and the current request's policy are equivalent, meaning the saved context can be
// safely reused. Any difference in enforcement level, scenario, or service names requires a
// fresh context so that the current request's policy is correctly applied.
private static bool AreExtendedProtectionPoliciesEquivalent(ExtendedProtectionPolicy? stored, ExtendedProtectionPolicy current)
{
if (stored is null)
{
// No policy was recorded for the saved context; treat as incompatible to be safe.
return false;
}

if (stored.PolicyEnforcement != current.PolicyEnforcement)
{
return false;
}

// Both are Never — no channel binding or service name enforcement either way.
if (stored.PolicyEnforcement == PolicyEnforcement.Never)
{
return true;
}

if (stored.ProtectionScenario != current.ProtectionScenario)
{
return false;
}

// Compare custom service name lists. Null means "use listener defaults", which is the
// same object for both calls, so null == null is always equivalent.
ServiceNameCollection? storedNames = stored.CustomServiceNames;
ServiceNameCollection? currentNames = current.CustomServiceNames;

if (storedNames is null && currentNames is null)
{
return true;
}

if (storedNames is null || currentNames is null || storedNames.Count != currentNames.Count)
{
return false;
}

foreach (string? name in storedNames)
{
if (!currentNames.Contains(name))
{
return false;
}
}

return true;
}

// This only works for context-destroying errors.
private static HttpStatusCode HttpStatusFromSecurityStatus(NegotiateAuthenticationStatusCode statusErrorCode)
{
Expand Down Expand Up @@ -1702,6 +1761,8 @@ private void HandleDisconnect()
internal NegotiateAuthentication? Session { get; set; }

internal string? SessionPackage { get; set; }

internal ExtendedProtectionPolicy? SessionExtendedProtectionPolicy { get; set; }
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,13 @@
using System.Collections.Generic;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Net.Security;
using System.Net.Sockets;
using System.Security.Authentication.ExtendedProtection;
using System.Text;
using System.Threading.Tasks;

using Microsoft.DotNet.XUnitExtensions;
using Xunit;

namespace System.Net.Tests
Expand Down Expand Up @@ -242,6 +246,33 @@ public async Task NegotiateAuthentication_InvalidRequestHeaders_ReturnsExpectedS
}
}

[ConditionalFact(typeof(Helpers), nameof(Helpers.IsWindowsImplementation))]
public async Task ExtendedProtectionSelectorDelegate_IncreasesPolicyBetweenNtlmLegs_AuthenticationFails()
{
_listener.AuthenticationSchemes = AuthenticationSchemes.Ntlm;

ExtendedProtectionPolicy relaxedPolicy = new ExtendedProtectionPolicy(PolicyEnforcement.Never);
ExtendedProtectionPolicy strictPolicy =
new ExtendedProtectionPolicy(
PolicyEnforcement.Always,
ProtectionScenario.TransportSelected,
new ServiceNameCollection(new[] { "HTTP/strict-only" }));

_listener.ExtendedProtectionSelectorDelegate = request =>
request.QueryString["strict"] == "1" ? strictPolicy : relaxedPolicy;

NtlmHandshakeResult baselineResult = await TryCompleteNtlmOverSingleConnection(secondLegStrict: false);
if (baselineResult == NtlmHandshakeResult.CredentialsUnavailable)
{
throw new SkipTestException("Unable to establish baseline NTLM authentication with default credentials.");
}

Assert.Equal(NtlmHandshakeResult.Authenticated, baselineResult);

NtlmHandshakeResult strictSecondLegResult = await TryCompleteNtlmOverSingleConnection(secondLegStrict: true);
Assert.Equal(NtlmHandshakeResult.Unauthorized, strictSecondLegResult);
}

[Fact]
public async Task AuthenticationSchemeSelectorDelegate_ReturnsInvalidAuthenticationScheme_PerformsNoAuthentication()
{
Expand Down Expand Up @@ -459,6 +490,201 @@ private async Task ValidateNullUser()
private Task ValidateValidUser() =>
ValidateValidUser(string.Format("{0}:{1}", TestUser, TestPassword), TestUser, TestPassword);

private async Task<NtlmHandshakeResult> TryCompleteNtlmOverSingleConnection(bool secondLegStrict)
{
using Socket client = _factory.GetConnectedSocket();
client.ReceiveTimeout = 15000;
client.SendTimeout = 15000;

Task<HttpListenerContext> serverContextTask = _listener.GetContextAsync();

NegotiateAuthenticationClientOptions clientOptions =
new NegotiateAuthenticationClientOptions
{
Package = "NTLM",
Credential = CredentialCache.DefaultNetworkCredentials,
TargetName = "HTTP/lax-target"
};

using NegotiateAuthentication clientContext = new NegotiateAuthentication(clientOptions);

byte[]? type1 = clientContext.GetOutgoingBlob(ReadOnlySpan<byte>.Empty, out NegotiateAuthenticationStatusCode type1Status);
if (type1 is null || type1Status != NegotiateAuthenticationStatusCode.ContinueNeeded)
{
return NtlmHandshakeResult.UnexpectedFailure;
}

Task<ResponseHeaders> firstResponseTask = Task.Run(() =>
SendRequestAndReadHeaders(client, CreateNtlmRequest(Convert.ToBase64String(type1), strict: false)));

Task firstCompletedTask = await Task.WhenAny(serverContextTask, firstResponseTask);
if (firstCompletedTask == serverContextTask)
{
HttpListenerContext unexpectedContext = await serverContextTask;
unexpectedContext.Response.StatusCode = (int)HttpStatusCode.InternalServerError;
unexpectedContext.Response.Close();
return NtlmHandshakeResult.UnexpectedFailure;
}

ResponseHeaders firstResponse = await firstResponseTask;
if (firstResponse.StatusCode != HttpStatusCode.Unauthorized)
{
return NtlmHandshakeResult.UnexpectedFailure;
}

string? challenge = GetNtlmChallenge(firstResponse.Headers);
if (challenge is null)
{
return NtlmHandshakeResult.UnexpectedFailure;
}

byte[]? type2 = Convert.FromBase64String(challenge);
byte[]? type3 = clientContext.GetOutgoingBlob(type2, out NegotiateAuthenticationStatusCode type3Status);
if (type3 is null)
{
return type3Status == NegotiateAuthenticationStatusCode.UnknownCredentials
? NtlmHandshakeResult.CredentialsUnavailable
: NtlmHandshakeResult.UnexpectedFailure;
}

if (type3Status != NegotiateAuthenticationStatusCode.Completed)
{
return NtlmHandshakeResult.UnexpectedFailure;
}

Task<ResponseHeaders> secondResponseTask = Task.Run(() =>
SendRequestAndReadHeaders(client, CreateNtlmRequest(Convert.ToBase64String(type3), secondLegStrict)));

Task completedTask = await Task.WhenAny(serverContextTask, secondResponseTask);
if (completedTask == serverContextTask)
{
HttpListenerContext context = await serverContextTask;
context.Response.StatusCode = (int)HttpStatusCode.NoContent;
context.Response.Close();

ResponseHeaders successfulResponse = await secondResponseTask;
return successfulResponse.StatusCode == HttpStatusCode.NoContent
? NtlmHandshakeResult.Authenticated
: NtlmHandshakeResult.UnexpectedFailure;
}

ResponseHeaders failedResponse = await secondResponseTask;
return failedResponse.StatusCode == HttpStatusCode.Unauthorized
? NtlmHandshakeResult.Unauthorized
: NtlmHandshakeResult.UnexpectedFailure;
}

private byte[] CreateNtlmRequest(string authBlob, bool strict)
{
string query = strict ? "?strict=1" : "?strict=0";
string[] headers =
[
"Connection: keep-alive",
$"Authorization: NTLM {authBlob}"
];

return _factory.GetContent("1.1", "HEAD", query, text: null, headers, headerOnly: true);
}

private static string? GetNtlmChallenge(List<string> headers)
{
foreach (string header in headers)
{
if (!header.StartsWith("WWW-Authenticate:", StringComparison.OrdinalIgnoreCase))
{
continue;
}

string value = header.Substring("WWW-Authenticate:".Length).Trim();
if (!value.StartsWith("NTLM ", StringComparison.OrdinalIgnoreCase))
{
continue;
}

return value.Substring("NTLM ".Length).Trim();
}

return null;
}

private static ResponseHeaders SendRequestAndReadHeaders(Socket client, byte[] requestBytes)
{
int totalSent = 0;
while (totalSent < requestBytes.Length)
{
int sent = client.Send(requestBytes, totalSent, requestBytes.Length - totalSent, SocketFlags.None);
if (sent == 0)
{
throw new InvalidOperationException("Socket closed before request bytes were fully sent.");
}

totalSent += sent;
}

string headersText = ReadHeaders(client);
int separatorIndex = headersText.IndexOf("\r\n", StringComparison.Ordinal);
Assert.True(separatorIndex >= 0, "Response did not include a status line.");

string statusLine = headersText.Substring(0, separatorIndex);
string[] statusLineParts = statusLine.Split(' ', StringSplitOptions.RemoveEmptyEntries);
Assert.True(statusLineParts.Length >= 2, $"Invalid status line: '{statusLine}'");
Assert.True(int.TryParse(statusLineParts[1], out int statusCode), $"Invalid status code in status line: '{statusLine}'");

List<string> headerLines = new List<string>();
int position = separatorIndex + 2;
while (position < headersText.Length)
{
int lineEnd = headersText.IndexOf("\r\n", position, StringComparison.Ordinal);
if (lineEnd < 0)
{
break;
}

if (lineEnd == position)
{
break;
}

headerLines.Add(headersText.Substring(position, lineEnd - position));
position = lineEnd + 2;
}

return new ResponseHeaders((HttpStatusCode)statusCode, headerLines);
}

private static string ReadHeaders(Socket client)
{
StringBuilder builder = new StringBuilder();
byte[] buffer = new byte[1024];

while (true)
{
int bytesRead = client.Receive(buffer);
if (bytesRead == 0)
{
throw new InvalidOperationException("Socket closed before response headers were fully received.");
}

builder.Append(Encoding.ASCII.GetString(buffer, 0, bytesRead));
string response = builder.ToString();
int headerEnd = response.IndexOf("\r\n\r\n", StringComparison.Ordinal);
if (headerEnd >= 0)
{
return response.Substring(0, headerEnd);
}
}
}

private enum NtlmHandshakeResult
{
Authenticated,
Unauthorized,
CredentialsUnavailable,
UnexpectedFailure
}

private sealed record ResponseHeaders(HttpStatusCode StatusCode, List<string> Headers);

private async Task ValidateValidUser(string authHeader, string expectedUsername, string expectedPassword)
{
Task<HttpListenerContext> serverContextTask = _listener.GetContextAsync();
Expand Down
Loading