Skip to content
Merged
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
12 changes: 6 additions & 6 deletions Darling/Darling.Tests/AlertNotebookEndpointTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -121,17 +121,17 @@ private static Task<HttpContext> Send(
});
}

/// <summary>The CI finding (pm-pr): the earlier "authenticated" pins presented <c>?token=</c> but never
/// followed the token->cookie exchange, so they hit <see cref="WebRequestAction.SetCookieAndRedirect"/>
/// (302) instead of the route handler and proved nothing. This performs the SAME two-step exchange
/// <c>DarlingWebHostGateLiveTests.NetworkMode_RightTokenInCidr_PassesTheGates</c> proves
/// the gate does: present <c>?token=</c> once to mint a session cookie from the <c>Set-Cookie</c> response
/// <summary>The CI finding (pm-pr): the earlier "authenticated" pins presented the token but never
/// followed the token->cookie exchange, so they hit the exchange's 302 instead of the route handler and
/// proved nothing. This performs the SAME two-step exchange
/// <c>DarlingWebHostGateLiveTests.NetworkMode_RightTokenInCidr_PassesTheGates</c> proves the gate does:
/// POST the token to the sign-in leg once to mint a session cookie from the <c>Set-Cookie</c> response
/// header, then re-send the real request with that cookie attached, which is what reaches
/// <c>WebAuthAction.Allow</c> and therefore the endpoint's own lambda.</summary>
private static async Task<HttpContext> SendAuthenticated(
TestServer server, string path, IPAddress remote, string host = "192.168.1.205")
{
var exchange = await Send(server, "/", remote, token: "correct-token-value", host: host);
var exchange = await DarlingWebHostGateLiveTests.PostSignIn(server, host, remote, "correct-token-value");
Assert.Equal(StatusCodes.Status302Found, exchange.Response.StatusCode);
var setCookie = exchange.Response.Headers.SetCookie.ToString();
Assert.False(string.IsNullOrEmpty(setCookie), "token exchange did not mint a session cookie");
Expand Down
8 changes: 4 additions & 4 deletions Darling/Darling.Tests/CollectorRuntimeStateTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -355,25 +355,25 @@ public void ThePingRoute_IsInsideTheNetworkModeAuthGate_WhichIsWhatLetsItCarryAF
Assert.Equal(
DarlingWebHostService.WebRequestAction.Forbid,
DarlingWebHostService.DecideWebRequest(
IPAddress.Parse("203.0.113.9"), cidr, isAuthFlowRoute: false, hasValidCookie: false, hasValidToken: false));
IPAddress.Parse("203.0.113.9"), cidr, isAuthFlowRoute: false, hasValidCookie: false));

/* Inside the CIDR but uncredentialed: the login page, NOT this route's body. */
Assert.Equal(
DarlingWebHostService.WebRequestAction.ShowLogin,
DarlingWebHostService.DecideWebRequest(
IPAddress.Parse("192.168.1.50"), cidr, isAuthFlowRoute: false, hasValidCookie: false, hasValidToken: false));
IPAddress.Parse("192.168.1.50"), cidr, isAuthFlowRoute: false, hasValidCookie: false));

/* And loopback too, in network mode — #1649 removed the tokenless local pass. */
Assert.Equal(
DarlingWebHostService.WebRequestAction.ShowLogin,
DarlingWebHostService.DecideWebRequest(
IPAddress.Loopback, cidr, isAuthFlowRoute: false, hasValidCookie: false, hasValidToken: false));
IPAddress.Loopback, cidr, isAuthFlowRoute: false, hasValidCookie: false));

/* Only a credential reaches the route. */
Assert.Equal(
DarlingWebHostService.WebRequestAction.Allow,
DarlingWebHostService.DecideWebRequest(
IPAddress.Parse("192.168.1.50"), cidr, isAuthFlowRoute: false, hasValidCookie: true, hasValidToken: false));
IPAddress.Parse("192.168.1.50"), cidr, isAuthFlowRoute: false, hasValidCookie: true));
}

