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:
Shinei Nouzen
2026-08-27 14:40:09 +05:30
parent 1ee01470d4
commit 30fdcd93c2
6 changed files with 244 additions and 14 deletions
+9
View File
@@ -25,3 +25,12 @@ MIRROR_CACHE_TTL=3600000
# Environment (production/development) # Environment (production/development)
NODE_ENV=production 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/
+15
View File
@@ -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/), 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). 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 ## [2.4.0] - 2026-08-14
### Added ### Added
+54
View File
@@ -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.
+7 -3
View File
@@ -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 express from "express";
import compression from "compression"; import compression from "compression";
import path from "path"; import path from "path";
@@ -27,8 +33,6 @@ import { dirname } from "path";
import { createApiRoutes } from "./src/routes/apiRoutes.js"; import { createApiRoutes } from "./src/routes/apiRoutes.js";
import { addCreatorInfo } from "./src/middleware/creatorInfo.js"; import { addCreatorInfo } from "./src/middleware/creatorInfo.js";
dotenv.config();
// ══════════════════════════════════════════════════════════════ // ══════════════════════════════════════════════════════════════
// SERVER CONFIGURATION // SERVER CONFIGURATION
// ══════════════════════════════════════════════════════════════ // ══════════════════════════════════════════════════════════════
+142
View File
@@ -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 };
+17 -11
View File
@@ -50,6 +50,7 @@ import { getCompletedAnime } from "../controllers/completedAnime.controller.js";
import { categoryRoutes } from "./category.route.js"; import { categoryRoutes } from "./category.route.js";
import { getMirrorStatus, resetMirrorCache, getProxyStatus } from "../helper/mirror.helper.js"; import { getMirrorStatus, resetMirrorCache, getProxyStatus } from "../helper/mirror.helper.js";
import { getCacheStats } from "../helper/cache.helper.js"; import { getCacheStats } from "../helper/cache.helper.js";
import { assertProxyableUrl, streamRefererHeaders } from "../helper/streamProxy.helper.js";
// ══════════════════════════════════════════════════════════════ // ══════════════════════════════════════════════════════════════
// ROUTE REGISTRATION // ROUTE REGISTRATION
@@ -235,13 +236,13 @@ app.use((req, res, next) => {
if (!url) { if (!url) {
return res.status(400).json({ success: false, message: "M3U8 URL is required" }); return res.status(400).json({ success: false, message: "M3U8 URL is required" });
} }
// NOTE: Validate URL is from known streaming domains // NOTE: Validate URL is from known streaming domains.
const allowedDomains = ["vidtube.site", "vidplay.site", "megaplay.buzz", "megaplay-1.buzz", "cdn.anipixcdn.co"]; // WARNING: Do not add an "or the path looks like a playlist" escape hatch —
const urlObj = new URL(url); // the previous version had one, which let any host through and turned this
const isAllowed = allowedDomains.some(d => urlObj.hostname === d || urlObj.hostname.endsWith(`.${d}`)) || urlObj.pathname.endsWith(".m3u8"); // endpoint into an open proxy onto the server's own network.
const check = assertProxyableUrl(url);
if (!isAllowed) { if (!check.ok) {
return res.status(403).json({ success: false, message: "Domain not allowed for proxy" }); return res.status(check.status).json({ success: false, message: check.message });
} }
const axios = (await import("axios")).default; const axios = (await import("axios")).default;
@@ -250,8 +251,7 @@ app.use((req, res, next) => {
const response = await axios.get(url, { const response = await axios.get(url, {
headers: { headers: {
...defaultHeaders, ...defaultHeaders,
"Referer": "https://anikototv.to/", ...streamRefererHeaders(),
"Origin": "https://anikototv.to",
}, },
timeout: 15000, timeout: 15000,
responseType: "text", responseType: "text",
@@ -288,14 +288,20 @@ app.use((req, res, next) => {
return res.status(400).json({ success: false, message: "TS URL is required" }); 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 axios = (await import("axios")).default;
const { headers: defaultHeaders } = await import("../configs/header.config.js"); const { headers: defaultHeaders } = await import("../configs/header.config.js");
const response = await axios.get(url, { const response = await axios.get(url, {
headers: { headers: {
...defaultHeaders, ...defaultHeaders,
"Referer": "https://anikototv.to/", ...streamRefererHeaders(),
"Origin": "https://anikototv.to",
}, },
timeout: 30000, timeout: 30000,
responseType: "arraybuffer", responseType: "arraybuffer",