File: Terminal\TerminalWebSocketProxyOriginTests.cs
Web Access
Project: src\tests\Aspire.Dashboard.Tests\Aspire.Dashboard.Tests.csproj (Aspire.Dashboard.Tests)
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
 
using Aspire.Dashboard.Terminal;
using Microsoft.AspNetCore.Http;
using Xunit;
 
namespace Aspire.Dashboard.Tests.Terminal;
 
public class TerminalWebSocketProxyOriginTests
{
    [Theory]
    // Plain match.
    [InlineData("https", "dashboard.example.com", "https://dashboard.example.com", true)]
    // Explicit non-default port matches HostString that includes the port.
    [InlineData("https", "dashboard.example.com:8443", "https://dashboard.example.com:8443", true)]
    // Default port on origin (implicit :443) matches host that has no explicit port.
    [InlineData("https", "dashboard.example.com", "https://dashboard.example.com:443", true)]
    // Case-insensitive scheme + host.
    [InlineData("HTTPS", "Dashboard.Example.Com", "https://dashboard.example.com", true)]
    // Wrong scheme (http vs https) is rejected even when host matches.
    [InlineData("https", "dashboard.example.com", "http://dashboard.example.com", false)]
    // Different host is rejected (the CSWSH case).
    [InlineData("https", "dashboard.example.com", "https://evil.example.com", false)]
    // Same host, different port is rejected.
    [InlineData("https", "dashboard.example.com:8443", "https://dashboard.example.com:8444", false)]
    // Localhost ports must match exactly.
    [InlineData("http", "localhost:5101", "http://localhost:5101", true)]
    [InlineData("http", "localhost:5101", "http://localhost:5100", false)]
    public void IsAllowedOrigin_MatchesRequestSchemeAndHost(string scheme, string host, string origin, bool expected)
    {
        var ctx = new DefaultHttpContext();
        ctx.Request.Scheme = scheme;
        ctx.Request.Host = HostString.FromUriComponent(host);
        ctx.Request.Headers.Origin = origin;
 
        var allowed = TerminalWebSocketProxy.IsAllowedOrigin(ctx, out var logged);
 
        Assert.Equal(expected, allowed);
        Assert.Equal(origin, logged);
    }
 
    [Fact]
    public void IsAllowedOrigin_MissingOrigin_Disallowed()
    {
        var ctx = new DefaultHttpContext();
        ctx.Request.Scheme = "https";
        ctx.Request.Host = new HostString("dashboard.example.com");
 
        var allowed = TerminalWebSocketProxy.IsAllowedOrigin(ctx, out var logged);
 
        Assert.False(allowed);
        Assert.Equal("(none)", logged);
    }
 
    [Theory]
    [InlineData("not-a-uri")]
    [InlineData("/relative")]
    [InlineData("https://")]
    public void IsAllowedOrigin_MalformedOrigin_Disallowed(string origin)
    {
        var ctx = new DefaultHttpContext();
        ctx.Request.Scheme = "https";
        ctx.Request.Host = new HostString("dashboard.example.com");
        ctx.Request.Headers.Origin = origin;
 
        var allowed = TerminalWebSocketProxy.IsAllowedOrigin(ctx, out _);
 
        Assert.False(allowed);
    }
 
    [Fact]
    public void IsAllowedOrigin_NullOrEmptyHost_Disallowed()
    {
        // Defensive: if Request.Host hasn't been populated (extremely unusual under
        // Kestrel, but possible in custom hosting setups) we should not accept any
        // cross-origin upgrade.
        var ctx = new DefaultHttpContext();
        ctx.Request.Scheme = "https";
        ctx.Request.Host = new HostString();
        ctx.Request.Headers.Origin = "https://dashboard.example.com";
 
        var allowed = TerminalWebSocketProxy.IsAllowedOrigin(ctx, out _);
 
        Assert.False(allowed);
    }
}