RocketChat / RocketChat/Rocket.Chat

serverFetch SSRF URL pinning breaks TLS on redirects from CDN-hosted services

Open
#40,118 0 comments 0 reactions 0 assignees View on GitHub

Nobody has claimed this yet.

type: bug
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
  1. Set Accounts_AvatarExternalProviderUrl to 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}/avatar which redirects to /p/<encoded>?width=128&height=128
  2. 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:

  1. Resolving the hostname to an IP via DNS
  2. Replacing the hostname in the URL with the resolved IP (buildPinnedUrl)
  3. Creating an https.Agent with servername set to the original hostname for SNI
  4. Setting the Host header to the original hostname

This works correctly for the initial request. However, on redirect:

  1. The server returns a relative Location header: Location: /p/...
  2. node-fetch 2.7.0 resolves relative Location headers against the request URL and overwrites the header — even in redirect: "manual" mode (line 1552 and 1571-1575 of node-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);
    }
    
  3. Since the request URL was pinned to an IP (e.g., https://[2606:4700:...]/u/user/avatar), the resolved Location becomes https://[2606:4700:...]/p/...
  4. serverFetch reads this from response.headers.get('location') via followRedirect()
  5. On the next loop iteration, extractHostname returns the IP address
  6. checkDirectIp() returns trueoriginalHostname is never set → no servername in agent → no SNI
  7. 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:

  1. External URL is behind a shared-TLS CDN that requires SNI (Cloudflare, AWS CloudFront, etc.)
  2. That service returns relative redirect Location headers (e.g., Location: /p/...)
  3. SSRF validation is enabled (default)
Affected code
  • @rocket.chat/server-fetchserverFetch() redirect loop in dist/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

Open the contributing guide

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. 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

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.