RocketChat / RocketChat/Rocket.Chat
serverFetch SSRF URL pinning breaks TLS on redirects from CDN-hosted services
Nobody has claimed this yet.
- Dominant language
- TypeScript
- Stars
- 46.1k
- Forks
- 13.9k
- Avg merge
- 3d 3h
- Merged PRs (30d)
- 130
Description
Description
When Accounts_AvatarExternalProviderUrl points to a service behind a shared-TLS CDN (e.g., Cloudflare), all avatar fetches fail with EPROTO: sslv3 alert handshake failure. The root cause is an interaction between @rocket.chat/server-fetch's SSRF URL pinning and node-fetch 2.7.0's redirect handling.
Steps to reproduce
- Set
Accounts_AvatarExternalProviderUrlto an external URL behind Cloudflare (or any CDN using SNI-based TLS) that returns relative 302 redirects — e.g.,https://images.hive.blog/u/{username}/avatarwhich redirects to/p/<encoded>?width=128&height=128 - Load any page that displays user avatars
Expected behavior
Avatars load normally. The SSRF-pinned fetch follows the redirect and maintains TLS connectivity.
Actual behavior
All avatar fetches fail with:
FetchError: request to https://[2606:4700:3035::ac43:8d81]/p/... failed,
reason: write EPROTO ...:error:0A000410:SSL routines:ssl3_read_bytes:
sslv3 alert handshake failure:...:SSL alert number 40
Root cause
serverFetch in @rocket.chat/server-fetch performs SSRF protection by:
- Resolving the hostname to an IP via DNS
- Replacing the hostname in the URL with the resolved IP (
buildPinnedUrl) - Creating an
https.Agentwithservernameset to the original hostname for SNI - Setting the
Hostheader to the original hostname
This works correctly for the initial request. However, on redirect:
- The server returns a relative Location header:
Location: /p/... node-fetch2.7.0 resolves relative Location headers against the request URL and overwrites the header — even inredirect: "manual"mode (line 1552 and 1571-1575 ofnode-fetch/lib/index.js):locationURL = new URL$1(location, request.url).toString(); // ... // node-fetch-specific step: make manual redirect a bit easier to use // by setting the Location header value to the resolved URL. if (locationURL !== null) { headers.set('Location', locationURL); }- Since the request URL was pinned to an IP (e.g.,
https://[2606:4700:...]/u/user/avatar), the resolved Location becomeshttps://[2606:4700:...]/p/... serverFetchreads this fromresponse.headers.get('location')viafollowRedirect()- On the next loop iteration,
extractHostnamereturns the IP address checkDirectIp()returnstrue→originalHostnameis never set → noservernamein agent → no SNI- Cloudflare (or any shared-TLS CDN) rejects the TLS handshake because it can't identify which certificate to serve without SNI
Reproduction script
Standalone reproduction (no Rocket.Chat install needed, just node-fetch 2.7.0):
import https from "https";
import dns from "dns";
import fetch from "node-fetch";
function nslookup(hostname) {
return new Promise((resolve, reject) => {
dns.lookup(hostname, (err, address) => {
if (err) reject(err); else resolve(address);
});
});
}
function checkDirectIp(ip) {
return /^(\d+\.\d+\.\d+\.\d+|\[?[0-9a-fA-F:]+]?)$/.test(ip);
}
function extractHostname(u) {
try {
const { hostname } = new URL(u);
return hostname.startsWith("[") ? hostname.slice(1, -1) : hostname;
} catch { return null; }
}
function buildPinnedUrl(orig, ip) {
const u = new URL(orig);
u.hostname = ip.includes(":") ? `[${ip}]` : ip;
return u.toString();
}
// Simulates serverFetch redirect loop
async function testFetch(inputUrl) {
let currentUrl = inputUrl;
for (let i = 0; i <= 3; i++) {
console.log(`\n--- Iteration ${i} ---`);
let pinnedUrl = currentUrl, originalHostname;
const host = extractHostname(currentUrl);
const isIp = checkDirectIp(host);
if (host && !isIp) {
const ip = await nslookup(host);
originalHostname = host;
pinnedUrl = buildPinnedUrl(currentUrl, ip);
console.log("pinned:", pinnedUrl, "sni:", originalHostname);
} else {
console.log("DIRECT IP - no SNI set for:", currentUrl);
}
const agent = originalHostname
? new https.Agent({ servername: originalHostname, rejectUnauthorized: true })
: null;
const headers = {};
if (originalHostname) headers.Host = originalHostname;
try {
const res = await fetch(pinnedUrl, {
redirect: "manual", headers, ...(agent ? { agent } : {})
});
console.log("status:", res.status);
if (res.status >= 300 && res.status < 400) {
const loc = res.headers.get("location");
console.log("Location:", loc);
currentUrl = new URL(loc, currentUrl).toString();
res.body.resume();
continue;
}
console.log("DONE:", res.status, res.headers.get("content-type"));
return;
} catch (e) {
console.log("ERROR:", e.message.substring(0, 200));
return;
}
}
}
testFetch("https://images.hive.blog/u/gandalf/avatar");
Output:
--- Iteration 0 ---
pinned: https://[2606:4700:...]/u/gandalf/avatar sni: images.hive.blog
status: 302
Location: https://[2606:4700:...]/p/3MxaK2J...
--- Iteration 1 ---
DIRECT IP - no SNI set for: https://[2606:4700:...]/p/3MxaK2J...
ERROR: request to https://[2606:4700:...]/p/... failed, reason: write EPROTO ... sslv3 alert handshake failure
Conditions for triggering
All three must be true:
- External URL is behind a shared-TLS CDN that requires SNI (Cloudflare, AWS CloudFront, etc.)
- That service returns relative redirect Location headers (e.g.,
Location: /p/...) - SSRF validation is enabled (default)
Affected code
@rocket.chat/server-fetch—serverFetch()redirect loop indist/index.js- Triggered via
handleExternalProvider()in the avatar serving code
Server Setup Information
- Rocket.Chat: 8.3.1
- Node.js: 22.16.0
- MongoDB: 8.0.20
- node-fetch: 2.7.0 (bundled)
Contributor guide
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
Research direction
Start with the serverFetch() redirect loop in @rocket.chat/server-fetch/dist/index.js and run the standalone node-fetch 2.7.0 reproduction script. Trace how the pinned URL and Location header are passed into followRedirect(); the fix is done when relative CDN redirects retain TLS/SNI behavior and the avatar fetch completes successfully.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- javascript, node.js
- Domain
- backend, networking, security
- Issue type
- Bug
- Difficulty
- 4/5
- Estimated time
- 3-5 days
- Activity status
- Quiet
- Clarity
- Mostly clear
- Newbie friendliness
- 55/100