Files
AniKotoAPI/src/helper/streamProxy.helper.js
T
Shinei Nouzen 30fdcd93c2 fix: close SSRF in stream proxies and fix dotenv load order
Security:
- Fixed unauthenticated SSRF in /api/stream/proxy and /api/stream/ts-proxy
  - proxy allowlist had escape hatch via pathname.endsWith('.m3u8') bypass
  - ts-proxy had no destination validation at all
  - Both could fetch internal services (127.0.0.1, 169.254.169.254)
- Added streamProxy.helper.js with domain allowlist, private IP blocking,
  and http(s)-only enforcement applied to both endpoints
- Removed dead megaplay-1.buzz; added vid-tube.site, norami.top,
  kryntal.top, akirax.buzz to allowlist
- Added SECURITY.md with vulnerability reporting policy

Bug fix:
- Fixed dotenv.config() loading after static imports -- helpers reading
  process.env at top level never received .env values. Changed to
  import 'dotenv/config' as first import

Config:
- Added STREAM_PROXY_DOMAINS env var for custom allowlist
- Added STREAM_PROXY_REFERER env var for custom CDN Referer
2026-08-27 14:40:09 +05:30

143 lines
5.5 KiB
JavaScript

/*
* ======= • ======= • ======= • ======= • =======• =======
* AniKotoAPI — streamProxy.helper.js
* Repository: https://github.com/Shineii86
*
* @description
* Request guard for the M3U8/TS streaming proxies. Validates that a
* caller-supplied URL points at a known streaming CDN before the server
* fetches it, so the proxy endpoints cannot be used to reach internal
* services (SSRF).
*
* @exports
* assertProxyableUrl
* streamRefererHeaders
*
* @author Shinei Nouzen
* @license MIT
* ======= • ======= • ======= • ======= • =======• =======
*/
// ══════════════════════════════════════════════════════════════
// PROXY ALLOWLIST
// ══════════════════════════════════════════════════════════════
// Registrable domains the proxies may fetch from. Subdomains are covered,
// so the apex is listed once and rotating hosts (cdn1., s2., …) still match.
// NOTE: megaplay-1.buzz was removed — it no longer resolves.
const DEFAULT_PROXY_DOMAINS = [
// Embed players
"megaplay.buzz",
"vidtube.site",
"vid-tube.site",
"vidplay.site",
// Stream / subtitle CDNs
"anipixcdn.co",
"norami.top",
"kryntal.top",
"akirax.buzz",
];
// Parse the allowlist from env or use defaults
function parseProxyDomains() {
const envDomains = process.env.STREAM_PROXY_DOMAINS;
if (!envDomains) return DEFAULT_PROXY_DOMAINS;
return envDomains
.split(",")
.map(d => d.trim().toLowerCase())
.filter(Boolean);
}
const PROXY_DOMAINS = parseProxyDomains();
// Referer the stream CDNs expect. They answer 403 to the site origin
// (anikototv.to) and to no Referer at all — only the player origin passes.
const STREAM_REFERER = process.env.STREAM_PROXY_REFERER || "https://megaplay.buzz/";
// ══════════════════════════════════════════════════════════════
// PRIVATE ADDRESS DETECTION
// ══════════════════════════════════════════════════════════════
// NOTE: Blocks IP literals pointing at the host's own network. This is a
// second layer, not the primary control — the allowlist is. A hostname that
// passes the allowlist but resolves to a private address (DNS rebinding) is
// not caught here, since axios re-resolves the name when it connects.
const isPrivateAddress = (hostname) => {
const host = hostname.replace(/^\[|\]$/g, "").toLowerCase();
// IPv6 loopback, link-local (fe80::/10) and unique-local (fc00::/7)
if (host === "::1" || host === "::") return true;
if (/^f[cd][0-9a-f]{2}:/.test(host)) return true;
if (/^fe[89ab][0-9a-f]:/.test(host)) return true;
// IPv4-mapped IPv6 — unwrap and fall through to the IPv4 checks
const mapped = host.match(/^::ffff:(\d+\.\d+\.\d+\.\d+)$/)?.[1];
const target = mapped || host;
const octets = target.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/);
if (!octets) return false;
const [a, b] = octets.slice(1).map(Number);
if (a === 0 || a === 10 || a === 127) return true; // this-network, private, loopback
if (a === 169 && b === 254) return true; // link-local / cloud metadata
if (a === 172 && b >= 16 && b <= 31) return true; // private
if (a === 192 && b === 168) return true; // private
if (a === 100 && b >= 64 && b <= 127) return true; // CGNAT
return false;
};
// ══════════════════════════════════════════════════════════════
// URL GUARD
// ══════════════════════════════════════════════════════════════
// ---- FEATURE: Proxy URL Validation ----
/**
* Validates a caller-supplied proxy target.
*
* @param {string} rawUrl - The URL from the request query
* @returns {{ ok: true, url: URL } | { ok: false, status: number, message: string }}
*
* @example
* const check = assertProxyableUrl(req.query.url);
* if (!check.ok) return res.status(check.status).json({ success: false, message: check.message });
*/
const assertProxyableUrl = (rawUrl) => {
let url;
try {
url = new URL(rawUrl);
} catch {
return { ok: false, status: 400, message: "Malformed URL" };
}
if (url.protocol !== "https:" && url.protocol !== "http:") {
return { ok: false, status: 400, message: "Only http and https URLs can be proxied" };
}
const hostname = url.hostname.toLowerCase();
if (isPrivateAddress(hostname)) {
return { ok: false, status: 403, message: "Domain not allowed for proxy" };
}
const isAllowed = PROXY_DOMAINS.some(d => hostname === d || hostname.endsWith(`.${d}`));
if (!isAllowed) {
return { ok: false, status: 403, message: "Domain not allowed for proxy" };
}
return { ok: true, url };
};
// ---- FEATURE: CDN Request Headers ----
/**
* Headers the stream CDNs require on a proxied fetch.
*
* @returns {object} Referer/Origin pair for the configured player origin
*/
const streamRefererHeaders = () => ({
"Referer": STREAM_REFERER,
"Origin": new URL(STREAM_REFERER).origin,
});
export { assertProxyableUrl, streamRefererHeaders };