Mit Roslyn umbenannt (Verweise folgen, Zeichenketten bleiben unberuehrt). Nicht umbenannt, weil gespeichert oder ueber die API gebunden (Stufe 4e): Properties, Record-Parameter, Enum-Member sowie die Parameter der Endpunkt-Handler (Routen-/Query-Bindung ueber den Namen). Die JSON-Felder anonymer Objekte bleiben ausdruecklich benannt (JsonFilter: pfad). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
131 lines
5.1 KiB
C#
131 lines
5.1 KiB
C#
using ClawdDotNet.Tools.WebFetch;
|
|
using Shouldly;
|
|
|
|
namespace ClawdDotNet.Tools.Tests.WebFetch;
|
|
|
|
/// <summary>
|
|
/// S5 from the inventory: the whitelist was only applied to the initial URL,
|
|
/// but HttpClient followed redirects itself. An allowed domain could thus redirect into the
|
|
/// local network — router, NAS, the own Git server, cloud metadata services.
|
|
/// </summary>
|
|
public sealed class UrlGuardTests
|
|
{
|
|
private static readonly string[] Allowed = ["example.com", "reuters.com"];
|
|
|
|
private static bool Validate(string url, out string? error)
|
|
=> UrlGuard.TryValidate(url, Allowed, out _, out error);
|
|
|
|
// ═══════════════════════════════════════════════════════════
|
|
// Network blocks
|
|
// ═══════════════════════════════════════════════════════════
|
|
|
|
[Theory]
|
|
[InlineData("http://169.254.169.254/latest/meta-data/")] // cloud metadata
|
|
[InlineData("http://127.0.0.1:8080/")]
|
|
[InlineData("http://localhost:8418/")]
|
|
[InlineData("http://192.168.178.10:8418/Richard/ClawdDotNet.git")]
|
|
[InlineData("http://10.0.0.5/admin")]
|
|
[InlineData("http://172.16.4.2/")]
|
|
[InlineData("http://172.31.255.255/")]
|
|
[InlineData("http://0.0.0.0/")]
|
|
[InlineData("http://100.64.1.1/")]
|
|
[InlineData("http://[::1]/")]
|
|
[InlineData("http://[fd00::1]/")]
|
|
[InlineData("http://nas.local/")]
|
|
[InlineData("http://dienst.internal/")]
|
|
public void Addresses_in_the_local_or_private_network_are_blocked(string url)
|
|
{
|
|
Validate(url, out var error).ShouldBeFalse($"'{url}' muss gesperrt sein");
|
|
error.ShouldNotBeNull();
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData("172.15.0.1")] // just below 172.16/12
|
|
[InlineData("172.32.0.1")] // just above
|
|
[InlineData("11.0.0.1")]
|
|
[InlineData("8.8.8.8")]
|
|
public void Public_addresses_do_not_count_as_private(string host)
|
|
{
|
|
UrlGuard.IsPrivateOrLocal(host).ShouldBeFalse();
|
|
}
|
|
|
|
// ═══════════════════════════════════════════════════════════
|
|
// Schema
|
|
// ═══════════════════════════════════════════════════════════
|
|
|
|
[Theory]
|
|
[InlineData("file:///C:/Windows/win.ini")]
|
|
[InlineData("ftp://example.com/datei")]
|
|
[InlineData("gopher://example.com/")]
|
|
public void Only_http_and_https_are_allowed(string url)
|
|
{
|
|
Validate(url, out _).ShouldBeFalse();
|
|
}
|
|
|
|
// ═══════════════════════════════════════════════════════════
|
|
// Whitelist
|
|
// ═══════════════════════════════════════════════════════════
|
|
|
|
[Theory]
|
|
[InlineData("https://example.com/artikel")]
|
|
[InlineData("https://www.example.com/artikel")]
|
|
[InlineData("https://news.example.com/artikel")]
|
|
[InlineData("https://a.b.example.com/x")]
|
|
[InlineData("https://reuters.com/")]
|
|
public void Allowed_domains_and_their_subdomains_go_through(string url)
|
|
{
|
|
Validate(url, out var error).ShouldBeTrue(error);
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData("https://example.com.attacker.net/x")]
|
|
[InlineData("https://notexample.com/x")]
|
|
[InlineData("https://evil.com/x")]
|
|
[InlineData("https://exampleXcom/x")]
|
|
public void Lookalike_domains_are_rejected(string url)
|
|
{
|
|
Validate(url, out _).ShouldBeFalse();
|
|
}
|
|
|
|
[Fact]
|
|
public void Without_whitelist_nothing_is_let_through()
|
|
{
|
|
UrlGuard.TryValidate("https://example.com", [], out _, out _).ShouldBeFalse();
|
|
}
|
|
|
|
/// <summary>
|
|
/// The old version removed "www." via a replace across the whole string:
|
|
/// "mywww.example.com" became "myexample.com".
|
|
/// </summary>
|
|
[Fact]
|
|
public void The_www_handling_does_not_mangle_other_host_names()
|
|
{
|
|
UrlGuard.IsDomainAllowed("mywww.example.com", Allowed).ShouldBeTrue();
|
|
UrlGuard.IsDomainAllowed("wwwxexample.com", Allowed).ShouldBeFalse();
|
|
}
|
|
|
|
[Fact]
|
|
public void A_trailing_dot_in_the_host_does_not_bypass_the_whitelist()
|
|
{
|
|
// "example.com." is the same host in DNS terms.
|
|
UrlGuard.IsDomainAllowed("evil.com.", Allowed).ShouldBeFalse();
|
|
UrlGuard.IsDomainAllowed("example.com.", Allowed).ShouldBeTrue();
|
|
}
|
|
|
|
[Fact]
|
|
public void Case_in_the_host_does_not_matter()
|
|
{
|
|
Validate("https://EXAMPLE.COM/x", out var error).ShouldBeTrue(error);
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData(null)]
|
|
[InlineData("")]
|
|
[InlineData("kein-url")]
|
|
[InlineData("/nur/ein/pfad")]
|
|
public void Invalid_inputs_are_rejected(string? url)
|
|
{
|
|
UrlGuard.TryValidate(url, Allowed, out _, out _).ShouldBeFalse();
|
|
}
|
|
}
|