blob: 2ac8cafd360d6fb65385aad96e7e2dbedc79ee86 [file] [edit]
/*
* Copyright 2026 Rive
*/
#ifdef WITH_RIVE_SCRIPTNET
#include "rive/scriptnet/net_policy.hpp"
#include <cstddef>
#include <cstring>
#include <initializer_list>
#include <vector>
namespace rive::scriptnet
{
namespace
{
// Hosts Rive operates. Script requests carry no cookies, but these must not
// see traffic from untrusted files at all: our own CORS allowlists, quotas
// and origin checks trust requests that look like they come from us.
const char* const kRiveHosts[] = {
"rive.app",
"2dimensions.com",
"riveusercontent.com",
};
// Names that only resolve on a local network.
const char* const kLocalHosts[] = {
"localhost",
"local",
"internal",
"home.arpa",
"localdomain",
"lan",
};
// https://fetch.spec.whatwg.org/#forbidden-request-header, plus the method
// override family, which the standard only forbids alongside a forbidden
// method.
const char* const kForbiddenHeaders[] = {
"accept-charset",
"accept-encoding",
"access-control-request-headers",
"access-control-request-method",
"access-control-request-private-network",
"connection",
"content-length",
"cookie",
"cookie2",
"date",
"dnt",
"expect",
"host",
"keep-alive",
"origin",
"referer",
"set-cookie",
"te",
"trailer",
"transfer-encoding",
"upgrade",
"via",
"x-http-method",
"x-http-method-override",
"x-method-override",
};
const char* const kAllowedMethods[] = {
"GET",
"HEAD",
"POST",
"PUT",
"PATCH",
"DELETE",
"OPTIONS",
};
bool isDigit(char c) { return c >= '0' && c <= '9'; }
bool isHexDigit(char c)
{
return isDigit(c) || (c >= 'a' && c <= 'f') || (c >= 'A' && c <= 'F');
}
std::string toUpper(std::string value)
{
for (char& c : value)
{
if (c >= 'a' && c <= 'z')
{
c = (char)(c - 'a' + 'A');
}
}
return value;
}
// RFC 9110 token characters, which methods and header names are made of.
bool isToken(const std::string& value)
{
if (value.empty())
{
return false;
}
for (char c : value)
{
bool alphanumeric = (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') ||
isDigit(c);
if (!alphanumeric &&
(c == '\0' || std::strchr("!#$%&'*+-.^_`|~", c) == nullptr))
{
return false;
}
}
return true;
}
// True when `host` is `suffix` or a subdomain of it.
bool isWithin(const std::string& host, const char* suffix)
{
size_t length = std::strlen(suffix);
if (host.size() < length ||
host.compare(host.size() - length, length, suffix) != 0)
{
return false;
}
return host.size() == length || host[host.size() - length - 1] == '.';
}
bool refuse(NetError* error, std::string message)
{
if (error != nullptr)
{
error->code = NetErrorCode::Policy;
error->message = std::move(message);
}
return false;
}
} // namespace
std::string lowerAscii(std::string value)
{
for (char& c : value)
{
if (c >= 'A' && c <= 'Z')
{
c = (char)(c - 'A' + 'a');
}
}
return value;
}
bool NetPolicy::checkHost(const std::string& host, std::string* reason)
{
auto refuseHost = [reason](const char* why) {
if (reason != nullptr)
{
*reason = why;
}
return false;
};
if (host.empty())
{
return refuseHost("the URL has no host");
}
if (host.size() > 253)
{
return refuseHost("the host name is too long");
}
for (char c : host)
{
if (c == '[' || c == ']' || c == ':')
{
return refuseHost("IP address hosts are not allowed");
}
if (!((c >= 'a' && c <= 'z') || isDigit(c) || c == '-' || c == '.' ||
c == '_'))
{
return refuseHost("the host must be an ASCII domain name "
"(punycode for international names)");
}
}
size_t labels = 0;
size_t lastLabelStart = 0;
size_t start = 0;
while (true)
{
size_t dot = host.find('.', start);
size_t end = dot == std::string::npos ? host.size() : dot;
if (end == start)
{
return refuseHost("the host has an empty label");
}
if (end - start > 63)
{
return refuseHost("a host label is longer than 63 characters");
}
labels++;
lastLabelStart = start;
if (dot == std::string::npos)
{
break;
}
start = dot + 1;
}
// The URL standard parses a host whose last label is a number as IPv4, so
// 127.1, 0x7f.1 and 2130706433 all reach loopback. Refuse them all.
std::string lastLabel = host.substr(lastLabelStart);
bool decimal = true;
for (char c : lastLabel)
{
decimal = decimal && isDigit(c);
}
bool hex = lastLabel.size() >= 2 && lastLabel[0] == '0' &&
lastLabel[1] == 'x';
for (size_t i = 2; hex && i < lastLabel.size(); i++)
{
hex = isHexDigit(lastLabel[i]);
}
if (decimal || hex)
{
return refuseHost("IP address hosts are not allowed");
}
if (labels < 2)
{
return refuseHost("the host must be a fully qualified domain name");
}
for (const char* local : kLocalHosts)
{
if (isWithin(host, local))
{
return refuseHost("local network hosts are not allowed");
}
}
for (const char* rive : kRiveHosts)
{
if (isWithin(host, rive))
{
return refuseHost("Rive's own hosts are not reachable from "
"scripts");
}
}
return true;
}
namespace
{
// Where an IPv4 address lies: public, or a kind scripts may not reach.
const char* ipv4Kind(const uint8_t* a)
{
if (a[0] == 127)
{
return "loopback";
}
if (a[0] == 10 || (a[0] == 172 && (a[1] & 0xf0) == 16) ||
(a[0] == 192 && a[1] == 168) || (a[0] == 100 && (a[1] & 0xc0) == 64))
{
return "private";
}
if (a[0] == 169 && a[1] == 254)
{
return "link-local";
}
// This network, IETF assignments, documentation (TEST-NET-1 to 3),
// benchmarking, multicast, and the reserved block with broadcast.
if (a[0] == 0 || (a[0] == 192 && a[1] == 0 && (a[2] == 0 || a[2] == 2)) ||
(a[0] == 198 && a[1] == 51 && a[2] == 100) ||
(a[0] == 203 && a[1] == 0 && a[2] == 113) ||
(a[0] == 198 && (a[1] & 0xfe) == 18) || a[0] >= 224)
{
return "reserved";
}
return nullptr;
}
bool isZero(const uint8_t* bytes, size_t length)
{
for (size_t i = 0; i < length; i++)
{
if (bytes[i] != 0)
{
return false;
}
}
return true;
}
const char* ipv6Kind(const uint8_t* a)
{
if (isZero(a, 15))
{
return a[15] == 0 ? "reserved" : a[15] == 1 ? "loopback" : nullptr;
}
// IPv4-mapped (::ffff:0:0/96) and the deprecated IPv4-compatible (::/96)
// forms carry the address in the last four bytes, as does NAT64's
// well-known prefix (64:ff9b::/96); 6to4 (2002::/16) carries it after
// the prefix.
if (isZero(a, 10) &&
((a[10] == 0xff && a[11] == 0xff) || (a[10] == 0 && a[11] == 0)))
{
return ipv4Kind(a + 12);
}
if (a[0] == 0 && a[1] == 0x64 && a[2] == 0xff && a[3] == 0x9b)
{
if (a[4] == 0 && a[5] == 1)
{
// Local-use NAT64 (64:ff9b:1::/48).
return "private";
}
return isZero(a + 4, 8) ? ipv4Kind(a + 12) : nullptr;
}
if (a[0] == 0x20 && a[1] == 0x02)
{
return ipv4Kind(a + 2);
}
if ((a[0] & 0xfe) == 0xfc || (a[0] == 0xfe && (a[1] & 0xc0) == 0xc0))
{
// Unique local (fc00::/7) and the deprecated site-local (fec0::/10).
return "private";
}
if (a[0] == 0xfe && (a[1] & 0xc0) == 0x80)
{
return "link-local";
}
// Multicast, the discard prefix (100::/64) and documentation
// (2001:db8::/32).
if (a[0] == 0xff || (a[0] == 1 && isZero(a + 1, 7)) ||
(a[0] == 0x20 && a[1] == 0x01 && a[2] == 0x0d && a[3] == 0xb8))
{
return "reserved";
}
return nullptr;
}
} // namespace
bool NetPolicy::checkAddress(
const uint8_t* address,
size_t length,
std::string* reason)
{
if (length != 4 && length != 16)
{
if (reason != nullptr)
{
*reason = "the host resolves to an address of an unknown kind";
}
return false;
}
const char* kind = length == 4 ? ipv4Kind(address) : ipv6Kind(address);
if (kind == nullptr)
{
return true;
}
if (reason != nullptr)
{
*reason = std::string("the host resolves to a ") + kind + " address";
}
return false;
}
bool NetPolicy::isForbiddenHeader(const std::string& name)
{
std::string lower = lowerAscii(name);
if (lower.compare(0, 6, "proxy-") == 0 || lower.compare(0, 4, "sec-") == 0)
{
return true;
}
for (const char* forbidden : kForbiddenHeaders)
{
if (lower == forbidden)
{
return true;
}
}
return false;
}
bool NetPolicy::check(
HttpRequest& request,
const NetLimits& limits,
NetError* error)
{
// URL. Only a conservative subset parses here, since the transport
// parses the URL again with its own parser.
const std::string& url = request.url;
if (url.empty())
{
return refuse(error, "the URL is empty");
}
if (url.size() > limits.maxUrlLength)
{
return refuse(
error,
"the URL is longer than " + std::to_string(limits.maxUrlLength) +
" characters");
}
for (char c : url)
{
unsigned char byte = (unsigned char)c;
if (byte <= 0x20 || byte >= 0x7f || c == '\\')
{
return refuse(
error,
"the URL must be ASCII with no spaces or "
"backslashes; percent-encode anything else");
}
}
size_t colon = url.find(':');
std::string scheme = colon == std::string::npos
? ""
: lowerAscii(url.substr(0, colon));
if (scheme == "http")
{
return refuse(error, "http: URLs are not allowed; use https:");
}
if (scheme != "https")
{
return refuse(error, "only https: URLs are allowed");
}
if (url.compare(colon + 1, 2, "//") != 0)
{
return refuse(error, "the URL has no host");
}
size_t authorityStart = colon + 3;
size_t authorityEnd = url.find_first_of("/?#", authorityStart);
if (authorityEnd == std::string::npos)
{
authorityEnd = url.size();
}
std::string authority = url.substr(
authorityStart,
authorityEnd - authorityStart);
if (authority.find('@') != std::string::npos)
{
return refuse(error, "URLs may not carry credentials");
}
if (!authority.empty() && authority[0] == '[')
{
return refuse(error, "IP address hosts are not allowed");
}
std::string host = authority;
std::string port;
size_t portColon = authority.rfind(':');
if (portColon != std::string::npos)
{
host = authority.substr(0, portColon);
port = authority.substr(portColon + 1);
uint32_t portValue = 0;
for (char c : port)
{
portValue = isDigit(c) && portValue <= 65535
? portValue * 10 + (uint32_t)(c - '0')
: 65536;
}
if (!port.empty() && (portValue == 0 || portValue > 65535))
{
return refuse(error, "the URL has an invalid port");
}
}
host = lowerAscii(host);
if (!host.empty() && host.back() == '.')
{
host.pop_back();
}
std::string reason;
if (!checkHost(host, &reason))
{
return refuse(error, reason);
}
// The fragment never reaches a server.
size_t fragment = url.find('#', authorityEnd);
std::string rest = url.substr(
authorityEnd,
fragment == std::string::npos ? std::string::npos
: fragment - authorityEnd);
std::string normalized = "https://" + host;
if (!port.empty())
{
normalized += ':' + port;
}
normalized += rest;
request.url = std::move(normalized);
// Method.
std::string method = request.method.empty() ? std::string("GET")
: toUpper(request.method);
bool allowed = false;
for (const char* candidate : kAllowedMethods)
{
allowed = allowed || method == candidate;
}
if (!allowed)
{
return refuse(
error,
isToken(method) ? "the " + method + " method is not allowed"
: std::string("the method is not a valid HTTP "
"token"));
}
request.method = method;
if ((method == "GET" || method == "HEAD") && !request.body.empty())
{
return refuse(error, "GET and HEAD requests cannot have a body");
}
// Headers.
if (request.headers.size() > limits.maxHeaderCount)
{
return refuse(
error,
"more than " + std::to_string(limits.maxHeaderCount) + " headers");
}
size_t headerBytes = 0;
for (const HttpHeader& header : request.headers)
{
if (!isToken(header.name))
{
return refuse(error, "a header name is not a valid HTTP token");
}
for (char c : header.value)
{
unsigned char byte = (unsigned char)c;
if ((byte < 0x20 && c != '\t') || byte >= 0x7f)
{
return refuse(
error,
"the value of the '" + header.name +
"' header must be printable ASCII");
}
}
if (isForbiddenHeader(header.name))
{
return refuse(
error,
"scripts cannot set the '" + header.name + "' header");
}
headerBytes += header.name.size() + header.value.size();
}
if (headerBytes > limits.maxHeaderBytes)
{
return refuse(
error,
"the headers are larger than " + std::to_string(limits.maxHeaderBytes) +
" bytes");
}
// Body and timeout.
if (request.body.size() > limits.maxRequestBodyBytes)
{
return refuse(
error,
"the request body is larger than " +
std::to_string(limits.maxRequestBodyBytes) + " bytes");
}
if (request.timeoutMs == 0)
{
request.timeoutMs = limits.defaultTimeoutMs;
}
if (request.timeoutMs > limits.maxTimeoutMs)
{
request.timeoutMs = limits.maxTimeoutMs;
}
return true;
}
namespace
{
// RFC 3986 section 5.2.4, on a path that starts with '/'.
std::string removeDotSegments(const std::string& path)
{
std::vector<std::string> kept;
bool directory = false;
size_t start = 1;
while (start <= path.size())
{
size_t slash = path.find('/', start);
bool last = slash == std::string::npos;
size_t end = last ? path.size() : slash;
std::string segment = path.substr(start, end - start);
if (segment == ".")
{
directory = last;
}
else if (segment == "..")
{
if (!kept.empty())
{
kept.pop_back();
}
directory = last;
}
else
{
kept.push_back(std::move(segment));
directory = false;
}
if (last)
{
break;
}
start = end + 1;
}
std::string result;
for (const std::string& segment : kept)
{
result += '/' + segment;
}
if (directory)
{
result += '/';
}
return result.empty() ? std::string("/") : result;
}
bool isSchemeChar(char c)
{
return (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || isDigit(c) ||
c == '+' || c == '-' || c == '.';
}
// RFC 3986 section 5.2: `location` against `base`, a URL check() let
// through. The fragment is dropped, as check() drops it.
bool resolveLocation(
const std::string& base,
std::string location,
std::string* resolved)
{
size_t first = 0;
while (first < location.size() &&
(location[first] == ' ' || location[first] == '\t'))
{
first++;
}
location.erase(0, first);
while (!location.empty() &&
(location.back() == ' ' || location.back() == '\t'))
{
location.pop_back();
}
if (location.empty())
{
return false;
}
// Servers send raw UTF-8, spaces and the odd control character in a
// Location, which browsers percent-encode before using it, as does
// NSURLSession before the Apple transport sees a hop. The same here, so
// the hop can pass check(), which takes nothing but ASCII.
std::string encoded;
for (char c : location)
{
unsigned char byte = (unsigned char)c;
if (byte <= 0x20 || byte >= 0x7f || c == '\\')
{
static const char kHex[] = "0123456789ABCDEF";
encoded += '%';
encoded += kHex[byte >> 4];
encoded += kHex[byte & 0xf];
}
else
{
encoded += c;
}
}
location = std::move(encoded);
// Absolute, with a scheme of its own.
if ((location[0] >= 'a' && location[0] <= 'z') ||
(location[0] >= 'A' && location[0] <= 'Z'))
{
size_t at = 1;
while (at < location.size() && isSchemeChar(location[at]))
{
at++;
}
if (at < location.size() && location[at] == ':')
{
*resolved = location;
return true;
}
}
size_t authorityStart = base.find("://");
if (authorityStart == std::string::npos)
{
return false;
}
authorityStart += 3;
if (location.compare(0, 2, "//") == 0)
{
*resolved = base.substr(0, authorityStart - 2) + location;
return true;
}
size_t authorityEnd = base.find_first_of("/?#", authorityStart);
if (authorityEnd == std::string::npos)
{
authorityEnd = base.size();
}
std::string origin = base.substr(0, authorityEnd);
size_t pathEnd = base.find_first_of("?#", authorityEnd);
std::string path = base.substr(
authorityEnd,
pathEnd == std::string::npos ? std::string::npos
: pathEnd - authorityEnd);
if (path.empty())
{
path = "/";
}
if (location[0] == '#')
{
// The same resource, query included; the fragment never reaches it.
*resolved = base;
return true;
}
std::string merged;
if (location[0] == '/')
{
merged = location;
}
else if (location[0] == '?')
{
merged = path + location;
}
else
{
merged = path.substr(0, path.rfind('/') + 1) + location;
}
size_t mergedPathEnd = merged.find_first_of("?#");
std::string tail = mergedPathEnd == std::string::npos
? std::string()
: merged.substr(mergedPathEnd);
*resolved = origin + removeDotSegments(merged.substr(0, mergedPathEnd)) +
tail;
return true;
}
// scheme://host[:port] of a URL check() let through, as an origin
// compares: the port by value, since check() keeps it as written, and
// left off when it is the default for https:.
std::string originOf(const std::string& url)
{
size_t authorityStart = url.find("://");
authorityStart = authorityStart == std::string::npos ? 0 : authorityStart + 3;
std::string origin = url.substr(0, url.find_first_of("/?#", authorityStart));
size_t colon = origin.find(':', authorityStart);
if (colon == std::string::npos)
{
return origin;
}
uint32_t port = 0;
for (size_t at = colon + 1; at < origin.size(); at++)
{
port = port * 10 + (uint32_t)(origin[at] - '0');
}
origin.resize(colon);
if (port != 443)
{
origin += ':' + std::to_string(port);
}
return origin;
}
void dropHeaders(HttpHeaders* headers, std::initializer_list<const char*> names)
{
for (size_t i = headers->size(); i-- > 0;)
{
std::string name = lowerAscii((*headers)[i].name);
for (const char* dropped : names)
{
if (name == dropped)
{
headers->erase(headers->begin() + (std::ptrdiff_t)i);
break;
}
}
}
}
} // namespace
bool NetPolicy::redirect(
HttpRequest& request,
uint16_t status,
const std::string& location,
uint32_t hops,
const NetLimits& limits,
NetError* error)
{
if (hops >= limits.maxRedirects)
{
if (error != nullptr)
{
error->code = NetErrorCode::Network;
error->message = "more than " + std::to_string(limits.maxRedirects) +
" redirects";
}
return false;
}
HttpRequest hop = request;
if (!resolveLocation(request.url, location, &hop.url))
{
return refuse(error, "the Location header could not be resolved");
}
// The Fetch standard's HTTP-redirect fetch: these continue as a GET,
// without the body and the headers that described it.
if ((status == 303 && hop.method != "GET" && hop.method != "HEAD") ||
((status == 301 || status == 302) && hop.method == "POST"))
{
hop.method = "GET";
hop.body.clear();
dropHeaders(
&hop.headers,
{"content-encoding",
"content-language",
"content-location",
"content-type"});
}
if (!check(hop, limits, error))
{
return false;
}
// Credentials stay with the origin they were meant for.
if (originOf(hop.url) != originOf(request.url))
{
dropHeaders(&hop.headers, {"authorization"});
}
request = std::move(hop);
return true;
}
} // namespace rive::scriptnet
#endif // WITH_RIVE_SCRIPTNET