diff --git a/.env.example b/.env.example index 55bd845..2271fe1 100644 --- a/.env.example +++ b/.env.example @@ -25,3 +25,12 @@ MIRROR_CACHE_TTL=3600000 # Environment (production/development) NODE_ENV=production + +# Domains the M3U8/TS stream proxies may fetch from (comma-separated). +# Subdomains are covered automatically, so list the registrable domain only. +# Leave unset to use the built-in defaults. +# STREAM_PROXY_DOMAINS=megaplay.buzz,vidtube.site,vid-tube.site,vidplay.site,anipixcdn.co,norami.top,kryntal.top,akirax.buzz + +# Referer sent to stream CDNs. They reject the site origin and an empty +# Referer; only the player origin passes. Change if the player domain moves. +# STREAM_PROXY_REFERER=https://megaplay.buzz/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 5d99aaf..d06d7e9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,21 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [2.4.1] - 2026-08-27 + +### Security +- **CRITICAL**: Fixed unauthenticated SSRF in `/api/stream/proxy` and `/api/stream/ts-proxy` — the proxy endpoint's domain allowlist had an escape hatch (`pathname.endsWith(".m3u8")`) that let any host through, and `ts-proxy` had no destination check at all. Both could be used to fetch internal services (127.0.0.1, 169.254.169.254) and read responses. +- Added shared `streamProxy.helper.js` with a real allowlist by registrable domain, private/loopback/link-local address blocking as a second layer, and https-only scheme enforcement. Both endpoints now use this guard. +- Removed dead domain `megaplay-1.buzz` from allowlist; added `vid-tube.site` and CDNs currently serving streams (`norami.top`, `kryntal.top`, `akirax.buzz`). +- Added `SECURITY.md` with vulnerability reporting policy (GitHub private vulnerability reporting preferred, Telegram fallback). + +### Fixed +- **Environment loading**: Fixed `dotenv.config()` being called after static imports — helpers reading `process.env` at module top level (mirror, cache, proxy, streamProxy) were never receiving `.env` values. Changed to `import "dotenv/config"` as the first import so `.env` is populated before any helper module loads. Affected settings: `MIRROR_DOMAINS`, `MIRROR_CACHE_TTL`, `CACHE_MAX_SIZE`, `CACHE_DEFAULT_TTL`, `SCRAPER_API_KEY`, `FLARESOLVERR_URL`, `STREAM_PROXY_DOMAINS`, `STREAM_PROXY_REFERER`. + +### Added +- **Stream Proxy Allowlist Config**: New `STREAM_PROXY_DOMAINS` env var to override the built-in domain allowlist (comma-separated). +- **Stream Proxy Referer Config**: New `STREAM_PROXY_REFERER` env var to override the Referer header sent to stream CDNs (default: `https://megaplay.buzz/`). + ## [2.4.0] - 2026-08-14 ### Added diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..32ae8e7 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,54 @@ +# Security Policy + +## Supported versions + +| Version | Supported | +| ------- | --------- | +| 2.4.x | ✅ | +| < 2.4 | ❌ | + +Fixes land on the latest release. If you are running an older version, upgrade before reporting — the issue may already be resolved. + +## Reporting a vulnerability + +**Please do not open a public issue or pull request for a security problem.** Public reports expose every running instance until a fix ships. + +Report privately instead: + +1. **Preferred — GitHub private vulnerability reporting.** Use the **Report a vulnerability** button on the [Security tab](https://github.com/Shineii86/AniKotoAPI/security). This keeps the discussion private until an advisory is published. +2. **Fallback — Telegram.** If private reporting is unavailable, message [@Shineii86](https://telegram.me/Shineii86) asking for a private channel. Send only a one-line description there, not the full details. + +## What to include + +The more of this you can provide, the faster a fix can be confirmed: + +- The affected endpoint or module, and the version or commit you tested. +- What an attacker can achieve — reading data, changing state, exhausting resources. +- Minimal steps to reproduce, ideally a single request. +- Whether authentication or any particular deployment setup is required. +- A suggested fix, if you have one. + +## What happens next + +- Reports are acknowledged as soon as the maintainer sees them. +- Once confirmed, a fix is prepared privately and released, and a GitHub advisory is published crediting the reporter unless anonymity is requested. +- Reporters are asked to hold public details until the fix is released. + +This project is maintained by one person as unpaid work. Please read the timing above as intent rather than a guaranteed schedule. + +## Scope + +In scope — anything that lets someone abuse a deployed instance: + +- Server-side request forgery, or any way to make the server fetch or reach destinations it should not. +- Injection, path traversal, or unsafe handling of caller-supplied input. +- Leakage of environment variables, credentials, or internal configuration. +- Denial of service reachable through a single unauthenticated request. +- Vulnerable dependencies with a practical path to exploitation in this codebase. + +Out of scope: + +- Availability, downtime, or changes to the upstream anime sites this API scrapes. +- Rate limiting or abuse controls on those upstream sites. +- Findings that require an already-compromised host or shell access to the server. +- Reports produced by automated scanners with no demonstrated impact. diff --git a/server.js b/server.js index 38b07f9..da2efc8 100644 --- a/server.js +++ b/server.js @@ -16,7 +16,13 @@ * ======= • ======= • ======= • ======= • =======• ======= */ -import dotenv from "dotenv"; +// WARNING: This import must stay first. Static imports are evaluated before the +// module body runs, so a dotenv.config() call down there fires only after every +// helper has already been evaluated — and helpers that read process.env at their +// top level (mirror, cache, proxy, streamProxy) would silently keep their +// defaults. Importing dotenv/config runs config() as an import side effect, so +// .env is populated before any of those modules load. +import "dotenv/config"; import express from "express"; import compression from "compression"; import path from "path"; @@ -27,8 +33,6 @@ import { dirname } from "path"; import { createApiRoutes } from "./src/routes/apiRoutes.js"; import { addCreatorInfo } from "./src/middleware/creatorInfo.js"; -dotenv.config(); - // ══════════════════════════════════════════════════════════════ // SERVER CONFIGURATION // ══════════════════════════════════════════════════════════════ diff --git a/src/helper/streamProxy.helper.js b/src/helper/streamProxy.helper.js new file mode 100644 index 0000000..e0ec6fb --- /dev/null +++ b/src/helper/streamProxy.helper.js @@ -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 }; diff --git a/src/routes/apiRoutes.js b/src/routes/apiRoutes.js index 5328498..bf6bfc6 100644 --- a/src/routes/apiRoutes.js +++ b/src/routes/apiRoutes.js @@ -50,6 +50,7 @@ import { getCompletedAnime } from "../controllers/completedAnime.controller.js"; import { categoryRoutes } from "./category.route.js"; import { getMirrorStatus, resetMirrorCache, getProxyStatus } from "../helper/mirror.helper.js"; import { getCacheStats } from "../helper/cache.helper.js"; +import { assertProxyableUrl, streamRefererHeaders } from "../helper/streamProxy.helper.js"; // ══════════════════════════════════════════════════════════════ // ROUTE REGISTRATION @@ -235,13 +236,13 @@ app.use((req, res, next) => { if (!url) { return res.status(400).json({ success: false, message: "M3U8 URL is required" }); } - // NOTE: Validate URL is from known streaming domains - const allowedDomains = ["vidtube.site", "vidplay.site", "megaplay.buzz", "megaplay-1.buzz", "cdn.anipixcdn.co"]; - const urlObj = new URL(url); - const isAllowed = allowedDomains.some(d => urlObj.hostname === d || urlObj.hostname.endsWith(`.${d}`)) || urlObj.pathname.endsWith(".m3u8"); - - if (!isAllowed) { - return res.status(403).json({ success: false, message: "Domain not allowed for proxy" }); + // NOTE: Validate URL is from known streaming domains. + // WARNING: Do not add an "or the path looks like a playlist" escape hatch — + // the previous version had one, which let any host through and turned this + // endpoint into an open proxy onto the server's own network. + const check = assertProxyableUrl(url); + if (!check.ok) { + return res.status(check.status).json({ success: false, message: check.message }); } const axios = (await import("axios")).default; @@ -250,8 +251,7 @@ app.use((req, res, next) => { const response = await axios.get(url, { headers: { ...defaultHeaders, - "Referer": "https://anikototv.to/", - "Origin": "https://anikototv.to", + ...streamRefererHeaders(), }, timeout: 15000, responseType: "text", @@ -288,14 +288,20 @@ app.use((req, res, next) => { return res.status(400).json({ success: false, message: "TS URL is required" }); } + // NOTE: Segment URLs come from the caller too, so they need the same guard + // the playlist proxy uses — this endpoint previously had none. + const check = assertProxyableUrl(url); + if (!check.ok) { + return res.status(check.status).json({ success: false, message: check.message }); + } + const axios = (await import("axios")).default; const { headers: defaultHeaders } = await import("../configs/header.config.js"); const response = await axios.get(url, { headers: { ...defaultHeaders, - "Referer": "https://anikototv.to/", - "Origin": "https://anikototv.to", + ...streamRefererHeaders(), }, timeout: 30000, responseType: "arraybuffer",