@open-condo/miniapp-utils
Version:
A set of helper functions / components / hooks used to build new condo apps fast
402 lines (396 loc) • 14.1 kB
JavaScript
// src/helpers/proxying/utils.ts
import jwt from "jsonwebtoken";
import proxyAddr from "proxy-addr";
import { z } from "zod";
// src/helpers/ip/utils.ts
var v4Seg = "(?:[0-9]|[1-9][0-9]|1[0-9][0-9]|2[0-4][0-9]|25[0-5])";
var v4Str = `(${v4Seg}[.]){3}${v4Seg}`;
var IPv4Reg = new RegExp(`^${v4Str}$`);
var v6Seg = "(?:[0-9a-fA-F]{1,4})";
var IPv6Reg = new RegExp(
`^((?:${v6Seg}:){7}(?:${v6Seg}|:)|(?:${v6Seg}:){6}(?:${v4Str}|:${v6Seg}|:)|(?:${v6Seg}:){5}(?::${v4Str}|(:${v6Seg}){1,2}|:)|(?:${v6Seg}:){4}(?:(:${v6Seg}){0,1}:${v4Str}|(:${v6Seg}){1,3}|:)|(?:${v6Seg}:){3}(?:(:${v6Seg}){0,2}:${v4Str}|(:${v6Seg}){1,4}|:)|(?:${v6Seg}:){2}(?:(:${v6Seg}){0,3}:${v4Str}|(:${v6Seg}){1,5}|:)|(?:${v6Seg}:){1}(?:(:${v6Seg}){0,4}:${v4Str}|(:${v6Seg}){1,6}|:)|(?::((?::${v6Seg}){0,5}:${v4Str}|(?::${v6Seg}){1,7}|:)))(%[0-9a-zA-Z]{1,})?$`
);
function isIPv4(s) {
return IPv4Reg.test(s);
}
function isIPv6(s) {
return IPv6Reg.test(s);
}
function isIP(s) {
if (isIPv4(s)) return 4;
if (isIPv6(s)) return 6;
return 0;
}
// src/helpers/ip/ipv4.ts
function ipv4ToLong(ip) {
if (!isIPv4(ip)) {
throw new Error(`not a valid IPv4 address: ${ip}`);
}
const octets = ip.split(".");
return (parseInt(octets[0], 10) << 24) + (parseInt(octets[1], 10) << 16) + (parseInt(octets[2], 10) << 8) + parseInt(octets[3], 10) >>> 0;
}
function createLongChecker(subnet) {
const [subnetAddress, prefixLengthString] = subnet.split("/");
const prefixLength = parseInt(prefixLengthString, 10);
if (!subnetAddress || !Number.isInteger(prefixLength)) {
throw new Error(`not a valid IPv4 subnet: ${subnet}`);
}
if (prefixLength < 0 || prefixLength > 32) {
throw new Error(`not a valid IPv4 prefix length: ${prefixLength} (from ${subnet})`);
}
const subnetLong = ipv4ToLong(subnetAddress);
return (addressLong) => {
if (prefixLength === 0) {
return true;
}
const subnetPrefix = subnetLong >> 32 - prefixLength;
const addressPrefix = addressLong >> 32 - prefixLength;
return subnetPrefix === addressPrefix;
};
}
function createChecker(subnetOrSubnets) {
if (Array.isArray(subnetOrSubnets)) {
const checks = subnetOrSubnets.map((subnet) => createLongChecker(subnet));
return (address) => {
const addressLong = ipv4ToLong(address);
return checks.some((check2) => check2(addressLong));
};
}
const check = createLongChecker(subnetOrSubnets);
return (address) => {
const addressLong = ipv4ToLong(address);
return check(addressLong);
};
}
// src/helpers/ip/ipv6.ts
var dot = /\./;
var mappedIpv4 = /^(.+:ffff:)(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})(?:%.+)?$/;
var colon = /:/;
var doubleColon = /::/;
function mappedIpv4ToIpv6(ip) {
const matches = ip.match(mappedIpv4);
if (!matches || !isIPv4(matches[2])) {
throw new Error(`not a mapped IPv4 address: ${ip}`);
}
const prefix = matches[1];
const ipv4 = matches[2];
const parts = ipv4.split(dot).map((x) => parseInt(x, 10));
const segment7 = ((parts[0] << 8) + parts[1]).toString(16);
const segment8 = ((parts[2] << 8) + parts[3]).toString(16);
return `${prefix}${segment7}:${segment8}`;
}
function extractMappedIpv4(ip) {
const matches = ip.match(mappedIpv4);
if (!matches || !isIPv4(matches[2])) {
throw new Error(`not a mapped IPv4 address: ${ip}`);
}
return matches[2];
}
function getIpv6Segments(ip) {
if (!isIPv6(ip)) {
throw new Error(`not a valid IPv6 address: ${ip}`);
}
if (dot.test(ip)) {
return getIpv6Segments(mappedIpv4ToIpv6(ip));
}
const [beforeChunk, afterChunk] = ip.split(doubleColon);
const beforeParts = beforeChunk && beforeChunk.split(colon) || [];
const afterParts = afterChunk && afterChunk.split(colon) || [];
const missingSegments = new Array(8 - (beforeParts.length + afterParts.length));
return beforeParts.concat(missingSegments, afterParts);
}
function createChecker2(subnetOrSubnets) {
if (Array.isArray(subnetOrSubnets)) {
const checks = subnetOrSubnets.map((subnet) => createSegmentChecker(subnet));
return (address) => {
const segments = getIpv6Segments(address);
return checks.some((check2) => check2(segments));
};
}
const check = createSegmentChecker(subnetOrSubnets);
return (address) => {
const segments = getIpv6Segments(address);
return check(segments);
};
}
function createSegmentChecker(subnet) {
const [subnetAddress, prefixLengthString] = subnet.split("/");
const prefixLength = parseInt(prefixLengthString, 10);
if (!subnetAddress || !Number.isInteger(prefixLength)) {
throw new Error(`not a valid IPv6 CIDR subnet: ${subnet}`);
}
if (prefixLength < 0 || prefixLength > 128) {
throw new Error(`not a valid IPv6 prefix length: ${prefixLength} (from ${subnet})`);
}
const subnetSegments = getIpv6Segments(subnetAddress);
return (addressSegments) => {
for (let i = 0; i < 8; ++i) {
const bitCount = Math.min(prefixLength - i * 16, 16);
if (bitCount <= 0) {
break;
}
const subnetPrefix = (subnetSegments[i] && parseInt(subnetSegments[i], 16) || 0) >> 16 - bitCount;
const addressPrefix = (addressSegments[i] && parseInt(addressSegments[i], 16) || 0) >> 16 - bitCount;
if (subnetPrefix !== addressPrefix) {
return false;
}
}
return true;
};
}
var specialNetsCache = {};
function isIPv4MappedAddress(address) {
if (!("mapped" in specialNetsCache)) {
specialNetsCache["mapped"] = createChecker2("::ffff:0:0/96");
}
if (specialNetsCache["mapped"](address)) {
const matches = address.match(mappedIpv4);
return Boolean(matches && isIPv4(matches[2]));
}
return false;
}
// src/helpers/ip/index.ts
function isInSubnet(address, subnetOrSubnets) {
return createChecker3(subnetOrSubnets)(address);
}
function createChecker3(subnetOrSubnets) {
if (!Array.isArray(subnetOrSubnets)) {
return createChecker3([subnetOrSubnets]);
}
const subnetsByVersion = subnetOrSubnets.reduce(
(acc, subnet) => {
const ip = subnet.split("/")[0];
acc[isIP(ip)].push(subnet);
return acc;
},
{ 0: [], 4: [], 6: [] }
);
if (subnetsByVersion[0].length !== 0) {
throw new Error(`some subnets are not valid IP addresses: ${subnetsByVersion[0]}`);
}
const check4 = createChecker(subnetsByVersion[4]);
const check6 = createChecker2(subnetsByVersion[6]);
return (address) => {
if (!isIP(address)) {
throw new Error(`not a valid IPv4 or IPv6 address: ${address}`);
}
if (isIPv6(address) && isIPv4MappedAddress(address)) {
return check6(address) || check4(extractMappedIpv4(address));
}
if (isIPv6(address)) {
return check6(address);
} else {
return check4(address);
}
};
}
// src/helpers/proxying/utils.ts
var _ipSchema = z.union([z.ipv4(), z.ipv6()]);
var _timeStampBasicRegexp = /^\d+$/;
var DEFAULT_PROXY_TIMEOUT_IN_MS = 5e3;
var X_PROXY_ID_HEADER = "x-proxy-id";
var X_PROXY_IP_HEADER = "x-proxy-ip";
var X_PROXY_TIMESTAMP_HEADER = "x-proxy-timestamp";
var X_PROXY_SIGNATURE_HEADER = "x-proxy-signature";
function _getTimestampFromHeader(timestamp) {
if (!_timeStampBasicRegexp.test(timestamp)) return Number.NaN;
return new Date(parseInt(timestamp)).getTime();
}
function _isProxyIP(ip, proxyConfig) {
const addresses = Array.isArray(proxyConfig.address) ? proxyConfig.address : [proxyConfig.address];
const config = addresses.reduce((acc, addr) => {
const isSubnet = addr.split("/").length > 1;
if (isSubnet) {
acc.subnets.push(addr);
} else {
acc.ips.push(addr);
}
return acc;
}, { ips: [], subnets: [] });
if (config.ips.length && config.ips.includes(ip)) {
return true;
}
return !!(config.subnets.length && isInSubnet(ip, config.subnets));
}
function getRequestIp(req, trustProxyFn, knownProxies) {
const originalIP = proxyAddr(req, trustProxyFn);
if (!knownProxies) return originalIP;
const xProxyId = req.headers[X_PROXY_ID_HEADER];
const xProxyIp = req.headers[X_PROXY_IP_HEADER];
const xProxyTimestamp = req.headers[X_PROXY_TIMESTAMP_HEADER];
const xProxySignature = req.headers[X_PROXY_SIGNATURE_HEADER];
if (typeof xProxyId !== "string" || typeof xProxyIp !== "string" || typeof xProxyTimestamp !== "string" || typeof xProxySignature !== "string") {
return originalIP;
}
const { success: isValidIp } = _ipSchema.safeParse(xProxyIp);
if (!isValidIp) {
return originalIP;
}
const timestamp = _getTimestampFromHeader(xProxyTimestamp);
const now = Date.now();
if (Number.isNaN(timestamp) || timestamp > now || now - timestamp > DEFAULT_PROXY_TIMEOUT_IN_MS) {
return originalIP;
}
if (!Object.hasOwn(knownProxies, xProxyId)) {
return originalIP;
}
const proxyConfig = knownProxies[xProxyId];
if (!proxyConfig || !_isProxyIP(originalIP, proxyConfig)) {
return originalIP;
}
try {
const jwtPayload = jwt.verify(xProxySignature, proxyConfig.secret, { algorithms: ["HS256"] });
const expectedPayloadSchema = z.object({
[X_PROXY_TIMESTAMP_HEADER]: z.literal(xProxyTimestamp),
[X_PROXY_IP_HEADER]: z.literal(xProxyIp),
[X_PROXY_ID_HEADER]: z.literal(xProxyId),
method: z.literal(req.method),
url: z.literal(req.url)
});
const { success: isMatchingSignature } = expectedPayloadSchema.safeParse(jwtPayload);
return isMatchingSignature ? xProxyIp : originalIP;
} catch {
return originalIP;
}
}
function getProxyHeadersForIp(method, url, ip, proxyId, secret) {
const timestampString = String(Date.now());
return {
[X_PROXY_IP_HEADER]: ip,
[X_PROXY_ID_HEADER]: proxyId,
[X_PROXY_TIMESTAMP_HEADER]: timestampString,
[X_PROXY_SIGNATURE_HEADER]: jwt.sign({
[X_PROXY_IP_HEADER]: ip,
[X_PROXY_ID_HEADER]: proxyId,
[X_PROXY_TIMESTAMP_HEADER]: timestampString,
method,
url
}, secret, {
expiresIn: Math.round(DEFAULT_PROXY_TIMEOUT_IN_MS / 1e3),
algorithm: "HS256"
})
};
}
function isRelativeUrl(url) {
return url.startsWith("/");
}
function replaceUpstreamEndpoint({
endpoint,
proxyPrefix,
upstreamPrefix,
upstreamOrigin,
rewrites = {}
}) {
const isRelativeLocation = isRelativeUrl(endpoint);
const locationUrl = new URL(endpoint, "https://_");
let targetLocation;
const lookupUrl = new URL(endpoint, upstreamOrigin);
lookupUrl.search = "";
if (isRelativeLocation || lookupUrl.origin === upstreamOrigin) {
targetLocation ??= rewrites[lookupUrl.pathname];
}
targetLocation ??= rewrites[lookupUrl.toString()];
if (targetLocation) {
const isRelativeTarget = isRelativeUrl(targetLocation);
const targetUrl = new URL(targetLocation, upstreamOrigin);
const targetSearchParams = new URLSearchParams(targetUrl.searchParams);
if (!targetUrl.hash) {
targetUrl.hash = locationUrl.hash;
}
targetUrl.search = locationUrl.search;
for (const [name] of targetSearchParams.entries()) {
targetUrl.searchParams.delete(name);
}
for (const [name, value] of targetSearchParams.entries()) {
targetUrl.searchParams.append(name, value);
}
if (isRelativeTarget) {
return targetUrl.pathname + targetUrl.search + targetUrl.hash;
} else {
return targetUrl.toString();
}
}
if ((isRelativeLocation || locationUrl.origin === upstreamOrigin) && locationUrl.pathname.startsWith(upstreamPrefix)) {
locationUrl.pathname = proxyPrefix + locationUrl.pathname.slice(upstreamPrefix.length);
return locationUrl.pathname + locationUrl.search + locationUrl.hash;
}
return endpoint;
}
// src/helpers/proxying/proxy.ts
import httpProxy from "http-proxy";
function createProxy(options) {
const {
name,
proxyPrefix,
upstreamOrigin,
upstreamPrefix,
ipProxying,
locationRewrites,
cookiePathRewrites,
logger = console
} = options;
const proxy = httpProxy.createProxy({
target: upstreamOrigin,
changeOrigin: true
});
const trustProxyFn = (ipProxying == null ? void 0 : ipProxying.trustProxyFn) ?? (() => false);
proxy.on("proxyReq", (proxyReq, req) => {
var _a;
if ((_a = req.url) == null ? void 0 : _a.startsWith(proxyPrefix)) {
proxyReq.path = upstreamPrefix + req.url.slice(proxyPrefix.length);
}
proxyReq.setHeader("via", name);
if (req.url && req.method && ipProxying) {
const ip = getRequestIp(req, trustProxyFn, ipProxying.knownProxies);
const headers = getProxyHeadersForIp(req.method, proxyReq.path, ip, ipProxying.proxyId, ipProxying.proxySecret);
for (const [headerName, headerValue] of Object.entries(headers)) {
proxyReq.setHeader(headerName, headerValue);
}
}
});
proxy.on("proxyRes", (proxyRes, _req, _res) => {
if (proxyRes.headers.location) {
proxyRes.headers.location = replaceUpstreamEndpoint({
endpoint: proxyRes.headers.location,
proxyPrefix,
upstreamPrefix,
upstreamOrigin,
rewrites: locationRewrites
});
}
const setCookieHeaders = proxyRes.headers["set-cookie"];
if (setCookieHeaders) {
proxyRes.headers["set-cookie"] = setCookieHeaders.map((cookieString) => {
return cookieString.replace(/;\s*Path=([^;]+)/i, (match, pathValue) => {
const rewrittenPath = replaceUpstreamEndpoint({
endpoint: pathValue,
proxyPrefix,
upstreamPrefix,
upstreamOrigin,
rewrites: cookiePathRewrites
});
return match.replace(pathValue, rewrittenPath);
});
});
}
});
return function syncProxyHandler(req, res) {
proxy.web(req, res, {}, (err) => {
if (err) {
logger.error({ msg: "Proxy error", err });
if (!res.headersSent) {
res.writeHead(502, { "Content-Type": "application/json" });
res.end(JSON.stringify({ errors: [{ message: "Proxy error" }] }));
} else {
res.end();
}
}
});
};
}
export {
createProxy,
getProxyHeadersForIp,
getRequestIp
};
//# sourceMappingURL=index.mjs.map