| /** @file |
| |
| Unit tests for IncludeUrlValidator. |
| |
| @section license License |
| |
| Licensed to the Apache Software Foundation (ASF) under one |
| or more contributor license agreements. See the NOTICE file |
| distributed with this work for additional information |
| regarding copyright ownership. The ASF licenses this file |
| to you under the Apache License, Version 2.0 (the |
| "License"); you may not use this file except in compliance |
| with the License. You may obtain a copy of the License at |
| |
| http://www.apache.org/licenses/LICENSE-2.0 |
| |
| Unless required by applicable law or agreed to in writing, software |
| distributed under the License is distributed on an "AS IS" BASIS, |
| WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
| See the License for the specific language governing permissions and |
| limitations under the License. |
| */ |
| |
| #include <catch2/catch_test_macros.hpp> |
| |
| #include "IncludeUrlValidator.h" |
| |
| using EsiLib::IncludeUrlValidator; |
| |
| TEST_CASE("splitUrl parses scheme and host") |
| { |
| std::string_view scheme; |
| std::string_view host; |
| |
| REQUIRE(IncludeUrlValidator::splitUrl("http://example.com/foo", scheme, host)); |
| REQUIRE(scheme == "http"); |
| REQUIRE(host == "example.com"); |
| |
| REQUIRE(IncludeUrlValidator::splitUrl("https://EXAMPLE.com:8443/path?x=1", scheme, host)); |
| REQUIRE(host == "EXAMPLE.com"); |
| |
| REQUIRE(IncludeUrlValidator::splitUrl("http://[::1]:80/", scheme, host)); |
| REQUIRE(host == "::1"); |
| |
| REQUIRE(IncludeUrlValidator::splitUrl("http://user:pw@example.com/x", scheme, host)); |
| REQUIRE(host == "example.com"); |
| |
| REQUIRE_FALSE(IncludeUrlValidator::splitUrl("no-scheme", scheme, host)); |
| REQUIRE_FALSE(IncludeUrlValidator::splitUrl("://nohost/", scheme, host)); |
| REQUIRE_FALSE(IncludeUrlValidator::splitUrl("http://", scheme, host)); |
| |
| // A bracketed IPv6 authority must be followed only by end-of-authority or a |
| // ":port" separator. Trailing junk after ']' (e.g. "[::1]evil.com") would |
| // otherwise mis-parse the host as "::1" while the URL points elsewhere. |
| REQUIRE_FALSE(IncludeUrlValidator::splitUrl("http://[::1]evil.com/", scheme, host)); |
| REQUIRE_FALSE(IncludeUrlValidator::splitUrl("http://[fe80::1]x/path", scheme, host)); |
| REQUIRE_FALSE(IncludeUrlValidator::splitUrl("http://[]/", scheme, host)); |
| REQUIRE_FALSE(IncludeUrlValidator::splitUrl("http://[::1", scheme, host)); |
| // Valid bracketed forms still parse. |
| REQUIRE(IncludeUrlValidator::splitUrl("http://[2001:db8::1]/p", scheme, host)); |
| REQUIRE(host == "2001:db8::1"); |
| REQUIRE(IncludeUrlValidator::splitUrl("http://[::1]", scheme, host)); |
| REQUIRE(host == "::1"); |
| } |
| |
| TEST_CASE("isPrivateHost recognizes private ranges") |
| { |
| // IPv4 ranges that must be denied. |
| REQUIRE(IncludeUrlValidator::isPrivateHost("127.0.0.1")); |
| REQUIRE(IncludeUrlValidator::isPrivateHost("10.0.0.1")); |
| REQUIRE(IncludeUrlValidator::isPrivateHost("172.16.0.1")); |
| REQUIRE(IncludeUrlValidator::isPrivateHost("192.168.5.5")); |
| REQUIRE(IncludeUrlValidator::isPrivateHost("169.254.169.254")); // cloud metadata |
| REQUIRE(IncludeUrlValidator::isPrivateHost("0.0.0.0")); |
| REQUIRE(IncludeUrlValidator::isPrivateHost("100.64.1.1")); // CGNAT |
| // IPv6. |
| REQUIRE(IncludeUrlValidator::isPrivateHost("::1")); |
| REQUIRE(IncludeUrlValidator::isPrivateHost("fe80::1")); |
| REQUIRE(IncludeUrlValidator::isPrivateHost("fc00::abcd")); |
| REQUIRE(IncludeUrlValidator::isPrivateHost("::ffff:10.0.0.1")); // IPv4-mapped private |
| REQUIRE(IncludeUrlValidator::isPrivateHost("64:ff9b::a00:1")); // NAT64 wrapping 10.0.0.1 |
| REQUIRE(IncludeUrlValidator::isPrivateHost("64:ff9b::a9fe:a9fe")); // NAT64 wrapping 169.254.169.254 |
| REQUIRE_FALSE(IncludeUrlValidator::isPrivateHost("64:ff9b::808:808")); // NAT64 wrapping 8.8.8.8 |
| // RFC 6874 IPv6 scope id: stripped before parsing, and the very |
| // presence of a zone id means scoped/local — deny outright. |
| REQUIRE(IncludeUrlValidator::isPrivateHost("fe80::1%eth0")); |
| REQUIRE(IncludeUrlValidator::isPrivateHost("fe80::1%25eth0")); // URL-encoded form |
| REQUIRE(IncludeUrlValidator::isPrivateHost("2001:db8::1%eth0")); // global address + zone → still scoped |
| REQUIRE(IncludeUrlValidator::isPrivateHost("malformed-ipv6%eth0")); // had zone but didn't parse → fail closed |
| // Names. |
| REQUIRE(IncludeUrlValidator::isPrivateHost("localhost")); |
| REQUIRE(IncludeUrlValidator::isPrivateHost("LocalHost")); |
| REQUIRE(IncludeUrlValidator::isPrivateHost("foo.localhost")); |
| // Trailing FQDN-root dot must not bypass the loopback check. |
| REQUIRE(IncludeUrlValidator::isPrivateHost("localhost.")); |
| REQUIRE(IncludeUrlValidator::isPrivateHost("foo.localhost.")); |
| // Non-canonical IPv4 numeric forms must not slip past the denylist as |
| // "hostnames". Common evasion forms decoded back to 127.0.0.1: |
| REQUIRE(IncludeUrlValidator::isPrivateHost("2130706433")); // decimal int |
| REQUIRE(IncludeUrlValidator::isPrivateHost("017700000001")); // octal |
| REQUIRE(IncludeUrlValidator::isPrivateHost("0x7f000001")); // hex |
| REQUIRE(IncludeUrlValidator::isPrivateHost("0x7f.0x0.0x0.0x1")); // mixed hex octets |
| REQUIRE(IncludeUrlValidator::isPrivateHost("127.1")); // 2-part compact |
| REQUIRE(IncludeUrlValidator::isPrivateHost("127.0.1")); // 3-part compact |
| // Even non-canonical numeric forms for "public" IPs are rejected: an |
| // include URL written this way is never a legitimate hostname and |
| // operators should use the dotted-quad form. |
| REQUIRE(IncludeUrlValidator::isPrivateHost("0x08080808")); // hex 8.8.8.8 |
| REQUIRE(IncludeUrlValidator::isPrivateHost("134744072")); // decimal 8.8.8.8 |
| // DNS hostnames that happen to start with a digit are still hostnames. |
| REQUIRE_FALSE(IncludeUrlValidator::isPrivateHost("1example.com")); |
| // Public. |
| REQUIRE_FALSE(IncludeUrlValidator::isPrivateHost("8.8.8.8")); |
| REQUIRE_FALSE(IncludeUrlValidator::isPrivateHost("2001:4860:4860::8888")); |
| REQUIRE_FALSE(IncludeUrlValidator::isPrivateHost("example.com")); |
| REQUIRE_FALSE(IncludeUrlValidator::isPrivateHost("example.com.")); |
| } |
| |
| TEST_CASE("validate enforces scheme and private-host denylist by default") |
| { |
| IncludeUrlValidator v; |
| REQUIRE(v.validate("http://example.com/foo") == IncludeUrlValidator::OK); |
| REQUIRE(v.validate("https://example.com/") == IncludeUrlValidator::OK); |
| // file:///etc/passwd has an empty authority and is rejected as MALFORMED before the scheme check. |
| REQUIRE(v.validate("file:///etc/passwd") == IncludeUrlValidator::MALFORMED); |
| REQUIRE(v.validate("file://host/etc/passwd") == IncludeUrlValidator::BAD_SCHEME); |
| REQUIRE(v.validate("gopher://example.com/") == IncludeUrlValidator::BAD_SCHEME); |
| REQUIRE(v.validate("not-a-url") == IncludeUrlValidator::MALFORMED); |
| REQUIRE(v.validate("http://169.254.169.254/latest/meta-data/") == IncludeUrlValidator::PRIVATE_HOST); |
| REQUIRE(v.validate("http://localhost:8080/admin") == IncludeUrlValidator::PRIVATE_HOST); |
| REQUIRE(v.validate("http://[::1]/") == IncludeUrlValidator::PRIVATE_HOST); |
| // Trailing-dot bypass: "localhost." must not slip past the denylist. |
| REQUIRE(v.validate("http://localhost./admin") == IncludeUrlValidator::PRIVATE_HOST); |
| // RFC 6874 IPv6 zone id: URL-encoded "%25" appears in the host after |
| // splitUrl strips the brackets; the scoped form must be rejected. |
| REQUIRE(v.validate("http://[fe80::1%25eth0]/x") == IncludeUrlValidator::PRIVATE_HOST); |
| } |
| |
| TEST_CASE("validate respects allow-private-hosts escape hatch") |
| { |
| IncludeUrlValidator v; |
| v.setAllowPrivateHosts(true); |
| REQUIRE(v.validate("http://10.0.0.5/svc") == IncludeUrlValidator::OK); |
| REQUIRE(v.validate("http://localhost/x") == IncludeUrlValidator::OK); |
| // Canonical dotted-quad loopback is allowed in this mode. |
| REQUIRE(v.validate("http://127.0.0.1/x") == IncludeUrlValidator::OK); |
| // Scheme is still enforced. |
| REQUIRE(v.validate("ftp://10.0.0.5/x") == IncludeUrlValidator::BAD_SCHEME); |
| // The escape hatch relaxes the private-range denylist, but non-canonical |
| // numeric IPv4 forms are SSRF evasion and stay rejected even here — for |
| // private addresses... |
| REQUIRE(v.validate("http://0x7f000001/x") == IncludeUrlValidator::PRIVATE_HOST); // hex 127.0.0.1 |
| REQUIRE(v.validate("http://2130706433/x") == IncludeUrlValidator::PRIVATE_HOST); // decimal 127.0.0.1 |
| REQUIRE(v.validate("http://017700000001/x") == IncludeUrlValidator::PRIVATE_HOST); // octal 127.0.0.1 |
| REQUIRE(v.validate("http://127.1/x") == IncludeUrlValidator::PRIVATE_HOST); // 2-part compact |
| // ...and for public addresses (never a legitimate include host). |
| REQUIRE(v.validate("http://0x08080808/x") == IncludeUrlValidator::PRIVATE_HOST); // hex 8.8.8.8 |
| REQUIRE(v.validate("http://134744072/x") == IncludeUrlValidator::PRIVATE_HOST); // decimal 8.8.8.8 |
| // An RFC 6874 zone id selects a (link-local) interface and is rejected even |
| // in this mode, so it can never be enabled by accident through the escape |
| // hatch. |
| REQUIRE(v.validate("http://[fe80::1%25eth0]/x") == IncludeUrlValidator::PRIVATE_HOST); |
| } |
| |
| TEST_CASE("validate enforces optional host allowlist regex") |
| { |
| IncludeUrlValidator v; |
| REQUIRE(v.setHostAllowRegex(R"((.+\.)?example\.com)")); |
| REQUIRE(v.validate("http://example.com/x") == IncludeUrlValidator::OK); |
| REQUIRE(v.validate("http://api.example.com/x") == IncludeUrlValidator::OK); |
| REQUIRE(v.validate("http://EXAMPLE.com/x") == IncludeUrlValidator::OK); |
| REQUIRE(v.validate("http://evil.com/x") == IncludeUrlValidator::NOT_ALLOWLISTED); |
| // Regex doesn't bypass the private-host denylist (private check runs first). |
| REQUIRE(v.validate("http://127.0.0.1/x") == IncludeUrlValidator::PRIVATE_HOST); |
| // Allowlist must see the normalized host: an operator pattern for |
| // "example.com" matches an include URL with the FQDN-root form. |
| REQUIRE(v.validate("http://example.com./x") == IncludeUrlValidator::OK); |
| REQUIRE(v.validate("http://api.example.com./x") == IncludeUrlValidator::OK); |
| } |
| |
| TEST_CASE("setHostAllowRegex rejects invalid pattern") |
| { |
| IncludeUrlValidator v; |
| REQUIRE_FALSE(v.setHostAllowRegex("(unclosed")); |
| // After a failed compile, validator falls back to no-allowlist behavior. |
| // The caller (TSPluginInit) is responsible for failing the plugin so |
| // this fallback is never reached in practice; the test pins the |
| // per-instance contract. |
| REQUIRE(v.validate("http://example.com/x") == IncludeUrlValidator::OK); |
| } |
| |
| TEST_CASE("redactUserInfo strips credentials before logging") |
| { |
| // Plain userinfo replaced with "***@". |
| REQUIRE(IncludeUrlValidator::redactUserInfo("http://user:pass@example.com/x") == "http://***@example.com/x"); |
| REQUIRE(IncludeUrlValidator::redactUserInfo("http://user@example.com/x") == "http://***@example.com/x"); |
| // No userinfo: unchanged. |
| REQUIRE(IncludeUrlValidator::redactUserInfo("http://example.com/x") == "http://example.com/x"); |
| // '@' only in the path/query must not be misidentified as userinfo. |
| REQUIRE(IncludeUrlValidator::redactUserInfo("http://example.com/path@notuser") == "http://example.com/path@notuser"); |
| REQUIRE(IncludeUrlValidator::redactUserInfo("http://example.com/?q=a@b") == "http://example.com/?q=a@b"); |
| // Unparseable / pre-expansion templates: tolerated. |
| REQUIRE(IncludeUrlValidator::redactUserInfo("not-a-url") == "not-a-url"); |
| // Empty userinfo (just "@") is still redacted so the form is consistent. |
| REQUIRE(IncludeUrlValidator::redactUserInfo("http://@example.com/") == "http://***@example.com/"); |
| } |