snapshot of https://github.com/RocketChat/Rocket.Chat.git (develop, 2026-09-16; blobs > 131072B omitted)
Some checks failed
Create issues from TODOs / todos (push) Failing after 3s
Close inactive issues / close-issues (push) Has been cancelled
Deploy GitHub Pages / deploy-preview (push) Has been cancelled
CI / ⚙️ Variables Setup (push) Has been cancelled
CI / ⚙️ Test Guard (push) Has been cancelled
CI / 🚀 Notify external services - draft (push) Has been cancelled
CI / 📦 Build Packages (push) Has been cancelled
CI / 📦 Meteor Build (coverage) (push) Has been cancelled
CI / 📦 Meteor Build (production) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [account-service presence-service omnichannel-transcript-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [account-service presence-service omnichannel-transcript-service], fips) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [authorization-service queue-worker-service ddp-streamer-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [authorization-service queue-worker-service ddp-streamer-service], fips) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], coverage) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], fips) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [account-service presence-service omnichannel-transcript-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [authorization-service queue-worker-service ddp-streamer-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [rocketchat], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [rocketchat], coverage) (push) Has been cancelled
CI / 🚢 Publish Docker Images (ghcr.io) (push) Has been cancelled
CI / 📦 Track Image Sizes (push) Has been cancelled
CI / 🔎 Code Check - Action Lint (push) Has been cancelled
CI / 🔎 Code Check (push) Has been cancelled
CI / 🔨 Test Storybook (push) Has been cancelled
CI / 🔨 Test Unit (push) Has been cancelled
CI / 🔨 Test API (CE) (push) Has been cancelled
CI / 🔨 Test API Livechat (CE) (push) Has been cancelled
CI / 🔨 Test UI (CE) (push) Has been cancelled
CI / 🔨 Test API (EE) (push) Has been cancelled
CI / 🔨 Test API Livechat (EE) (push) Has been cancelled
CI / 🔨 Test API Apps (deno-runtime - EE) (push) Has been cancelled
CI / 🔨 Test UI (EE) (push) Has been cancelled
CI / 🔨 Test API (FIPS) (push) Has been cancelled
CI / 🔨 Test API Livechat (FIPS) (push) Has been cancelled
CI / 🔨 Test UI (FIPS) (push) Has been cancelled
CI / 🔨 Test Federation Matrix (push) Has been cancelled
CI / 📊 Report Coverage (push) Has been cancelled
CI / ✅ Tests Done (push) Has been cancelled
CI / 🚀 Publish build assets (push) Has been cancelled
CI / 🚀 Publish Docker Images (DockerHub) (push) Has been cancelled
CI / 🚀 Notify external services (push) Has been cancelled
CI / Update Version Durability (push) Has been cancelled
Release / Release (push) Has been cancelled
Code scanning - action / CodeQL-Build (push) Failing after 2s
Auto-close duplicate issues / auto-close-duplicates (push) Failing after 1s

This commit is contained in:
upstream-mirror
2026-09-16 01:21:02 +05:00
commit 30810eecd7
10161 changed files with 987110 additions and 0 deletions

View File

@@ -0,0 +1,146 @@
import { isTruthy } from '@rocket.chat/tools';
import {
allowlistedIpResolved,
isIpInAnyRange,
isIpValid,
isValidDomain,
normalizeAllowlistEntry,
normalizeHostForAllowlistMatch,
nslookup,
parseIpv4WithPort,
} from './helpers';
export const parseSsrfAllowlist = (value: string): string[] => {
if (!value.trim()) {
return [];
}
return value
.split(/[\n,]/)
.map((entry) => entry.trim())
.filter(isTruthy);
};
function getEffectiveAllowlist(allowListRaw: string[]): string[] {
return allowListRaw.map(normalizeAllowlistEntry).filter((entry) => entry.length > 0);
}
/** Normalize allowList (string or string[]) to a single effective list. Parses raw string in this single place. */
function toEffectiveAllowlist(allowList?: string | string[]): string[] {
if (allowList === undefined || allowList === null) {
return [];
}
if (typeof allowList === 'string') {
return getEffectiveAllowlist(parseSsrfAllowlist(allowList));
}
return getEffectiveAllowlist(allowList);
}
function isInAllowlist(hostOrIp: string, port: string | undefined, allowlist: string[]): boolean {
if (allowlist.length === 0) {
return false;
}
const normalized = normalizeHostForAllowlistMatch(hostOrIp);
const withPort = port ? `${normalized}:${port}` : normalized;
return allowlist.some((entry) => entry === normalized || entry === withPort);
}
/**
* Returns whether the URL is allowed by SSRF rules.
* @param input - URL or host to check
* @param allowList - Optional raw string (newline/comma-separated) or array of allowed hosts/IPs/CIDRs. Parsed inside.
*/
export const checkForSsrf = async (input: string, allowList?: string | string[]): Promise<boolean> => {
const result = await checkForSsrfWithIp(input, allowList);
return result.allowed;
};
/**
* SSRF check with resolved IP for pinning. allowList is optional; string is parsed in this single place.
*/
export const checkForSsrfWithIp = async (
input: string,
allowList?: string | string[],
): Promise<{ allowed: false } | { allowed: true; resolvedIp: string }> => {
const effectiveAllowlist = toEffectiveAllowlist(allowList);
let ipOrDomain: string;
let port: string | undefined;
let wasUrlParsed = false;
try {
const url = new URL(input);
if (url.protocol !== 'http:' && url.protocol !== 'https:') {
return { allowed: false };
}
ipOrDomain = url.hostname;
port = url.port || undefined;
wasUrlParsed = true;
} catch {
const parsed = parseIpv4WithPort(input);
if (parsed) {
ipOrDomain = parsed.ip;
port = parsed.port;
} else {
ipOrDomain = input;
}
}
if (ipOrDomain.startsWith('[') && ipOrDomain.endsWith(']')) {
ipOrDomain = ipOrDomain.slice(1, -1);
}
const parsed = parseIpv4WithPort(ipOrDomain);
if (parsed) {
ipOrDomain = parsed.ip;
if (parsed.port && !port) {
port = parsed.port;
}
}
const ipValid = isIpValid(ipOrDomain);
const domainValid = isValidDomain(ipOrDomain);
if (!ipValid && !domainValid) {
return { allowed: false };
}
if (isInAllowlist(ipOrDomain, port, effectiveAllowlist)) {
if (ipValid) {
return { allowed: true, resolvedIp: allowlistedIpResolved(ipOrDomain, port, wasUrlParsed) };
}
if (domainValid) {
try {
const resolvedIp = await nslookup(ipOrDomain);
const resolvedIpWithPort = !wasUrlParsed && port ? `${resolvedIp}:${port}` : resolvedIp;
return { allowed: true, resolvedIp: resolvedIpWithPort };
} catch {
return { allowed: false };
}
}
}
if (ipValid && isIpInAnyRange(ipOrDomain)) {
return { allowed: false };
}
if (domainValid && /metadata\.google\.internal/i.test(ipOrDomain)) {
return { allowed: false };
}
if (!domainValid) {
const ipWithPort = !wasUrlParsed && port ? `${ipOrDomain}:${port}` : ipOrDomain;
return { allowed: true, resolvedIp: ipWithPort };
}
try {
const resolvedIp = await nslookup(ipOrDomain);
if (isIpInAnyRange(resolvedIp)) {
return { allowed: false };
}
const resolvedIpWithPort = !wasUrlParsed && port ? `${resolvedIp}:${port}` : resolvedIp;
return { allowed: true, resolvedIp: resolvedIpWithPort };
} catch {
return { allowed: false };
}
};

View File

@@ -0,0 +1,45 @@
export const ipv4Ranges = [
'0.0.0.0/8',
'10.0.0.0/8',
'100.64.0.0/10',
'127.0.0.0/8',
'169.254.0.0/16',
'172.16.0.0/12',
'192.0.0.0/24',
'192.0.2.0/24',
'192.88.99.0/24',
'192.168.0.0/16',
'198.18.0.0/15',
'198.51.100.0/24',
'203.0.113.0/24',
'224.0.0.0/4',
'240.0.0.0/4',
'255.255.255.255',
'100.100.100.200/32',
];
export const ipv6Ranges = [
'::/128',
'::1/128',
'::ffff:0:0/96',
'3fff::/20',
'5f00::/16',
'64:ff9b::/96',
'64:ff9b:1::/48',
'100::/64',
'2001::/32',
'2001:20::/28',
'2001:db8::/32',
'2002::/16',
'fc00::/7',
'fe80::/10',
'ff00::/8',
];
export const domainPattern = /^(?=.{1,253}$)(?:[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[A-Za-z]{2,63}$/;
export const ipv4WithPortPattern = /^(\d+\.\d+\.\d+\.\d+)(?::(\d+))?$/;
export const MAX_REDIRECTS = 5;
export const redirectStatus = new Set([301, 302, 303, 307, 308]);

View File

@@ -0,0 +1,163 @@
import { lookup } from 'node:dns';
import net from 'node:net';
import { domainPattern, ipv4Ranges, ipv4WithPortPattern, ipv6Ranges } from './constants';
export const isValidDomain = (domain: string) => domainPattern.test(domain);
export const unwrapBrackets = (address: string) => (address.startsWith('[') && address.endsWith(']') ? address.slice(1, -1) : address);
export const isIpValid = (ip: string): boolean => net.isIP(unwrapBrackets(ip)) !== 0;
export const isIpInCidrRange = (ip: string, cidr: string): boolean => {
const [network, prefixStr] = cidr.split('/');
const ipUnwrapped = unwrapBrackets(ip);
if (net.isIPv4(ipUnwrapped)) {
const prefix = prefixStr ? parseInt(prefixStr, 10) : 32;
if (!network || !net.isIPv4(network)) {
return false;
}
const toNum = (ipString: string) =>
ipString.split('.').reduce((accumulated, octet) => (accumulated << 8) + parseInt(octet, 10), 0) >>> 0;
const mask = prefix === 0 ? 0 : (0xffffffff << (32 - prefix)) >>> 0;
return (toNum(ipUnwrapped) & mask) === (toNum(network) & mask);
}
if (net.isIPv6(ipUnwrapped)) {
const prefix = prefixStr ? parseInt(prefixStr, 10) : 128;
if (!network || !net.isIPv6(unwrapBrackets(network))) {
return false;
}
const toBigInt = (address: string): bigint => {
const parts = unwrapBrackets(address).split(':');
const nonEmptyParts = parts.filter((part) => part.length > 0);
const fill = 8 - nonEmptyParts.reduce((count, part) => count + (part.includes('.') ? 2 : 1), 0);
const groups: string[] = [];
let filled = false;
for (const part of parts) {
if (part === '') {
if (!filled) {
for (let index = 0; index < fill; index++) groups.push('0');
filled = true;
}
} else if (part.includes('.')) {
if (!net.isIPv4(part)) {
return 0n;
}
const num = part.split('.').reduce((accumulated, octet) => (accumulated << 8) + parseInt(octet, 10), 0) >>> 0;
groups.push((num >>> 16).toString(16), (num & 0xffff).toString(16));
} else {
groups.push(part);
}
}
return groups.reduce((value, hexGroup) => (value << 16n) + BigInt(parseInt(hexGroup, 16)), 0n);
};
const mask = prefix === 0 ? 0n : ((1n << BigInt(prefix)) - 1n) << (128n - BigInt(prefix));
return (toBigInt(ip) & mask) === (toBigInt(network) & mask);
}
return false;
};
export const isIpInAnyRange = (ip: string): boolean => {
const ranges = ip.includes(':') ? ipv6Ranges : ipv4Ranges;
return ranges.some((range) => isIpInCidrRange(ip, range));
};
export const normalizeAllowlistEntry = (entry: string): string => {
const trimmed = entry.trim();
if (!trimmed) {
return '';
}
if (isValidDomain(trimmed)) {
return trimmed.toLowerCase();
}
if (trimmed.startsWith('[')) {
return trimmed;
}
if (trimmed.includes(':') && net.isIPv6(trimmed)) {
return `[${trimmed}]`;
}
return trimmed.toLowerCase();
};
export const normalizeHostForAllowlistMatch = (hostOrIp: string): string => {
if (hostOrIp.startsWith('[')) {
return hostOrIp;
}
if (hostOrIp.includes(':') && net.isIPv6(hostOrIp)) {
return `[${hostOrIp}]`;
}
return hostOrIp.toLowerCase();
};
export const parseIpv4WithPort = (input: string): { ip: string; port?: string } | null => {
const match = input.match(ipv4WithPortPattern);
if (match) {
return { ip: match[1]!, port: match[2] };
}
return null;
};
export const allowlistedIpResolved = (ipOrDomain: string, port: string | undefined, wasUrlParsed: boolean): string => {
if (wasUrlParsed || !port) {
return ipOrDomain;
}
if (ipOrDomain.includes(':')) {
return `[${ipOrDomain}]:${port}`;
}
return `${ipOrDomain}:${port}`;
};
export function nslookup(hostname: string): Promise<string> {
return new Promise((resolve, reject) => {
lookup(hostname, (err, address) => {
if (err) reject(err);
else resolve(address);
});
});
}
export function checkDirectIp(ip: string): boolean {
return /^(\d+\.\d+\.\d+\.\d+|\[?[0-9a-fA-F:]+]?)$/.test(ip);
}
export function extractHostname(urlString: string): string | null {
try {
const { hostname } = new URL(urlString);
if (hostname.startsWith('[') && hostname.endsWith(']')) {
return hostname.slice(1, -1);
}
return hostname;
} catch {
return null;
}
}
export function buildPinnedUrl(originalUrl: string, resolvedIp: string): string {
try {
const url = new URL(originalUrl);
let ipAddress: string;
const ipv4WithPortMatch = resolvedIp.match(/^(\d+\.\d+\.\d+\.\d+)(?::(\d+))$/);
if (ipv4WithPortMatch) {
ipAddress = ipv4WithPortMatch[1];
} else if (resolvedIp.includes(':') && !resolvedIp.includes('.')) {
const ipv6WithPortMatch = resolvedIp.match(/^(\[[0-9a-fA-F:]+\])(?::(\d+))$/);
if (ipv6WithPortMatch) {
ipAddress = ipv6WithPortMatch[1];
} else {
ipAddress = resolvedIp.startsWith('[') && resolvedIp.endsWith(']') ? resolvedIp : `[${resolvedIp}]`;
}
} else {
// IPv4 without port
ipAddress = resolvedIp;
}
url.hostname = ipAddress;
return url.toString();
} catch {
return originalUrl;
}
}

View File

@@ -0,0 +1,198 @@
import http from 'node:http';
import https from 'node:https';
import { Logger } from '@rocket.chat/logger';
import { censorUrl } from '@rocket.chat/tools';
import { AbortController } from 'abort-controller';
import { HttpProxyAgent } from 'http-proxy-agent';
import { HttpsProxyAgent } from 'https-proxy-agent';
import fetch, { FetchError, Response } from 'node-fetch';
import { getProxyForUrl } from 'proxy-from-env';
import { checkForSsrfWithIp, parseSsrfAllowlist } from './checkForSsrf';
import { MAX_REDIRECTS, redirectStatus } from './constants';
import { buildPinnedUrl, checkDirectIp, extractHostname } from './helpers';
import { parseRequestOptions } from './parsers';
import type { ExtendedFetchOptions } from './types';
const logger = new Logger('ExternalRequest');
function getFetchAgent<U extends string>(
url: U,
allowSelfSignedCerts?: boolean,
originalHostname?: string,
): http.Agent | https.Agent | null | HttpsProxyAgent<U> | HttpProxyAgent<U> {
const isHttps = url.startsWith('https');
const proxy = getProxyForUrl(url);
if (proxy) {
const AgentFn = isHttps ? HttpsProxyAgent : HttpProxyAgent;
return new AgentFn(proxy);
}
if (!isHttps) {
return new http.Agent();
}
const agentOptions: https.AgentOptions = {};
if (originalHostname) {
agentOptions.servername = originalHostname;
agentOptions.rejectUnauthorized = true;
} else if (allowSelfSignedCerts) {
// For self-signed certificates, do not disable certificate validation.
// Instead, fall back to the default HTTPS agent behavior (full validation).
return null;
} else {
return null;
}
return new https.Agent(agentOptions);
}
async function getFetchAgentWithValidation<U extends string>(
url: U,
allowSelfSignedCerts?: boolean,
ignoreSsrfValidation?: boolean,
allowList?: string | string[],
): Promise<{
agent: http.Agent | https.Agent | null | HttpsProxyAgent<U> | HttpProxyAgent<U>;
pinnedUrl: string;
originalHostname?: string;
resolvedIp?: string;
}> {
let pinnedUrl: string = url;
let originalHostname: string | undefined;
let resolvedIp: string | undefined;
if (!ignoreSsrfValidation) {
const ssrfResult = await checkForSsrfWithIp(url, allowList);
if (!ssrfResult.allowed) {
logger.error({ msg: 'SSRF validation failed for URL', url: censorUrl(url) });
throw new Error('error-ssrf-validation-failed');
}
resolvedIp = ssrfResult.resolvedIp;
const extractedHostname = extractHostname(url);
if (extractedHostname) {
originalHostname = extractedHostname;
if (!checkDirectIp(extractedHostname)) {
pinnedUrl = buildPinnedUrl(url, resolvedIp);
}
}
} else {
logger.debug({ msg: 'Request not using SSRF validation', url: censorUrl(pinnedUrl) });
}
return { agent: getFetchAgent(pinnedUrl, allowSelfSignedCerts, originalHostname), pinnedUrl, originalHostname, resolvedIp };
}
function getTimeout(timeout?: number) {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), timeout ?? 20000);
return { controller, timeoutId };
}
function followRedirect(response: fetch.Response, redirectCount = 0) {
const location = response.headers.get('location');
if (!location) {
logger.error({ msg: 'Malformed redirect response', status: response.status });
throw new Error('redirect-response-location-header-missing');
}
if (redirectCount === MAX_REDIRECTS) {
logger.error({ msg: 'Redirect count reached the maximum amount of redirects allowed', MAX_REDIRECTS, status: response.status });
throw new Error('error-too-many-redirects');
}
logger.debug({ msg: 'Following redirect', redirectCount, location: censorUrl(location), status: response.status });
return location;
}
export async function serverFetch(input: string, options?: ExtendedFetchOptions, allowSelfSignedCerts?: boolean): Promise<Response> {
let currentUrl = input;
const { controller, timeoutId } = getTimeout(options?.timeout);
try {
for (let redirectCount = 0; redirectCount <= MAX_REDIRECTS; redirectCount += 1) {
const { agent, pinnedUrl, originalHostname, resolvedIp } = await getFetchAgentWithValidation(
currentUrl,
allowSelfSignedCerts,
options?.ignoreSsrfValidation,
options?.allowList,
);
const params = new URLSearchParams(options?.params);
const url = new URL(pinnedUrl);
if (redirectCount === 0 && params.toString()) {
params.forEach((value, key) => {
if (value) {
url.searchParams.append(key, value);
}
});
}
const parsedOptions = parseRequestOptions(options) || {};
const existingHeaders = parsedOptions.headers || {};
const headers: Record<string, string> = {};
if (existingHeaders && typeof existingHeaders === 'object') {
if (existingHeaders instanceof Headers) {
existingHeaders.forEach((value, key) => {
headers[key] = value;
});
} else if (Array.isArray(existingHeaders)) {
existingHeaders.forEach(([key, value]) => {
headers[key] = Array.isArray(value) ? value.join(', ') : value;
});
} else {
Object.assign(headers, existingHeaders);
}
}
if (originalHostname && resolvedIp) {
if (!checkDirectIp(originalHostname)) {
try {
const originalUrl = new URL(currentUrl);
headers.Host = originalUrl.port ? `${originalHostname}:${originalUrl.port}` : originalHostname;
} catch {
headers.Host = originalHostname;
}
}
}
const response = await fetch(url.toString(), {
// @ts-expect-error - This complained when types were moved to file :/
signal: controller.signal,
...parsedOptions,
redirect: 'manual',
headers,
...(agent ? { agent } : {}),
});
if (!redirectStatus.has(response.status)) {
return response;
}
currentUrl = new URL(followRedirect(response, redirectCount), currentUrl).toString();
// https://github.com/node-fetch/node-fetch/issues/1673 - body not consumed == open socket
response.body.resume();
}
} finally {
if (timeoutId) {
clearTimeout(timeoutId);
}
}
throw new Error('error-processing-request');
}
export { FetchError, Response };
export type { ExtendedFetchOptions };
export { parseSsrfAllowlist };

View File

@@ -0,0 +1,48 @@
import type { ExtendedFetchOptions, FetchOptions, OriginalFetchOptions } from './types';
const jsonParser = (options: ExtendedFetchOptions) => {
if (!options) {
return {};
}
try {
if (typeof options.body === 'object' && !Buffer.isBuffer(options.body)) {
options.body = JSON.stringify(options.body);
options.headers = {
...options.headers,
'Content-Type': 'application/json', // force content type to be json
};
}
} catch (e) {
// Body is not JSON, do nothing
}
return options as FetchOptions;
};
const urlencodedParser = (options: ExtendedFetchOptions) => {
return options as FetchOptions;
};
const getParser = (contentTypeHeader?: string): ((options: ExtendedFetchOptions) => FetchOptions) => {
switch (contentTypeHeader) {
case 'application/json':
return jsonParser;
case 'application/x-www-form-urlencoded':
return urlencodedParser;
default:
return jsonParser;
}
};
export function parseRequestOptions(options?: ExtendedFetchOptions): OriginalFetchOptions {
if (!options) {
return {};
}
const headers = (options.headers as { [k: string]: string }) ?? {};
const contentTypeHeader = headers['Content-Type'] || headers['content-type'];
return getParser(contentTypeHeader?.toLowerCase())(options);
}

View File

@@ -0,0 +1,26 @@
import type { BodyInit } from 'node-fetch';
export type OriginalFetchOptions = Parameters<typeof fetch>[1];
export type FetchOptions = NonNullable<Parameters<typeof fetch>[1]>;
export type FetchOptionsWithoutBody = Omit<FetchOptions, 'body'>;
type BaseExtendedOptions = FetchOptionsWithoutBody & {
compress?: boolean;
follow?: number;
size?: number;
timeout?: number;
params?: Record<string, any>;
body?: BodyInit | Record<string, any>;
};
type SsrfValidationIgnored = {
ignoreSsrfValidation: true;
allowList?: never;
};
type SsrfValidationEnforced = {
ignoreSsrfValidation: false;
allowList: string | string[];
};
export type ExtendedFetchOptions = BaseExtendedOptions & (SsrfValidationIgnored | SsrfValidationEnforced);