@arcjet/ip
Version:
Arcjet utilities for finding the originating IP of a request
442 lines (441 loc) • 17.3 kB
JavaScript
import { cloudflare } from "./cloudflare.js";
//#region src/index.ts
function parseXForwardedFor(value) {
if (typeof value !== "string") return [];
const forwardedIps = [];
for (const item of value.split(",")) forwardedIps.push(item.trim());
return forwardedIps;
}
function isIpv4Cidr(cidr) {
return typeof cidr === "object" && cidr !== null && "type" in cidr && typeof cidr.type === "string" && cidr.type === "v4" && "contains" in cidr && typeof cidr.contains === "function";
}
function isIpv6Cidr(cidr) {
return typeof cidr === "object" && cidr !== null && "type" in cidr && typeof cidr.type === "string" && cidr.type === "v6" && "contains" in cidr && typeof cidr.contains === "function";
}
function isProxyService(value) {
return typeof value === "object" && value !== null && "kind" in value && value.kind === "service";
}
function resolveCandidate(candidate, services, proxies, headers) {
if (!isGlobalIp(candidate, proxies)) return;
if (services.length === 0) return candidate;
const service = services.find((service) => !isGlobalIp(candidate, service.ranges));
if (!service) return candidate;
if (typeof headers !== "object" || headers === null) return;
for (const { header, format } of service.clientIp) {
const value = getHeader(headers, header);
const items = format === "ip" ? [typeof value === "string" ? value.trim() : value] : parseXForwardedFor(value).reverse();
for (const item of items) if (isGlobalIp(item, proxies)) return item;
}
}
function isTrustedProxy(ip, segments, proxies) {
if (Array.isArray(proxies) && proxies.length > 0) return proxies.some((proxy) => {
if (typeof proxy === "string") return proxy === ip;
if (isIpv4Tuple(segments) && isIpv4Cidr(proxy)) return proxy.contains(segments);
if (isIpv6Tuple(segments) && isIpv6Cidr(proxy)) return proxy.contains(segments);
return false;
});
return false;
}
function cidrContains(cidr, ip) {
let part = 0;
let shift;
let cidrBits = cidr.bits;
while (cidrBits > 0) {
shift = cidr.partSize - cidrBits;
if (shift < 0) shift = 0;
if (ip[part] >> shift !== cidr.parts[part] >> shift) return false;
cidrBits -= cidr.partSize;
part += 1;
}
return true;
}
var Ipv4Cidr = class {
type = "v4";
partSize = 8;
parts;
bits;
constructor(parts, bits) {
this.bits = bits;
this.parts = parts;
Object.freeze(this);
}
contains(ip) {
return cidrContains(this, ip);
}
};
var Ipv6Cidr = class {
type = "v6";
partSize = 16;
parts;
bits;
constructor(parts, bits) {
this.bits = bits;
this.parts = parts;
Object.freeze(this);
}
contains(ip) {
return cidrContains(this, ip);
}
};
function parseCidr(cidr) {
const cidrParts = cidr.split("/");
if (cidrParts.length !== 2) throw new Error("invalid CIDR address: must be exactly 2 parts");
const parser = new Parser(cidrParts[0]);
const maybeIpv4 = parser.readIpv4Address();
if (isIpv4Tuple(maybeIpv4)) {
const bits = parseInt(cidrParts[1], 10);
if (isNaN(bits) || bits < 0 || bits > 32) throw new Error("invalid CIDR address: incorrect amount of bits");
return new Ipv4Cidr(maybeIpv4, bits);
}
const maybeIpv6 = parser.readIpv6Address();
if (isIpv6Tuple(maybeIpv6)) {
const bits = parseInt(cidrParts[1], 10);
if (isNaN(bits) || bits < 0 || bits > 128) throw new Error("invalid CIDR address: incorrect amount of bits");
return new Ipv6Cidr(maybeIpv6, bits);
}
throw new Error("invalid CIDR address: could not parse IP address");
}
function isCidr(address) {
return address.includes("/");
}
/**
* Parse CIDR addresses and keep non-CIDR IP addresses.
*
* @param value
* Value to parse.
* @returns
* Parsed {@linkcode Cidr} if range or given `value` if IP.
*/
function parseProxy(value) {
if (isCidr(value)) return parseCidr(value);
else return value;
}
/**
* Parse a list of trusted proxies.
*
* CIDR range strings are parsed to {@linkcode Cidr} (so they match by range);
* plain IP strings and {@linkcode ProxyService} objects (such as those created
* by {@linkcode cloudflare}) are passed through unchanged. Use this to
* normalize the `proxies` option before handing it to {@linkcode findIp}.
*
* @param proxies
* Trusted proxies to parse.
* @returns
* Parsed proxies.
*/
function parseProxies(proxies) {
return proxies.map((proxy) => typeof proxy === "string" ? parseProxy(proxy) : proxy);
}
function isIpv4Tuple(segements) {
if (typeof segements === "undefined") return false;
return segements.length === 4;
}
function isIpv6Tuple(segements) {
if (typeof segements === "undefined") return false;
return segements.length === 8;
}
function u16FromBytes(bytes) {
const u8 = new Uint8Array(bytes);
return new Uint16Array(u8.buffer)[0];
}
function u32FromBytes(bytes) {
const u8 = new Uint8Array(bytes);
return new Uint32Array(u8.buffer)[0];
}
var Parser = class {
state;
constructor(input) {
this.state = input;
}
readAtomically(inner) {
const state = this.state;
const result = inner(this);
if (typeof result === "undefined") this.state = state;
return result;
}
peakChar() {
return this.state[0];
}
readChar() {
const b = this.state[0];
this.state = this.state.slice(1);
return b;
}
readGivenChar(target) {
return this.readAtomically((p) => {
const c = p.readChar();
if (c === target) return c;
});
}
readSeparator(sep, index, inner) {
return this.readAtomically((p) => {
if (index > 0) {
if (typeof p.readGivenChar(sep) === "undefined") return;
}
return inner(p);
});
}
readNumber(radix, maxDigits, allowZeroPrefix = false) {
return this.readAtomically((p) => {
let result = 0;
let digitCount = 0;
const hasLeadingZero = p.peakChar() === "0";
function nextCharAsDigit() {
return p.readAtomically((p) => {
const c = p.readChar();
if (c) {
const n = parseInt(c, radix);
if (!isNaN(n)) return n;
}
});
}
for (let digit = nextCharAsDigit(); digit !== void 0; digit = nextCharAsDigit()) {
result = result * radix;
result = result + digit;
digitCount += 1;
if (typeof maxDigits !== "undefined") {
if (digitCount > maxDigits) return;
}
}
if (digitCount === 0) return;
else if (!allowZeroPrefix && hasLeadingZero && digitCount > 1) return;
else return result;
});
}
readIpv4Address() {
return this.readAtomically((p) => {
const groups = [];
for (let idx = 0; idx < 4; idx++) {
const result = p.readSeparator(".", idx, (p) => {
return p.readNumber(10, 3, false);
});
if (result === void 0) return;
else groups.push(result);
}
return groups;
});
}
readIpv6Address() {
const readGroups = (p, groups) => {
const limit = groups.length;
for (const i of groups.keys()) {
if (i < limit - 1) {
const ipv4 = p.readSeparator(":", i, (p) => p.readIpv4Address());
if (isIpv4Tuple(ipv4)) {
const [one, two, three, four] = ipv4;
groups[i + 0] = u16FromBytes([one, two]);
groups[i + 1] = u16FromBytes([three, four]);
return [i + 2, true];
}
}
const group = p.readSeparator(":", i, (p) => p.readNumber(16, 4, true));
if (typeof group !== "undefined") groups[i] = group;
else return [i, false];
}
return [groups.length, false];
};
return this.readAtomically((p) => {
const head = /* @__PURE__ */ new Uint16Array(8);
const [headSize, headIpv4] = readGroups(p, head);
if (headSize === 8) return head;
if (headIpv4) return;
if (typeof p.readGivenChar(":") === "undefined") return;
if (typeof p.readGivenChar(":") === "undefined") return;
const tail = /* @__PURE__ */ new Uint16Array(7);
const limit = 8 - (headSize + 1);
const [tailSize, _] = readGroups(p, tail.subarray(0, limit));
head.set(tail.slice(0, tailSize), 8 - tailSize);
return head;
});
}
readPort() {
return this.readAtomically((p) => {
if (typeof p.readGivenChar(":") !== "undefined") return p.readNumber(10, void 0, true);
});
}
readScopeId() {
return this.readAtomically((p) => {
if (typeof p.readGivenChar("%") !== "undefined") return p.readNumber(10, void 0, true);
});
}
};
const IPV4_BROADCAST = u32FromBytes([
255,
255,
255,
255
]);
function isGlobalIpv4(s, proxies) {
if (typeof s !== "string") return false;
const parser = new Parser(s);
const octets = parser.readIpv4Address();
if (!isIpv4Tuple(octets)) return false;
if (isTrustedProxy(s, octets, proxies)) return false;
parser.readPort();
if (parser.state.length !== 0) return false;
if (octets[0] === 0) return false;
if (octets[0] === 10) return false;
if (octets[0] === 172 && octets[1] >= 16 && octets[1] <= 31) return false;
if (octets[0] === 192 && octets[1] === 168) return false;
if (octets[0] === 127) return false;
if (octets[0] === 100 && (octets[1] & 192) === 64) return false;
if (octets[0] === 169 && octets[1] === 254) return false;
if (octets[0] === 192 && octets[1] === 0 && octets[2] === 0) return false;
if (octets[0] === 192 && octets[1] === 0 && octets[2] === 2) return false;
if (octets[0] === 198 && octets[1] === 51 && octets[2] === 100) return false;
if (octets[0] === 203 && octets[1] === 0 && octets[2] === 113) return false;
if (octets[0] === 198 && (octets[1] & 254) === 18) return false;
const isBroadcast = u32FromBytes(octets) === IPV4_BROADCAST;
if ((octets[0] & 240) === 240 && !isBroadcast) return false;
if (isBroadcast) return false;
for (const octet of octets) if (octet < 0 || octet > 255) return false;
return true;
}
function isGlobalIpv6(s, proxies) {
if (typeof s !== "string") return false;
const parser = new Parser(s);
const segments = parser.readIpv6Address();
if (!isIpv6Tuple(segments)) return false;
if (isTrustedProxy(s, segments, proxies)) return false;
parser.readScopeId();
if (parser.state.length !== 0) return false;
if (segments[0] === 0 && segments[1] === 0 && segments[2] === 0 && segments[3] === 0 && segments[4] === 0 && segments[5] === 0 && segments[6] === 0 && segments[7] === 0) return false;
if (segments[0] === 0 && segments[1] === 0 && segments[2] === 0 && segments[3] === 0 && segments[4] === 0 && segments[5] === 0 && segments[6] === 0 && segments[7] === 1) return false;
if (segments[0] === 0 && segments[1] === 0 && segments[2] === 0 && segments[3] === 0 && segments[4] === 0 && segments[5] === 65535) return false;
if (segments[0] === 100 && segments[1] === 65435 && segments[2] === 1) return false;
if (segments[0] === 256 && segments[1] === 0 && segments[2] === 0 && segments[3] === 0) return false;
if (segments[0] === 8193 && segments[1] < 512) {
if (segments[0] === 8193 && segments[1] === 1 && segments[2] === 0 && segments[3] === 0 && segments[4] === 0 && segments[5] === 0 && segments[6] === 0 && segments[7] === 1) return true;
if (segments[0] === 8193 && segments[1] === 1 && segments[2] === 0 && segments[3] === 0 && segments[4] === 0 && segments[5] === 0 && segments[6] === 0 && segments[7] === 2) return true;
if (segments[0] === 8193 && segments[1] === 3) return true;
if (segments[0] === 8193 && segments[1] === 4 && segments[2] === 274) return true;
if (segments[0] === 8193 && segments[1] >= 32 && segments[1] <= 47) return true;
return false;
}
if (segments[0] === 8193 && segments[1] === 3512) return false;
if ((segments[0] & 65024) === 64512) return false;
if ((segments[0] & 65472) === 65152) return false;
return true;
}
function isGlobalIp(s, proxies) {
if (isGlobalIpv4(s, proxies)) return true;
if (isGlobalIpv6(s, proxies)) return true;
return false;
}
function isHeaders(val) {
return typeof val.get === "function";
}
function getHeader(headers, headerKey) {
if (isHeaders(headers)) return headers.get(headerKey);
else {
const headerValue = headers[headerKey];
if (Array.isArray(headerValue)) return headerValue.join(",");
else return headerValue;
}
}
/**
* Find a client IP address on a request-like object.
*
* @param request
* Request-like object.
* @param [options]
* Configuration (optional).
* @returns
* Found IP address; empty string if not found.
*/
function findIp(request, options) {
const { platform, proxies: rawProxies } = options || {};
const proxies = [];
const services = [];
if (Array.isArray(rawProxies)) for (const cidrOrIp of rawProxies) {
if (typeof cidrOrIp === "string") proxies.push(parseProxy(cidrOrIp));
if (isIpv4Cidr(cidrOrIp) || isIpv6Cidr(cidrOrIp)) proxies.push(cidrOrIp);
if (isProxyService(cidrOrIp)) {
const ranges = [];
for (const range of cidrOrIp.ranges) ranges.push(typeof range === "string" ? parseProxy(range) : range);
services.push({
...cidrOrIp,
ranges
});
}
}
const ipFromRequest = resolveCandidate(request.ip, services, proxies, request.headers);
if (ipFromRequest) return ipFromRequest;
const socketRemoteAddress = resolveCandidate(request.socket?.remoteAddress, services, proxies, request.headers);
if (socketRemoteAddress) return socketRemoteAddress;
const infoRemoteAddress = resolveCandidate(request.info?.remoteAddress, services, proxies, request.headers);
if (infoRemoteAddress) return infoRemoteAddress;
const requestContextIdentitySourceIp = resolveCandidate(request.requestContext?.identity?.sourceIp, services, proxies, request.headers);
if (requestContextIdentitySourceIp) return requestContextIdentitySourceIp;
if (typeof request.headers !== "object" || request.headers === null) return "";
if (platform === "cloudflare") {
const cfConnectingIpv6 = getHeader(request.headers, "cf-connecting-ipv6");
if (isGlobalIpv6(cfConnectingIpv6, proxies)) return cfConnectingIpv6;
const cfConnectingIp = getHeader(request.headers, "cf-connecting-ip");
if (isGlobalIp(cfConnectingIp, proxies)) return cfConnectingIp;
return "";
}
if (platform === "firebase") {
const fahClientIp = resolveCandidate(getHeader(request.headers, "x-fah-client-ip"), services, proxies, request.headers);
if (fahClientIp) return fahClientIp;
const xForwardedForItems = parseXForwardedFor(getHeader(request.headers, "x-forwarded-for"));
for (const item of xForwardedForItems.reverse()) {
const resolved = resolveCandidate(item, services, proxies, request.headers);
if (resolved) return resolved;
}
return "";
}
if (platform === "fly-io") {
const flyClientIp = resolveCandidate(getHeader(request.headers, "fly-client-ip"), services, proxies, request.headers);
if (flyClientIp) return flyClientIp;
return "";
}
if (platform === "vercel") {
const xRealIp = resolveCandidate(getHeader(request.headers, "x-real-ip"), services, proxies, request.headers);
if (xRealIp) return xRealIp;
const xVercelForwardedForItems = parseXForwardedFor(getHeader(request.headers, "x-vercel-forwarded-for"));
for (const item of xVercelForwardedForItems.reverse()) {
const resolved = resolveCandidate(item, services, proxies, request.headers);
if (resolved) return resolved;
}
const xForwardedForItems = parseXForwardedFor(getHeader(request.headers, "x-forwarded-for"));
for (const item of xForwardedForItems.reverse()) {
const resolved = resolveCandidate(item, services, proxies, request.headers);
if (resolved) return resolved;
}
return "";
}
if (platform === "render") {
const trueClientIp = resolveCandidate(getHeader(request.headers, "true-client-ip"), services, proxies, request.headers);
if (trueClientIp) return trueClientIp;
return "";
}
const xForwardedForItems = parseXForwardedFor(getHeader(request.headers, "x-forwarded-for"));
for (const item of xForwardedForItems.reverse()) {
const resolved = resolveCandidate(item, services, proxies, request.headers);
if (resolved) return resolved;
}
const xClientIp = resolveCandidate(getHeader(request.headers, "x-client-ip"), services, proxies, request.headers);
if (xClientIp) return xClientIp;
const doConnectingIp = resolveCandidate(getHeader(request.headers, "do-connecting-ip"), services, proxies, request.headers);
if (doConnectingIp) return doConnectingIp;
const fastlyClientIp = resolveCandidate(getHeader(request.headers, "fastly-client-ip"), services, proxies, request.headers);
if (fastlyClientIp) return fastlyClientIp;
const trueClientIp = resolveCandidate(getHeader(request.headers, "true-client-ip"), services, proxies, request.headers);
if (trueClientIp) return trueClientIp;
const xRealIp = resolveCandidate(getHeader(request.headers, "x-real-ip"), services, proxies, request.headers);
if (xRealIp) return xRealIp;
const xClusterClientIp = resolveCandidate(getHeader(request.headers, "x-cluster-client-ip"), services, proxies, request.headers);
if (xClusterClientIp) return xClusterClientIp;
const xForwarded = resolveCandidate(getHeader(request.headers, "x-forwarded"), services, proxies, request.headers);
if (xForwarded) return xForwarded;
const forwardedFor = resolveCandidate(getHeader(request.headers, "forwarded-for"), services, proxies, request.headers);
if (forwardedFor) return forwardedFor;
const forwarded = resolveCandidate(getHeader(request.headers, "forwarded"), services, proxies, request.headers);
if (forwarded) return forwarded;
const xAppEngineUserIp = resolveCandidate(getHeader(request.headers, "x-appengine-user-ip"), services, proxies, request.headers);
if (xAppEngineUserIp) return xAppEngineUserIp;
return "";
}
//#endregion
export { cloudflare, findIp as default, findIp, parseProxies, parseProxy };