using ClawdDotNet.Tools.WebFetch; using Shouldly; namespace ClawdDotNet.Tools.Tests.WebFetch; /// /// 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. /// 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(); } /// /// The old version removed "www." via a replace across the whole string: /// "mywww.example.com" became "myexample.com". /// [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(); } }