| /** @file | |
| Validator for esi:include src URLs to mitigate SSRF. | |
| @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 "IncludeUrlValidator.h" | |
| #include <arpa/inet.h> | |
| #include <cctype> | |
| #include <cstring> | |
| #include <netinet/in.h> | |
| #include <string> | |
| #include <sys/socket.h> | |
| using std::string_view; | |
| namespace EsiLib | |
| { | |
| namespace | |
| { | |
| // Backtracking limit for the allowlist match. PCRE2 stops and reports | |
| // PCRE2_ERROR_MATCHLIMIT once this many match steps are taken, bounding | |
| // worst-case CPU per validation against attacker-influenced hostnames. | |
| constexpr uint32_t ALLOW_REGEX_MATCH_LIMIT = 1750; | |
| bool | |
| iequals(string_view a, string_view b) | |
| { | |
| if (a.size() != b.size()) { | |
| return false; | |
| } | |
| for (size_t i = 0; i < a.size(); ++i) { | |
| if (std::tolower(static_cast<unsigned char>(a[i])) != std::tolower(static_cast<unsigned char>(b[i]))) { | |
| return false; | |
| } | |
| } | |
| return true; | |
| } | |
| bool | |
| iendswith(string_view s, string_view suffix) | |
| { | |
| if (s.size() < suffix.size()) { | |
| return false; | |
| } | |
| return iequals(s.substr(s.size() - suffix.size()), suffix); | |
| } | |
| // RFC 1918, loopback, link-local, unspecified, broadcast, and CGNAT. | |
| bool | |
| ipv4IsPrivate(const in_addr &a) | |
| { | |
| uint32_t h = ntohl(a.s_addr); | |
| // 0.0.0.0/8 (unspecified / "this network") | |
| if ((h & 0xFF000000u) == 0x00000000u) { | |
| return true; | |
| } | |
| // 10.0.0.0/8 | |
| if ((h & 0xFF000000u) == 0x0A000000u) { | |
| return true; | |
| } | |
| // 100.64.0.0/10 (CGNAT) | |
| if ((h & 0xFFC00000u) == 0x64400000u) { | |
| return true; | |
| } | |
| // 127.0.0.0/8 | |
| if ((h & 0xFF000000u) == 0x7F000000u) { | |
| return true; | |
| } | |
| // 169.254.0.0/16 (link-local, includes cloud metadata 169.254.169.254) | |
| if ((h & 0xFFFF0000u) == 0xA9FE0000u) { | |
| return true; | |
| } | |
| // 172.16.0.0/12 | |
| if ((h & 0xFFF00000u) == 0xAC100000u) { | |
| return true; | |
| } | |
| // 192.0.0.0/24 (IETF protocol assignments) and 192.0.2.0/24 (TEST-NET-1) | |
| if ((h & 0xFFFFFF00u) == 0xC0000000u || (h & 0xFFFFFF00u) == 0xC0000200u) { | |
| return true; | |
| } | |
| // 192.168.0.0/16 | |
| if ((h & 0xFFFF0000u) == 0xC0A80000u) { | |
| return true; | |
| } | |
| // 198.18.0.0/15 (benchmarking) | |
| if ((h & 0xFFFE0000u) == 0xC6120000u) { | |
| return true; | |
| } | |
| // 198.51.100.0/24 (TEST-NET-2), 203.0.113.0/24 (TEST-NET-3) | |
| if ((h & 0xFFFFFF00u) == 0xC6336400u || (h & 0xFFFFFF00u) == 0xCB007100u) { | |
| return true; | |
| } | |
| // 224.0.0.0/4 (multicast), 240.0.0.0/4 (reserved), 255.255.255.255 | |
| if ((h & 0xF0000000u) == 0xE0000000u || (h & 0xF0000000u) == 0xF0000000u) { | |
| return true; | |
| } | |
| return false; | |
| } | |
| bool | |
| ipv6IsPrivate(const in6_addr &a) | |
| { | |
| // ::/128 unspecified, ::1/128 loopback | |
| bool all_zero = true; | |
| for (int i = 0; i < 15; ++i) { | |
| if (a.s6_addr[i] != 0) { | |
| all_zero = false; | |
| break; | |
| } | |
| } | |
| if (all_zero && (a.s6_addr[15] == 0 || a.s6_addr[15] == 1)) { | |
| return true; | |
| } | |
| // fe80::/10 link-local | |
| if (a.s6_addr[0] == 0xfe && (a.s6_addr[1] & 0xc0) == 0x80) { | |
| return true; | |
| } | |
| // fc00::/7 unique local | |
| if ((a.s6_addr[0] & 0xfe) == 0xfc) { | |
| return true; | |
| } | |
| // ff00::/8 multicast | |
| if (a.s6_addr[0] == 0xff) { | |
| return true; | |
| } | |
| // ::ffff:0:0/96 IPv4-mapped — fall through to IPv4 check | |
| bool is_v4_mapped = true; | |
| for (int i = 0; i < 10; ++i) { | |
| if (a.s6_addr[i] != 0) { | |
| is_v4_mapped = false; | |
| break; | |
| } | |
| } | |
| if (is_v4_mapped && a.s6_addr[10] == 0xff && a.s6_addr[11] == 0xff) { | |
| in_addr v4; | |
| std::memcpy(&v4.s_addr, &a.s6_addr[12], 4); | |
| return ipv4IsPrivate(v4); | |
| } | |
| // 64:ff9b::/96 NAT64 — the last 32 bits are an embedded IPv4. Recurse | |
| // through ipv4IsPrivate so private IPv4 destinations (e.g. | |
| // 64:ff9b::0a00:0001 → 10.0.0.1) cannot bypass the denylist in | |
| // NAT64-enabled environments. | |
| if (a.s6_addr[0] == 0x00 && a.s6_addr[1] == 0x64 && a.s6_addr[2] == 0xff && a.s6_addr[3] == 0x9b) { | |
| bool nat64_zero_middle = true; | |
| for (int i = 4; i < 12; ++i) { | |
| if (a.s6_addr[i] != 0) { | |
| nat64_zero_middle = false; | |
| break; | |
| } | |
| } | |
| if (nat64_zero_middle) { | |
| in_addr v4; | |
| std::memcpy(&v4.s_addr, &a.s6_addr[12], 4); | |
| return ipv4IsPrivate(v4); | |
| } | |
| } | |
| return false; | |
| } | |
| // Non-canonical numeric IPv4 forms — decimal ("2130706433"), octal | |
| // ("017700000001"), hex ("0x7f000001"), and shortcut forms like "127.1" — | |
| // are accepted by inet_aton but not inet_pton. They have no legitimate use | |
| // in an include URL and are a standard SSRF-filter evasion. They must be | |
| // rejected even when --allow-private-include-hosts is set, since that flag | |
| // only relaxes the private-range denylist, not these evasion forms. | |
| // Strips any zone id and trailing FQDN-root dot before checking, matching | |
| // the normalization in isPrivateHost. | |
| bool | |
| isNonCanonicalNumericIPv4(string_view host) | |
| { | |
| if (auto pct = host.find('%'); pct != string_view::npos) { | |
| host = host.substr(0, pct); | |
| } | |
| if (!host.empty() && host.back() == '.') { | |
| host.remove_suffix(1); | |
| } | |
| std::string h(host); | |
| in_addr v4{}; | |
| if (inet_pton(AF_INET, h.c_str(), &v4) == 1) { | |
| return false; // canonical dotted-quad, handled by the normal denylist | |
| } | |
| return inet_aton(h.c_str(), &v4) != 0; | |
| } | |
| } // namespace | |
| bool | |
| IncludeUrlValidator::splitUrl(string_view url, string_view &scheme, string_view &host) | |
| { | |
| auto sep = url.find("://"); | |
| if (sep == string_view::npos || sep == 0) { | |
| return false; | |
| } | |
| scheme = url.substr(0, sep); | |
| string_view rest = url.substr(sep + 3); | |
| if (rest.empty()) { | |
| return false; | |
| } | |
| // Strip userinfo "user:pass@". Be careful: '@' may appear in path; only look | |
| // before the authority terminator. | |
| auto authority_end = rest.find_first_of("/?#"); | |
| string_view authority = (authority_end == string_view::npos) ? rest : rest.substr(0, authority_end); | |
| auto at = authority.rfind('@'); | |
| if (at != string_view::npos) { | |
| authority.remove_prefix(at + 1); | |
| } | |
| if (authority.empty()) { | |
| return false; | |
| } | |
| // IPv6 literal in brackets: [....] optionally followed by ":port". | |
| if (authority.front() == '[') { | |
| auto rb = authority.find(']'); | |
| if (rb == string_view::npos) { | |
| return false; | |
| } | |
| // The closing ']' must terminate the authority or be immediately followed | |
| // by the port separator ':'. Anything else (e.g. "[::1]evil.com") is | |
| // malformed; reject it rather than silently extracting "::1" as the host | |
| // while the real URL points elsewhere. | |
| if (rb + 1 != authority.size() && authority[rb + 1] != ':') { | |
| return false; | |
| } | |
| host = authority.substr(1, rb - 1); | |
| } else { | |
| auto colon = authority.find(':'); | |
| host = (colon == string_view::npos) ? authority : authority.substr(0, colon); | |
| } | |
| return !host.empty(); | |
| } | |
| bool | |
| IncludeUrlValidator::isPrivateHost(string_view host) | |
| { | |
| // RFC 6874 IPv6 scope id: "fe80::1%eth0" (or URL-encoded | |
| // "fe80::1%25eth0" after splitUrl strips the brackets). inet_pton | |
| // rejects the '%', so strip from there before parsing — and treat | |
| // the very presence of a zone id as private, because scope ids only | |
| // make sense for link-local / non-global addresses. | |
| bool had_zone = false; | |
| if (auto pct = host.find('%'); pct != string_view::npos) { | |
| had_zone = true; | |
| host = host.substr(0, pct); | |
| } | |
| // Strip a single trailing FQDN-root dot: "localhost." resolves the | |
| // same as "localhost"; without this, the hostname checks below would | |
| // be trivially bypassed. | |
| if (!host.empty() && host.back() == '.') { | |
| host.remove_suffix(1); | |
| } | |
| std::string h(host); | |
| in_addr v4{}; | |
| if (inet_pton(AF_INET, h.c_str(), &v4) == 1) { | |
| return ipv4IsPrivate(v4); | |
| } | |
| // Non-canonical IPv4 numeric forms — decimal integer ("2130706433"), | |
| // octal ("017700000001"), hex ("0x7f000001"), and shortcut forms | |
| // like "127.1" — are accepted by inet_aton but not by inet_pton. | |
| // They have no legitimate use in ESI include URLs and are routinely | |
| // used to evade SSRF filters that only canonicalize the dotted-quad | |
| // form. Reject outright instead of trying to apply ipv4IsPrivate to | |
| // the parsed value. | |
| if (inet_aton(h.c_str(), &v4) != 0) { | |
| return true; | |
| } | |
| in6_addr v6{}; | |
| if (inet_pton(AF_INET6, h.c_str(), &v6) == 1) { | |
| return had_zone || ipv6IsPrivate(v6); | |
| } | |
| // Had a zone id but didn't parse as IPv6 — fail closed. | |
| if (had_zone) { | |
| return true; | |
| } | |
| // Non-IP hostname: deny obvious loopback aliases. Real hostnames that | |
| // resolve to private space must be caught by the optional allowlist or | |
| // upstream remap policy; we don't do DNS here. | |
| if (iequals(host, "localhost") || iendswith(host, ".localhost")) { | |
| return true; | |
| } | |
| return false; | |
| } | |
| bool | |
| IncludeUrlValidator::setHostAllowRegex(const std::string &pattern) | |
| { | |
| std::string error; | |
| int erroffset = -1; | |
| if (!_allow_regex.compile(pattern, error, erroffset, RE_CASE_INSENSITIVE)) { | |
| _has_allow_regex = false; | |
| return false; | |
| } | |
| // Cap backtracking so a pathological pattern/host can't burn unbounded CPU | |
| // on this hot path; an exceeded limit surfaces from exec() as a negative | |
| // return and is treated as "not allowlisted" (fail closed). | |
| _match_context.set_match_limit(ALLOW_REGEX_MATCH_LIMIT); | |
| _has_allow_regex = true; | |
| return true; | |
| } | |
| std::string | |
| IncludeUrlValidator::redactUserInfo(string_view url) | |
| { | |
| // Locate the authority, then its userinfo. The authority begins after "://" | |
| // for an absolute URL, or after a leading "//" for a protocol-relative URL | |
| // ("//user:pass@host/path"). If there is an '@' before the authority | |
| // terminator ('/', '?', or '#'), everything up to and including the '@' is | |
| // userinfo and may carry credentials — replace it with "***@". This runs on | |
| // rejection paths for URLs that fail validation too, so it must not require | |
| // a valid scheme. Check the leading "//" first: a protocol-relative URL has | |
| // no scheme, and a later "://" (e.g. inside a query) must not be mistaken | |
| // for the authority delimiter. | |
| size_t authority_begin; | |
| if (url.substr(0, 2) == "//") { | |
| authority_begin = 2; | |
| } else if (auto scheme_sep = url.find("://"); scheme_sep != string_view::npos) { | |
| authority_begin = scheme_sep + 3; | |
| } else { | |
| return std::string{url}; | |
| } | |
| size_t authority_end = url.find_first_of("/?#", authority_begin); | |
| string_view authority = (authority_end == string_view::npos) ? url.substr(authority_begin) : | |
| url.substr(authority_begin, authority_end - authority_begin); | |
| auto at_in_authority = authority.rfind('@'); | |
| if (at_in_authority == string_view::npos) { | |
| return std::string{url}; | |
| } | |
| std::string redacted; | |
| redacted.reserve(url.size()); | |
| redacted.append(url.substr(0, authority_begin)); | |
| redacted.append("***@"); | |
| redacted.append(url.substr(authority_begin + at_in_authority + 1)); | |
| return redacted; | |
| } | |
| IncludeUrlValidator::Reason | |
| IncludeUrlValidator::validate(string_view url) const | |
| { | |
| // Reject ASCII control characters / whitespace to avoid request splitting in TSFetchUrl request construction. | |
| for (unsigned char c : url) { | |
| if (c <= 0x20 || c == 0x7f) { | |
| return MALFORMED; | |
| } | |
| } | |
| string_view scheme; | |
| string_view host; | |
| if (!splitUrl(url, scheme, host)) { | |
| return MALFORMED; | |
| } | |
| if (!iequals(scheme, "http") && !iequals(scheme, "https")) { | |
| return BAD_SCHEME; | |
| } | |
| // Non-canonical numeric IPv4 forms are SSRF evasion and are rejected | |
| // unconditionally — the allow-private-hosts escape hatch only relaxes the | |
| // private-range denylist, not these forms. | |
| if (isNonCanonicalNumericIPv4(host)) { | |
| return PRIVATE_HOST; | |
| } | |
| // An RFC 6874 zone id (e.g. "fe80::1%25eth0", with the '%' URL-encoded as | |
| // "%25") selects a network interface and is only meaningful for link-local | |
| // / non-global addresses. Reject any host carrying a '%' unconditionally so | |
| // it can never be enabled by accident through the private-hosts escape | |
| // hatch. | |
| if (host.find('%') != string_view::npos) { | |
| return PRIVATE_HOST; | |
| } | |
| if (!_allow_private_hosts && isPrivateHost(host)) { | |
| return PRIVATE_HOST; | |
| } | |
| if (_has_allow_regex) { | |
| // Normalize the host the same way isPrivateHost does so an | |
| // allowlist for "example.com" still matches "example.com." and | |
| // doesn't accidentally match a host carrying a zone id. | |
| string_view bare = host; | |
| if (auto pct = bare.find('%'); pct != string_view::npos) { | |
| bare = bare.substr(0, pct); | |
| } | |
| if (!bare.empty() && bare.back() == '.') { | |
| bare.remove_suffix(1); | |
| } | |
| std::string h(bare); | |
| // RE_FULL_MATCH requires the pattern to span the entire host (the | |
| // std::regex_match semantics this replaced). exec() returns the capture | |
| // count on success, 0 if it matched but the match buffer was too small for | |
| // all capture groups, and a negative value for no match / error / exceeded | |
| // match limit. We only care whether the host matched, not the captures, so | |
| // any non-negative return is a match; only a negative return fails closed. | |
| RegexMatches matches; | |
| if (_allow_regex.exec(h, matches, RE_FULL_MATCH, &_match_context) < 0) { | |
| return NOT_ALLOWLISTED; | |
| } | |
| } | |
| return OK; | |
| } | |
| const char * | |
| IncludeUrlValidator::reasonString(Reason r) | |
| { | |
| switch (r) { | |
| case OK: | |
| return "ok"; | |
| case MALFORMED: | |
| return "malformed-url"; | |
| case BAD_SCHEME: | |
| return "bad-scheme"; | |
| case PRIVATE_HOST: | |
| return "private-host"; | |
| case NOT_ALLOWLISTED: | |
| return "not-allowlisted"; | |
| } | |
| return "unknown"; | |
| } | |
| } // namespace EsiLib |