Files
upstream-mirror 30810eecd7
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
Auto-close duplicate issues / auto-close-duplicates (push) Failing after 1s
Code scanning - action / CodeQL-Build (push) Failing after 2s
snapshot of https://github.com/RocketChat/Rocket.Chat.git (develop, 2026-09-16; blobs > 131072B omitted)
2026-09-16 01:21:02 +05:00

380 lines
11 KiB
TypeScript

import type {
OEmbedUrlContentResult,
MessageUrl,
OEmbedUrlWithMetadata,
OEmbedMeta,
IMessage,
OEmbedUrlContent,
} from '@rocket.chat/core-typings';
import { isOEmbedUrlWithMetadata } from '@rocket.chat/core-typings';
import { Logger } from '@rocket.chat/logger';
import { OEmbedCache, Messages } from '@rocket.chat/models';
import { serverFetch as fetch } from '@rocket.chat/server-fetch';
import { isAbsoluteURL } from '@rocket.chat/tools';
import he from 'he';
import iconv from 'iconv-lite';
import ipRangeCheck from 'ip-range-check';
import jschardet from 'jschardet';
import { camelCase } from 'lodash';
import { Info } from '../../../../app/utils/rocketchat.info';
import { settings } from '../../../settings';
import { afterParseUrlContent, beforeGetUrlContent } from '../lib/oembed/providers';
const MAX_EXTERNAL_URL_PREVIEWS = 5;
const log = new Logger('OEmbed');
// Detect encoding
// Priority:
// Detected == HTTP Header > Detected == HTML meta > HTTP Header > HTML meta > Detected > Default (utf-8)
// See also: https://www.w3.org/International/questions/qa-html-encoding-declarations.en#quickanswer
const getCharset = function (contentType: string, body: Buffer): string {
let detectedCharset;
let httpHeaderCharset;
let htmlMetaCharset;
let result;
contentType = contentType || '';
const binary = body.toString('binary');
const detected = jschardet.detect(binary);
if (detected.confidence > 0.8) {
detectedCharset = detected.encoding.toLowerCase();
}
const m1 = contentType.match(/charset=([\w\-]+)/i);
if (m1) {
httpHeaderCharset = m1[1].toLowerCase();
}
const m2 = binary.match(/<meta\b[^>]*charset=["']?([\w\-]+)/i);
if (m2) {
htmlMetaCharset = m2[1].toLowerCase();
}
if (detectedCharset) {
if (detectedCharset === httpHeaderCharset) {
result = httpHeaderCharset;
} else if (detectedCharset === htmlMetaCharset) {
result = htmlMetaCharset;
}
}
if (!result) {
result = httpHeaderCharset || htmlMetaCharset || detectedCharset;
}
return result || 'utf-8';
};
const toUtf8 = function (contentType: string, body: Buffer): string {
return iconv.decode(body, getCharset(contentType, body));
};
const getUrlContent = async (urlObj: URL, redirectCount = 5): Promise<OEmbedUrlContent> => {
const portsProtocol = new Map<string, string>(
Object.entries({
80: 'http:',
8080: 'http:',
443: 'https:',
}),
);
const ignoredHosts =
settings
.get<string>('API_EmbedIgnoredHosts')
.replace(/\s/g, '')
.split(',')
.filter(Boolean)
.map((host) => host.toLowerCase()) || [];
const isIgnoredHost = (hostname: string | undefined): boolean => {
hostname = hostname?.toLowerCase();
if (!hostname || !ignoredHosts.length) {
return false;
}
const exactHosts = ignoredHosts.filter((h) => !h.includes('*'));
if (exactHosts.includes(hostname) || ipRangeCheck(hostname, exactHosts)) {
return true;
}
return ignoredHosts
.filter((h) => h.includes('*'))
.some((pattern) => {
const validationRegex = /^(?:\*\.)?(?:\*|[a-z0-9-]+)(?:\.(?:\*|[a-z0-9-]+))*$/i;
if (!validationRegex.test(pattern) || pattern === '*') {
return false;
}
const escaped = pattern.replace(/[-/\\^$+?.()|[\]{}]/g, '\\$&');
const source = `^${escaped.replace(/\*/g, '[^.]*')}$`;
try {
const regex = new RegExp(source, 'i');
return regex.test(hostname);
} catch {
// fail safe on invalid patterns
return false;
}
});
};
if (isIgnoredHost(urlObj.hostname)) {
throw new Error('host is ignored');
}
const safePorts = settings.get<string>('API_EmbedSafePorts').replace(/\s/g, '').split(',') || [];
// checks if the URL port is in the safe ports list
if (safePorts.length > 0 && urlObj.port && !safePorts.includes(urlObj.port)) {
throw new Error('invalid/unsafe port');
}
// if port is not detected, use protocol to verify instead
if (safePorts.length > 0 && !urlObj.port && !safePorts.some((port) => portsProtocol.get(port) === urlObj.protocol)) {
throw new Error('invalid/unsafe port');
}
const data = beforeGetUrlContent({ urlObj });
const url = data.urlObj.toString();
const sizeLimit = 250000;
log.debug({ msg: 'Fetching URL for OEmbed', url, redirectCount });
const start = Date.now();
const response = await fetch(
url,
{
compress: true,
follow: redirectCount,
headers: {
'User-Agent': `${settings.get('API_Embed_UserAgent')} Rocket.Chat/${Info.version}`,
'Accept-Language': settings.get('Language') || 'en',
...data.headerOverrides,
},
timeout: settings.get<number>('API_EmbedTimeout') * 1000,
size: sizeLimit, // max size of the response body, this was not working as expected so I'm also manually verifying that on the iterator
ignoreSsrfValidation: false,
allowList: settings.get<string>('SSRF_Allowlist'),
},
settings.get('Allow_Invalid_SelfSigned_Certs'),
);
const end = Date.now();
let totalSize = 0;
const chunks = [];
for await (const chunk of response.body) {
totalSize += chunk.length;
chunks.push(chunk as Buffer);
if (totalSize > sizeLimit) {
log.warn({ msg: 'OEmbed request size exceeded', url });
break;
}
}
log.debug({ msg: 'Obtained response from server', length: totalSize, timeSpent: `${end - start}ms` });
const buffer = Buffer.concat(chunks);
return {
headers: Object.fromEntries(response.headers),
body: toUtf8(response.headers.get('content-type') || 'text/plain', buffer),
statusCode: response.status,
urlObj,
};
};
const parseUrl = async function (url: string): Promise<{ urlPreview: MessageUrl; foundMeta: boolean }> {
const parsedUrlObject: MessageUrl = { url, meta: {} };
let foundMeta = false;
if (!isAbsoluteURL(url)) {
return { urlPreview: parsedUrlObject, foundMeta };
}
const data = await getUrlMetaWithCache(url);
if (!data) {
return { urlPreview: parsedUrlObject, foundMeta };
}
if (isOEmbedUrlWithMetadata(data) && data.meta) {
parsedUrlObject.meta = getRelevantMetaTags(data.meta) || {};
if (parsedUrlObject.meta?.oembedHtml) {
parsedUrlObject.meta.oembedHtml = insertMaxWidthInOembedHtml(parsedUrlObject.meta.oembedHtml) || '';
}
}
foundMeta = true;
return {
urlPreview: {
...parsedUrlObject,
...((parsedUrlObject.headers || data.headers) && {
headers: {
...parsedUrlObject.headers,
...(data.headers?.contentLength && { contentLength: data.headers.contentLength }),
...(data.headers?.contentType && { contentType: data.headers.contentType }),
},
}),
},
foundMeta,
};
};
const getUrlMeta = async function (
url: string,
withFragment?: boolean,
): Promise<OEmbedUrlWithMetadata | OEmbedUrlContentResult | undefined> {
log.debug({ msg: 'Obtaining metadata for URL', url });
const urlObj = new URL(url);
if (withFragment) {
urlObj.searchParams.set('_escaped_fragment_', '');
}
log.debug({ msg: 'Fetching URL content', url: urlObj.toString() });
let content: OEmbedUrlContent | undefined;
try {
content = await getUrlContent(urlObj, 5);
} catch (err) {
log.error({ msg: 'Error fetching URL content', url: urlObj.toString(), err });
}
if (!content) {
return;
}
log.debug({ msg: 'Parsing metadata for URL', url });
const metas: OEmbedMeta = {} as any;
if (content?.body) {
const escapeMeta = (name: string, value: string): string => {
metas[name] = metas[name] || he.unescape(value);
return metas[name];
};
content.body.replace(/<title[^>]*>([^<]*)<\/title>/gim, (_meta, title) => {
return escapeMeta('pageTitle', title);
});
content.body.replace(/<meta[^>]*(?:name|property)=[']([^']*)['][^>]*\scontent=[']([^']*)['][^>]*>/gim, (_meta, name, value) => {
return escapeMeta(camelCase(name), value);
});
content.body.replace(/<meta[^>]*(?:name|property)=["]([^"]*)["][^>]*\scontent=["]([^"]*)["][^>]*>/gim, (_meta, name, value) => {
return escapeMeta(camelCase(name), value);
});
content.body.replace(/<meta[^>]*\scontent=[']([^']*)['][^>]*(?:name|property)=[']([^']*)['][^>]*>/gim, (_meta, value, name) => {
return escapeMeta(camelCase(name), value);
});
content.body.replace(/<meta[^>]*\scontent=["]([^"]*)["][^>]*(?:name|property)=["]([^"]*)["][^>]*>/gim, (_meta, value, name) => {
return escapeMeta(camelCase(name), value);
});
if (metas.fragment === '!' && withFragment == null) {
return getUrlMeta(url, true);
}
delete metas.oembedHtml;
}
const headers: { [k: string]: string } = {};
if (content?.headers) {
const headerObj = content.headers;
Object.keys(headerObj).forEach((header) => {
headers[camelCase(header)] = headerObj[header];
});
}
if (content && content.statusCode !== 200) {
return;
}
return afterParseUrlContent({
url,
meta: metas,
headers,
content,
});
};
const getUrlMetaWithCache = async function (
url: string,
withFragment?: boolean,
): Promise<OEmbedUrlWithMetadata | undefined | OEmbedUrlContentResult> {
log.debug({ msg: 'Getting OEmbed metadata for URL', url });
const cache = await OEmbedCache.findOneById(url);
if (cache) {
log.debug({ msg: 'Found OEmbed metadata in cache for URL', url });
return cache.data;
}
const data = await getUrlMeta(url, withFragment);
if (!data) {
return;
}
try {
log.debug({ msg: 'Saving OEmbed metadata in cache for URL', url });
await OEmbedCache.createWithIdAndData(url, data);
} catch (_error) {
log.error({ msg: 'OEmbed duplicated record', url });
}
return data;
};
const getRelevantMetaTags = function (metaObj: OEmbedMeta): Record<string, string> | void {
const tags: Record<string, string> = {};
Object.keys(metaObj).forEach((key) => {
const value = metaObj[key];
if (/^(og|fb|twitter|oembed|msapplication).+|description|title|pageTitle$/.test(key.toLowerCase()) && value && value.trim() !== '') {
tags[key] = value;
}
});
if (Object.keys(tags).length > 0) {
return tags;
}
};
const insertMaxWidthInOembedHtml = (oembedHtml?: string): string | undefined =>
oembedHtml?.replace('iframe', 'iframe style="max-width: 100%;width:400px;height:225px"');
const rocketUrlParser = async function (message: IMessage): Promise<IMessage> {
log.debug({ msg: 'Parsing message URLs' });
if (!settings.get('API_Embed')) {
return message;
}
if (!Array.isArray(message.urls)) {
return message;
}
log.debug({ msg: 'URLs found in message', count: message.urls.length });
if (
(message.attachments && message.attachments.length > 0) ||
message.urls.filter((item) => !item.url.includes(settings.get('Site_Url'))).length > MAX_EXTERNAL_URL_PREVIEWS
) {
log.debug({ msg: 'All URLs ignored for OEmbed' });
return message;
}
let changed = false;
for await (const item of message.urls) {
if (item.ignoreParse === true) {
log.debug({ msg: 'URL ignored for OEmbed', url: item.url });
continue;
}
const { urlPreview, foundMeta } = await parseUrl(item.url);
Object.assign(item, foundMeta ? urlPreview : {});
changed = changed || foundMeta;
}
if (changed === true) {
await Messages.setUrlsById(message._id, message.urls);
}
return message;
};
export const OEmbed: {
rocketUrlParser: (message: IMessage) => Promise<IMessage>;
parseUrl: (url: string) => Promise<{ urlPreview: MessageUrl; foundMeta: boolean }>;
} = {
rocketUrlParser,
parseUrl,
};