// ── The census ────────────────────────────────────────────────────────────────────────
Expand Down
1 change: 1 addition & 0 deletions Darling/Darling.Tests/Darling.Tests.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -135,5 +135,6 @@
TimescaleSupport.HypertableCount via the same formula BuildWorkerSizingConfAppend writes for the
managed Windows store, and compose's command: line cannot call into the product. -->
<None Include="..\compose\docker-compose.yml" Link="Fixtures\docker-compose.yml" CopyToOutputDirectory="PreserveNewest" />
<None Include="..\compose\darling.sample.json" Link="Fixtures\compose.darling.sample.json" CopyToOutputDirectory="PreserveNewest" />
</ItemGroup>
</Project>
9 changes: 2 additions & 7 deletions Darling/Darling.Tests/DarlingAllowFromListGateTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -250,17 +250,12 @@ private static async Task<TestServer> BuildWebServer(CidrAllowList allowedCidr,
return app.GetTestServer();
}

/// <summary>A <c>GET /?token=</c> with the RIGHT token from <paramref name="remote"/>: 302 means the gate
/// <summary>A sign-in form POST with the RIGHT token from <paramref name="remote"/>: 302 means the gate
/// exchanged the token for a session cookie (past every gate), 403 means the CIDR gate refused it first.</summary>
private static async Task<int> WebTokenRequestAsync(TestServer server, IPAddress remote, string? forwardedFor = null)
{
var ctx = await server.SendAsync(c =>
var ctx = await DarlingWebHostGateLiveTests.PostSignIn(server, ListenIp, remote, WebToken, configure: c =>
{
c.Request.Method = "GET";
c.Request.Path = "/";
c.Request.QueryString = new QueryString("?token=" + Uri.EscapeDataString(WebToken));
c.Request.Headers.Host = ListenIp;
c.Connection.RemoteIpAddress = remote;
if (forwardedFor is not null)
{
c.Request.Headers["X-Forwarded-For"] = forwardedFor;
Expand Down
20 changes: 10 additions & 10 deletions Darling/Darling.Tests/DarlingCliCommandsTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -111,13 +111,13 @@ public void WebLoginHint_TlsSet_PrintsHttps(string form)
var hint = DarlingCliCommands.BuildWebLoginHint("192.168.1.205", 5153, tls);

/* The network listener speaks TLS only once a certificate is set, so an http:// URL fails the handshake. */
Assert.Equal(" https://192.168.1.205:5153/?token=<your-access-token>", hint[1]);
Assert.Equal(" https://192.168.1.205:5153/", hint[1]);
Assert.DoesNotContain(hint, line => line.Contains("http://", StringComparison.Ordinal));

/* A block the service REFUSES (both forms) leaves the dashboard loopback-only: no remote URL, so http stays. */
var refused = DarlingCliCommands.BuildWebLoginHint(
"192.168.1.205", 5153, new WebTlsConfig { PfxPath = "a.pfx", CertPath = "a.crt", KeyPath = "a.key" });
Assert.Equal(" http://192.168.1.205:5153/?token=<your-access-token>", refused[1]);
Assert.Equal(" http://192.168.1.205:5153/", refused[1]);
}

[Fact]
Expand All @@ -129,11 +129,11 @@ public void WebLoginHint_NoTls_ByteIdentical()
var hint = DarlingCliCommands.BuildWebLoginHint("192.168.1.205", 5153, tls);
Assert.Equal(3, hint.Count);
Assert.Equal(" Remote browser login (after the service restarts):", hint[0]);
Assert.Equal(" http://192.168.1.205:5153/?token=<your-access-token>", hint[1]);
Assert.Equal(" http://192.168.1.205:5153/", hint[1]);
}

Assert.Equal(
" http://<a-LAN-IP-of-this-machine>:5153/?token=<your-access-token>",
" http://<a-LAN-IP-of-this-machine>:5153/",
DarlingCliCommands.BuildWebLoginHint("0.0.0.0", 5153, null)[1]);

/* The one line that changed (F9): loopback presents the token too (#1649), so it no longer says it needs none. */
Expand All @@ -143,10 +143,10 @@ public void WebLoginHint_NoTls_ByteIdentical()
}

[Theory]
[InlineData("2001:db8::5", " http://[2001:db8::5]:5153/?token=<your-access-token>")]
[InlineData("[2001:db8::5]", " http://[2001:db8::5]:5153/?token=<your-access-token>")]
[InlineData("::", " http://<a-LAN-IP-of-this-machine>:5153/?token=<your-access-token>")]
[InlineData("0.0.0.0", " http://<a-LAN-IP-of-this-machine>:5153/?token=<your-access-token>")]
[InlineData("2001:db8::5", " http://[2001:db8::5]:5153/")]
[InlineData("[2001:db8::5]", " http://[2001:db8::5]:5153/")]
[InlineData("::", " http://<a-LAN-IP-of-this-machine>:5153/")]
[InlineData("0.0.0.0", " http://<a-LAN-IP-of-this-machine>:5153/")]
public void WebLoginHint_Ipv6Listen_IsBracketed(string listen, string expectedUrlLine)
{
/* http://2001:db8::5:5153/ is not a URL, and a :: listen printed http://:::5153/ before this. */
Expand Down Expand Up @@ -1164,7 +1164,7 @@ public async Task ConfigureNetwork_Web_GeneratesToken_PrintsPlaintextOnce_Stores
Assert.Contains("SAVE THIS NOW", error.ToString(), StringComparison.Ordinal); // STDERR: the warning

/* The next-steps handoff includes the browser login hint — the one step Web does differently. */
Assert.Contains("http://192.168.1.205:5153/?token=", output.ToString(), StringComparison.Ordinal);
Assert.Contains("http://192.168.1.205:5153/", output.ToString(), StringComparison.Ordinal);
}
finally
{
Expand Down Expand Up @@ -1462,7 +1462,7 @@ then web (bind, CIDR), decline restart — the three-upsert + three-guard path i
Assert.Contains("--print-viewer-connection", stdout, StringComparison.Ordinal);
Assert.Contains("PerformanceMonitor Darling MCP (port", stdout, StringComparison.Ordinal);
Assert.Contains("PerformanceMonitor Darling Web (port", stdout, StringComparison.Ordinal);
Assert.Contains("?token=", stdout, StringComparison.Ordinal);
Assert.Contains("sign-in form", stdout, StringComparison.Ordinal);
}
finally
{
Expand Down
132 changes: 132 additions & 0 deletions Darling/Darling.Tests/DarlingHostBindingTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
*/

using System;
using System.Linq;
using System.Net;
using Microsoft.Extensions.Logging;
using PerformanceMonitor.Darling.Service.Hosting;
Expand Down Expand Up @@ -670,4 +671,135 @@ public void DescribeFirewallPortAuthority_WebSurface_NamesTheWebKeysAndVerbs()
Assert.Contains("--enable-web", fallback, StringComparison.Ordinal);
Assert.DoesNotContain("--enable-mcp", fallback, StringComparison.Ordinal);
}

/* ---- the token floor: empty or short tokens fail closed before an exposed bind ---- */

[Fact]
public void DescribeTokenProblem_EmptyToken_IsAProblem()
{
Assert.NotNull(DarlingHostBinding.DescribeTokenProblem(null));
Assert.NotNull(DarlingHostBinding.DescribeTokenProblem(" "));
}

[Fact]
public void DescribeTokenProblem_ShortToken_NamesTheFloor_WithoutTheValueOrTheLength()
{
var problem = DarlingHostBinding.DescribeTokenProblem("hunter2");

Assert.NotNull(problem);
Assert.Contains("shorter than " + DarlingHostBinding.MinimumTokenLength, problem, StringComparison.Ordinal);
Assert.DoesNotContain("7 characters", problem, StringComparison.Ordinal);
Assert.DoesNotContain("hunter2", problem, StringComparison.Ordinal);
}

[Fact]
public void DescribeTokenProblem_AtOrAboveTheFloor_IsNull()
{
Assert.Null(DarlingHostBinding.DescribeTokenProblem("abcdefghijklmnopqrstuvwxyz012345"[..DarlingHostBinding.MinimumTokenLength]));
Assert.Null(DarlingHostBinding.DescribeTokenProblem("abcdefghijklmnopqrstuvwxyz012345")); // the shape of what --configure-network generates
}

[Theory]
[InlineData("aaaaaaaaaaaaaaaa")]
[InlineData("abababababababababababab")]
[InlineData("0123401234012340123401234")]
public void DescribeTokenProblem_ALongTokenOfFewDifferentCharacters_IsAProblem_WithoutTheValueOrTheLength(string token)
{
Assert.True(token.Length >= DarlingHostBinding.MinimumTokenLength);

var problem = DarlingHostBinding.DescribeTokenProblem(token);

Assert.NotNull(problem);
Assert.Contains("fewer than " + DarlingHostBinding.MinimumDistinctTokenCharacters + " different characters", problem, StringComparison.Ordinal);
Assert.DoesNotContain(token, problem, StringComparison.Ordinal);
Assert.DoesNotContain(token.Length.ToString(System.Globalization.CultureInfo.InvariantCulture), problem, StringComparison.Ordinal);
Assert.False(DarlingHostBinding.ApplyTokenFloor(token, networkMode: true).NetworkMode);
}

[Fact]
public void DescribeTokenProblem_ExactlyTheDistinctCharacterFloor_Passes()
{
var token = new string('a', 10) + "bcdef" + new string('a', 5);

Assert.Equal(DarlingHostBinding.MinimumDistinctTokenCharacters, token.Distinct().Count());
Assert.Null(DarlingHostBinding.DescribeTokenProblem(token));
Assert.NotNull(DarlingHostBinding.DescribeTokenProblem(token.Replace('f', 'a')));
}

[Fact]
public void TheWebHostsTokenFloor_TurnsTheNetworkBindOff_AndLeavesALoopbackStartAlone()
{
/* Both hosts' start paths apply the floor through ApplyTokenFloor to the token they resolved: a short one drops
the bind to loopback-only (the token is still kept to gate the loopback listener); one at the floor leaves it. */
var weak = DarlingHostBinding.ApplyTokenFloor("hunter2", networkMode: true);
Assert.False(weak.NetworkMode);
Assert.NotNull(weak.Problem);

var strong = DarlingHostBinding.ApplyTokenFloor("abcdefghijklmnopqrstuvwxyz012345", networkMode: true);
Assert.True(strong.NetworkMode);
Assert.Null(strong.Problem);

Assert.False(DarlingHostBinding.ApplyTokenFloor("hunter2", networkMode: false).NetworkMode);
}

/* ---- trustedProxies: IPs and CIDRs, nothing else, none by default ---- */

[Fact]
public void TryParseTrustedProxies_NullOrEmpty_IsNoProxies()
{
Assert.True(DarlingHostBinding.TryParseTrustedProxies(null, out var none, out var problem));
Assert.Empty(none);
Assert.Null(problem);

Assert.True(DarlingHostBinding.TryParseTrustedProxies(new[] { "", " " }, out var blank, out _));
Assert.Empty(blank);
}

[Fact]
public void TryParseTrustedProxies_AcceptsIpsAndCidrs()
{
Assert.True(DarlingHostBinding.TryParseTrustedProxies(
new[] { "10.0.0.5", " 10.0.1.0/24 ", "::1", "2001:db8::/32" }, out var proxies, out _));

Assert.Equal(4, proxies.Count);
Assert.True(proxies[0].Contains(IPAddress.Parse("10.0.0.5")));
Assert.False(proxies[0].Contains(IPAddress.Parse("10.0.0.6"))); // a bare IP is that one address
Assert.True(proxies[1].Contains(IPAddress.Parse("10.0.1.200")));
Assert.True(proxies[2].Contains(IPAddress.IPv6Loopback));
Assert.True(proxies[3].Contains(IPAddress.Parse("2001:db8::9")));
}

[Fact]
public void TryParseTrustedProxies_RefusesAnEntryItCannotRead()
{
Assert.False(DarlingHostBinding.TryParseTrustedProxies(new[] { "10.0.0.5", "proxy.corp.local" }, out var proxies, out var problem));

Assert.Empty(proxies);
Assert.Contains("proxy.corp.local", problem, StringComparison.Ordinal);
}

[Theory]
[InlineData("192.168.010.0/24")] // a leading zero reads as octal: IPNetwork.TryParse makes it 192.168.8.0/24
[InlineData("10/8")] // a short form is zero-padded: IPNetwork.TryParse makes it 0.0.0.0/8
[InlineData("010.0.0.5")] // the same spellings as a bare address
[InlineData("10.1")]
public void TryParseTrustedProxies_RefusesAnEntryNotWrittenAsPlainText(string entry)
{
Assert.False(DarlingHostBinding.TryParseTrustedProxies(new[] { "10.0.0.5", entry }, out var proxies, out var problem));

Assert.Empty(proxies);
Assert.Contains(entry, problem, StringComparison.Ordinal);
Assert.Contains("plain address/prefix", problem, StringComparison.Ordinal);
}

[Fact]
public void TryParseTrustedProxies_StillAcceptsPlainEntries()
{
Assert.True(DarlingHostBinding.TryParseTrustedProxies(
new[] { "192.168.10.0/24", "10.0.0.5", "0.0.0.0/8" }, out var proxies, out var problem));

Assert.Null(problem);
Assert.Equal(3, proxies.Count);
Assert.True(proxies[0].Contains(IPAddress.Parse("192.168.10.77")));
}
}
18 changes: 18 additions & 0 deletions Darling/Darling.Tests/DarlingHttpRefusalLogTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,24 @@ public void ADifferentGate_FromTheSameSource_LogsAtOnce()
Assert.True(log.Observe(DarlingRefusalGate.HostAllowlist, "10.0.0.5", T0.AddSeconds(2)).Log);
}

