| /** @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. |
| // Matches the value used by the regex_remap plugin. |
| 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 |