mirror of
https://github.com/Shineii86/AniKotoAPI.git
synced 2026-10-22 19:05:11 +00:00
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
This commit is contained in:
@@ -0,0 +1,142 @@
|
||||
/*
|
||||
* ======= • ======= • ======= • ======= • =======• =======
|
||||
* 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 };
|
||||
Reference in New Issue
Block a user