/// <summary>The token form's same-site refusal has its own fold key: a burst of them from one address neither hides
/// nor is hidden by the OIDC flow's refusals or a wrong token from the same address, and its description is not the
/// OIDC flow's.</summary>
[Fact]
public void TheTokenSignInSiteGate_FoldsApartFromTheOidcFlowAndTheToken_AndIsNotDescribedAsOidc()
{
var log = new DarlingHttpRefusalLog(Window, 16);

Assert.True(log.Observe(DarlingRefusalGate.SignIn, "10.0.0.5", T0).Log);
Assert.True(log.Observe(DarlingRefusalGate.TokenSignInSite, "10.0.0.5", T0.AddSeconds(1)).Log);
Assert.True(log.Observe(DarlingRefusalGate.Token, "10.0.0.5", T0.AddSeconds(2)).Log);

var described = DarlingHttpRefusalLog.Describe(DarlingRefusalGate.TokenSignInSite);
Assert.Contains("web.publicBaseUrl", described, StringComparison.Ordinal);
Assert.DoesNotContain("OIDC", described, StringComparison.Ordinal);
Assert.NotEqual(DarlingHttpRefusalLog.Describe(DarlingRefusalGate.SignIn), described);
}

/// <summary>
/// Per SOURCE, not per gate alone. Keyed per gate only, one scanner's line wins the window and hides
/// the tester's own refusal completely — the failure this exists to fix, reintroduced by the fix.
Expand Down
Loading
Loading