blob: 5bb9c0c51fe2624daf71b6982cf5173e616da7c2 [file]
/** @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