Files
homefeed/backend/src/api/mediaProxy.ts
T
Claude c2b34623d1 Add configurable tweet media hosting mode and fxtwitter base URL
Adds nitterMediaMode (self-host/proxy/direct, default proxy) and
fxtwitterBaseUrl to global settings with a new Retention tab panel.
Tweet images and avatars now resolve through the chosen mode instead
of always being downloaded — proxy mode streams media through a new
SSRF-hardened /media/proxy route (hostname allowlist + DNS-rebinding
defense) so the origin server's IP is never exposed to Twitter's CDN,
direct hotlinks the original URL, and self-host keeps the prior
always-download behavior. fxtwitterBaseUrl lets the enrichment call
target a self-hosted FixTweet mirror instead of the public instance.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014c1L8ghNBFjfiH64UMViP8
2026-07-22 23:43:52 +00:00

118 lines
4.4 KiB
TypeScript

// Backs the "proxy" Nitter media mode (see Retention tab / GlobalSettings.nitterMediaMode):
// the visitor's browser requests media from this route instead of directly from
// Twitter/the Nitter instance's CDN, so only this server's IP is ever exposed to the
// remote host — the media itself is streamed straight through, never written to disk.
//
// Since this route fetches whatever URL it's given, it's a textbook SSRF vector unless
// tightly restricted: only twimg.com (Twitter's media CDN), the configured
// fxtwitterBaseUrl's host, and the hostnames of the admin's own configured Nitter
// sources are allowed — and even an allowed hostname is rejected if it resolves to a
// private/loopback/link-local address (defends against DNS rebinding, not just a
// hostname string check).
import type { FastifyInstance } from 'fastify';
import dns from 'node:dns/promises';
import { Readable } from 'node:stream';
import * as sourcesDb from '../storage/db/sources.js';
import { getSettings } from '../storage/db/settings.js';
import { logger } from '../storage/db/logs.js';
const USER_AGENT = 'Mozilla/5.0 (compatible; HomefeedBot/1.0; self-hosted RSS reader)';
const FETCH_TIMEOUT_MS = 15_000;
const TWITTER_MEDIA_HOST_RE = /(^|\.)twimg\.com$/i;
function hostnameOf(rawUrl: string | null): string | null {
if (!rawUrl) return null;
try {
return new URL(rawUrl).hostname.toLowerCase();
} catch {
return null;
}
}
function isAllowedHost(hostname: string): boolean {
const lower = hostname.toLowerCase();
if (TWITTER_MEDIA_HOST_RE.test(lower)) return true;
const settings = getSettings();
if (hostnameOf(settings.fxtwitterBaseUrl) === lower) return true;
const nitterHosts = sourcesDb
.listSources()
.filter((s) => s.type === 'nitter')
.map((s) => hostnameOf(s.url))
.filter((h): h is string => !!h);
return nitterHosts.includes(lower);
}
function isPrivateOrReservedIp(ip: string, family: number): boolean {
if (family === 4) {
const [a, b] = ip.split('.').map(Number);
if (a === 10 || a === 127 || a === 0) return true;
if (a === 169 && b === 254) return true;
if (a === 172 && b >= 16 && b <= 31) return true;
if (a === 192 && b === 168) return true;
if (a === 100 && b >= 64 && b <= 127) return true; // CGNAT range
return false;
}
const lower = ip.toLowerCase();
if (lower === '::1') return true;
if (lower.startsWith('fc') || lower.startsWith('fd')) return true; // unique local fc00::/7
if (lower.startsWith('fe80')) return true; // link-local
if (lower.startsWith('::ffff:')) {
const v4 = lower.split(':').pop();
if (v4?.includes('.')) return isPrivateOrReservedIp(v4, 4);
}
return false;
}
export async function registerMediaProxy(app: FastifyInstance) {
app.get('/media/proxy', async (req, reply) => {
const { url } = req.query as { url?: string };
if (!url) return reply.code(400).send({ error: 'url required' });
let parsed: URL;
try {
parsed = new URL(url);
} catch {
return reply.code(400).send({ error: 'invalid url' });
}
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
return reply.code(400).send({ error: 'unsupported scheme' });
}
if (!isAllowedHost(parsed.hostname)) {
logger.warn('media-proxy', `Blocked proxy request to disallowed host: ${parsed.hostname}`);
return reply.code(403).send({ error: 'host not allowed' });
}
let addresses: { address: string; family: number }[];
try {
addresses = await dns.lookup(parsed.hostname, { all: true });
} catch {
return reply.code(502).send({ error: 'DNS resolution failed' });
}
if (addresses.some((a) => isPrivateOrReservedIp(a.address, a.family))) {
logger.warn('media-proxy', `Blocked proxy request resolving to a private/reserved address: ${parsed.hostname}`);
return reply.code(403).send({ error: 'host not allowed' });
}
try {
const res = await fetch(parsed.toString(), {
headers: { 'User-Agent': USER_AGENT },
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS)
});
if (!res.ok || !res.body) {
return reply.code(502).send({ error: `upstream responded ${res.status}` });
}
reply.header('content-type', res.headers.get('content-type') ?? 'application/octet-stream');
reply.header('cache-control', res.headers.get('cache-control') ?? 'public, max-age=3600');
return reply.send(Readable.fromWeb(res.body as any));
} catch (err) {
logger.warn('media-proxy', `Proxy fetch failed for ${parsed.toString()}: ${(err as Error).message}`);
return reply.code(502).send({ error: 'fetch failed' });
}
});
}