Skip to content

Commit 2e9965e

Browse files
Remove MAX_CONCURRENT_DOWNLOADS configuration from various files and update documentation accordingly. Introduce security enhancements in the API and worker to enforce SSRF protection and client IP allowlisting. Update environment variables in .env.example and docker-compose files to reflect changes in worker settings.
1 parent 4b7bfd6 commit 2e9965e

8 files changed

Lines changed: 199 additions & 36 deletions

File tree

docs/ARCHITECTURE.md

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -559,7 +559,6 @@ The system deploys **2 independent download workers** by default to maximize thr
559559

560560
**Per-Worker Configuration:**
561561
```
562-
MAX_CONCURRENT_DOWNLOADS=3 # Jobs processed simultaneously per worker
563562
MAX_DOWNLOAD_WORKERS=10 # Threads per video for segment downloading
564563
```
565564

docs/SPECIFICATION.md

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -180,7 +180,6 @@ services:
180180
environment:
181181
- REDIS_URL=redis://redis:6379
182182
- DATABASE_URL=postgresql://postgres:password@db:5432/m3u8_db
183-
- MAX_CONCURRENT_DOWNLOADS=3
184183
- MAX_DOWNLOAD_WORKERS=10
185184
volumes:
186185
- /nas/downloads:/downloads
@@ -194,7 +193,6 @@ services:
194193
environment:
195194
- REDIS_URL=redis://redis:6379
196195
- DATABASE_URL=postgresql://postgres:password@db:5432/m3u8_db
197-
- MAX_CONCURRENT_DOWNLOADS=3
198196
- MAX_DOWNLOAD_WORKERS=10
199197
volumes:
200198
- /nas/downloads:/downloads
@@ -401,7 +399,6 @@ API_KEY=your-secure-api-key-here
401399
DB_PASSWORD=your-secure-db-password-here
402400
# Storage is mounted to /downloads inside containers
403401
STORAGE_PATH=/downloads
404-
MAX_CONCURRENT_DOWNLOADS=3
405402
MAX_DOWNLOAD_WORKERS=10
406403
MAX_RETRY_ATTEMPTS=3
407404
FFMPEG_THREADS=4
Lines changed: 44 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,32 +1,52 @@
1-
# API Configuration
2-
API_KEY=change-this-to-a-secure-random-key
3-
API_HOST=0.0.0.0
4-
API_PORT=8000
1+
# WebVideo2NAS - Docker environment example
2+
#
3+
# Copy to .env and adjust values.
4+
#
55

6-
# NAS Storage Configuration
7-
NAS_STORAGE_PATH=/volume1/downloads/m3u8
6+
# =====================
7+
# Required
8+
# =====================
9+
# API authentication (must NOT be change-this-key)
10+
API_KEY=change-this-to-a-very-long-secure-key-minimum-32-chars
811

9-
# Database Configuration
10-
DATABASE_URL=postgresql://postgres:postgres_password@db:5432/m3u8_db
12+
# PostgreSQL password (used by db container and API/worker connection string)
13+
DB_PASSWORD=ChangeThisPassword123!
1114

12-
# Redis Configuration
13-
REDIS_URL=redis://redis:6379/0
1415

15-
# Download Worker Configuration
16-
MAX_CONCURRENT_DOWNLOADS=3
17-
MAX_DOWNLOAD_WORKERS=10
18-
MAX_RETRY_ATTEMPTS=3
19-
DOWNLOAD_TIMEOUT=300
16+
# =====================
17+
# Common
18+
# =====================
19+
LOG_LEVEL=INFO
2020

21-
# FFmpeg Configuration
22-
FFMPEG_THREADS=4
23-
FFMPEG_PRESET=fast
24-
OUTPUT_FORMAT=mp4
21+
# CORS (API)
22+
# Example for Chrome extension:
23+
# ALLOWED_ORIGINS=chrome-extension://<your-extension-id>
24+
ALLOWED_ORIGINS=chrome-extension://*
25+
# Optional: allow cookies/credentials (requires explicit origins; wildcard will be rejected)
26+
CORS_ALLOW_CREDENTIALS=false
2527

26-
# Logging
27-
LOG_LEVEL=INFO
28-
LOG_FILE=/logs/app.log
2928

30-
# Security
31-
ALLOWED_ORIGINS=chrome-extension://*,http://localhost:*
29+
# =====================
30+
# Performance
31+
# =====================
32+
MAX_DOWNLOAD_WORKERS=20
33+
MAX_RETRY_ATTEMPTS=3
34+
FFMPEG_THREADS=2
35+
36+
37+
# =====================
38+
# Security (recommended if exposed to public internet)
39+
# =====================
40+
# Per-client rate limit for protected endpoints (0 disables)
3241
RATE_LIMIT_PER_MINUTE=10
42+
43+
# Restrict who can call the API (comma-separated CIDRs)
44+
# Examples:
45+
# ALLOWED_CLIENT_CIDRS=203.0.113.8/32
46+
# ALLOWED_CLIENT_CIDRS=203.0.113.8/32,198.51.100.0/24
47+
ALLOWED_CLIENT_CIDRS=
48+
49+
# Basic SSRF guard for /api/download (blocks private/loopback/link-local/reserved destinations)
50+
# Set true for public deployments. Keep false if you intentionally download from LAN hosts.
51+
SSRF_GUARD=false
52+

m3u8-downloader/docker/SYNOLOGY_DEPLOY_COMMANDS.md

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,6 @@ cd /volume1/docker/m3u8-downloader/docker
2222
cat > .env << 'EOF'
2323
DB_PASSWORD=ChangeThisPassword123!
2424
API_KEY=change-this-to-a-very-long-secure-key-minimum-32-chars
25-
MAX_CONCURRENT_DOWNLOADS=3
2625
MAX_DOWNLOAD_WORKERS=10
2726
MAX_RETRY_ATTEMPTS=3
2827
FFMPEG_THREADS=4
@@ -318,7 +317,6 @@ docker-compose -f docker-compose.synology.yml restart
318317
vi /volume1/docker/m3u8-downloader/docker/.env
319318

320319
# Change the following values:
321-
# MAX_CONCURRENT_DOWNLOADS=5 # Number of concurrent downloads
322320
# MAX_DOWNLOAD_WORKERS=15 # Number of threads per video
323321

324322
# Restart Worker

m3u8-downloader/docker/api/main.py

Lines changed: 113 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,11 +16,16 @@
1616
from sqlalchemy import create_engine, text
1717
from sqlalchemy.orm import sessionmaker, Session
1818
import uuid
19+
import ipaddress
20+
import socket
1921

2022
# Configuration
2123
DATABASE_URL = os.getenv("DATABASE_URL", "postgresql://postgres:postgres@db:5432/m3u8_db")
2224
REDIS_URL = os.getenv("REDIS_URL", "redis://redis:6379/0")
2325
API_KEY = os.getenv("API_KEY")
26+
RATE_LIMIT_PER_MINUTE = int(os.getenv("RATE_LIMIT_PER_MINUTE", "0") or "0")
27+
ALLOWED_CLIENT_CIDRS_RAW = os.getenv("ALLOWED_CLIENT_CIDRS", "").strip()
28+
SSRF_GUARD_ENABLED = os.getenv("SSRF_GUARD", "false").strip().lower() in ("1", "true", "yes", "y", "on")
2429

2530
# Backward-compatible default: allow all origins unless explicitly restricted.
2631
_allowed_origins_raw = os.getenv("ALLOWED_ORIGINS", "*").strip()
@@ -58,6 +63,102 @@
5863
# Redis setup
5964
redis_client = redis.from_url(REDIS_URL, decode_responses=True)
6065

66+
# Security helpers
67+
def _get_client_ip(request: Request) -> str:
68+
"""
69+
Best-effort client IP for rate limiting / allowlisting.
70+
If you're behind a reverse proxy, ensure it is trusted before relying on X-Forwarded-For.
71+
"""
72+
xff = (request.headers.get("x-forwarded-for") or "").strip()
73+
if xff:
74+
# Use the left-most (original) IP
75+
return xff.split(",")[0].strip()
76+
if request.client and request.client.host:
77+
return request.client.host
78+
return "unknown"
79+
80+
81+
def _parse_allowed_client_networks() -> list[ipaddress._BaseNetwork]:
82+
if not ALLOWED_CLIENT_CIDRS_RAW:
83+
return []
84+
networks: list[ipaddress._BaseNetwork] = []
85+
for token in [t.strip() for t in ALLOWED_CLIENT_CIDRS_RAW.split(",") if t.strip()]:
86+
try:
87+
networks.append(ipaddress.ip_network(token, strict=False))
88+
except ValueError:
89+
raise HTTPException(status_code=503, detail=f"Server misconfigured: invalid ALLOWED_CLIENT_CIDRS entry: {token}")
90+
return networks
91+
92+
93+
_ALLOWED_CLIENT_NETWORKS = _parse_allowed_client_networks()
94+
95+
96+
def _enforce_client_allowlist(request: Request) -> None:
97+
if not _ALLOWED_CLIENT_NETWORKS:
98+
return
99+
client_ip_str = _get_client_ip(request)
100+
try:
101+
client_ip = ipaddress.ip_address(client_ip_str)
102+
except ValueError:
103+
raise HTTPException(status_code=403, detail="Client IP not allowed")
104+
for net in _ALLOWED_CLIENT_NETWORKS:
105+
if client_ip in net:
106+
return
107+
raise HTTPException(status_code=403, detail="Client IP not allowed")
108+
109+
110+
def _rate_limit(request: Request, bucket: str) -> None:
111+
if RATE_LIMIT_PER_MINUTE <= 0:
112+
return
113+
client_ip = _get_client_ip(request)
114+
window = int(datetime.utcnow().timestamp() // 60)
115+
key = f"rl:{bucket}:{client_ip}:{window}"
116+
try:
117+
count = redis_client.incr(key)
118+
redis_client.expire(key, 90)
119+
except Exception:
120+
# If Redis is unavailable, skip rate limiting (avoid breaking core API).
121+
return
122+
if count > RATE_LIMIT_PER_MINUTE:
123+
raise HTTPException(status_code=429, detail="Rate limit exceeded")
124+
125+
126+
def _resolve_host_ips(hostname: str) -> list[ipaddress._BaseAddress]:
127+
# Resolve A/AAAA records; if it fails, we treat as invalid for SSRF protection.
128+
infos = socket.getaddrinfo(hostname, None, proto=socket.IPPROTO_TCP)
129+
ips: list[ipaddress._BaseAddress] = []
130+
for info in infos:
131+
sockaddr = info[4]
132+
ip_str = sockaddr[0]
133+
ips.append(ipaddress.ip_address(ip_str))
134+
return ips
135+
136+
137+
def _is_ip_public(ip: ipaddress._BaseAddress) -> bool:
138+
# Block common SSRF targets: loopback, link-local, RFC1918/ULA, multicast, reserved, etc.
139+
if ip.is_loopback or ip.is_private or ip.is_link_local or ip.is_multicast or ip.is_reserved or ip.is_unspecified:
140+
return False
141+
return True
142+
143+
144+
def _enforce_ssrf_guard(url: HttpUrl) -> None:
145+
if not SSRF_GUARD_ENABLED:
146+
return
147+
hostname = url.host
148+
if not hostname:
149+
raise HTTPException(status_code=400, detail="Invalid URL host")
150+
if hostname.lower() in ("localhost",):
151+
raise HTTPException(status_code=400, detail="URL host not allowed")
152+
try:
153+
ips = _resolve_host_ips(hostname)
154+
except Exception:
155+
raise HTTPException(status_code=400, detail="URL host could not be resolved")
156+
if not ips:
157+
raise HTTPException(status_code=400, detail="URL host could not be resolved")
158+
for ip in ips:
159+
if not _is_ip_public(ip):
160+
raise HTTPException(status_code=400, detail="URL host not allowed")
161+
61162
# Pydantic models
62163
class DownloadRequest(BaseModel):
63164
url: HttpUrl
@@ -73,6 +174,8 @@ def validate_video_url(cls, v):
73174
is_valid = '.m3u8' in url_str or '.mp4' in url_str
74175
if not is_valid:
75176
raise ValueError('URL must contain .m3u8 or .mp4')
177+
# Optional SSRF protection for public deployments
178+
_enforce_ssrf_guard(v)
76179
return v
77180

78181
class JobResponse(BaseModel):
@@ -102,8 +205,10 @@ def get_db():
102205
finally:
103206
db.close()
104207

105-
def verify_api_key(authorization: Optional[str] = Header(None)):
208+
def verify_api_key(request: Request, authorization: Optional[str] = Header(None)):
106209
"""Verify API key from Authorization header"""
210+
_enforce_client_allowlist(request)
211+
_rate_limit(request, bucket="auth")
107212
if not API_KEY or API_KEY.strip() == "" or API_KEY.strip() == "change-this-key":
108213
raise HTTPException(status_code=503, detail="Server not configured: API_KEY is not set")
109214
if not authorization:
@@ -128,9 +233,15 @@ async def root():
128233
}
129234

130235
@app.get("/api/health")
131-
async def health_check():
236+
async def health_check(request: Request, authorization: Optional[str] = Header(None)):
132237
"""Health check endpoint"""
133238
try:
239+
# Avoid exposing internal status to the public internet.
240+
# Allow localhost checks (Docker healthcheck) without auth; require API key otherwise.
241+
client_ip = _get_client_ip(request)
242+
if client_ip not in ("127.0.0.1", "::1"):
243+
verify_api_key(request=request, authorization=authorization)
244+
134245
# Check database
135246
db = SessionLocal()
136247
db.execute(text("SELECT 1"))

m3u8-downloader/docker/docker-compose.synology.yml

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -100,7 +100,6 @@ services:
100100
- DATABASE_URL=postgresql://postgres:${DB_PASSWORD:-postgres_password}@db:5432/m3u8_db
101101
- REDIS_URL=redis://redis:6379/0
102102
- LOG_LEVEL=${LOG_LEVEL:-INFO}
103-
- MAX_CONCURRENT_DOWNLOADS=${MAX_CONCURRENT_DOWNLOADS:-3}
104103
- MAX_DOWNLOAD_WORKERS=${MAX_DOWNLOAD_WORKERS:-2}
105104
- MAX_RETRY_ATTEMPTS=${MAX_RETRY_ATTEMPTS:-3}
106105
- FFMPEG_THREADS=${FFMPEG_THREADS:-4}
@@ -130,7 +129,6 @@ services:
130129
- DATABASE_URL=postgresql://postgres:${DB_PASSWORD:-postgres_password}@db:5432/m3u8_db
131130
- REDIS_URL=redis://redis:6379/0
132131
- LOG_LEVEL=${LOG_LEVEL:-INFO}
133-
- MAX_CONCURRENT_DOWNLOADS=${MAX_CONCURRENT_DOWNLOADS:-3}
134132
- MAX_DOWNLOAD_WORKERS=${MAX_DOWNLOAD_WORKERS:-2}
135133
- MAX_RETRY_ATTEMPTS=${MAX_RETRY_ATTEMPTS:-3}
136134
- FFMPEG_THREADS=${FFMPEG_THREADS:-4}

m3u8-downloader/docker/docker-compose.yml

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -90,7 +90,6 @@ services:
9090
- DATABASE_URL=postgresql://postgres:${DB_PASSWORD:-postgres_password}@db:5432/m3u8_db
9191
- REDIS_URL=redis://redis:6379/0
9292
- LOG_LEVEL=${LOG_LEVEL:-INFO}
93-
- MAX_CONCURRENT_DOWNLOADS=${MAX_CONCURRENT_DOWNLOADS:-3}
9493
- MAX_DOWNLOAD_WORKERS=${MAX_DOWNLOAD_WORKERS:-2}
9594
- MAX_RETRY_ATTEMPTS=${MAX_RETRY_ATTEMPTS:-3}
9695
- FFMPEG_THREADS=${FFMPEG_THREADS:-4}
@@ -118,7 +117,6 @@ services:
118117
- DATABASE_URL=postgresql://postgres:${DB_PASSWORD:-postgres_password}@db:5432/m3u8_db
119118
- REDIS_URL=redis://redis:6379/0
120119
- LOG_LEVEL=${LOG_LEVEL:-INFO}
121-
- MAX_CONCURRENT_DOWNLOADS=${MAX_CONCURRENT_DOWNLOADS:-3}
122120
- MAX_DOWNLOAD_WORKERS=${MAX_DOWNLOAD_WORKERS:-2}
123121
- MAX_RETRY_ATTEMPTS=${MAX_RETRY_ATTEMPTS:-3}
124122
- FFMPEG_THREADS=${FFMPEG_THREADS:-4}

m3u8-downloader/docker/worker/worker.py

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,12 +16,15 @@
1616
from datetime import datetime
1717
from urllib.parse import urlparse
1818
import signal
19+
import ipaddress
20+
import socket
1921

2022
# Configuration
2123
DATABASE_URL = os.getenv("DATABASE_URL", "postgresql://postgres:postgres@db:5432/m3u8_db")
2224
REDIS_URL = os.getenv("REDIS_URL", "redis://redis:6379/0")
2325
LOG_LEVEL = os.getenv("LOG_LEVEL", "INFO")
2426
MAX_RETRY_ATTEMPTS = int(os.getenv("MAX_RETRY_ATTEMPTS", "3"))
27+
SSRF_GUARD_ENABLED = os.getenv("SSRF_GUARD", "false").strip().lower() in ("1", "true", "yes", "y", "on")
2528

2629
# Setup logging
2730
logging.basicConfig(
@@ -48,6 +51,41 @@ def signal_handler(sig, frame):
4851
signal.signal(signal.SIGINT, signal_handler)
4952
signal.signal(signal.SIGTERM, signal_handler)
5053

54+
def _resolve_host_ips(hostname: str) -> list[ipaddress._BaseAddress]:
55+
infos = socket.getaddrinfo(hostname, None, proto=socket.IPPROTO_TCP)
56+
ips: list[ipaddress._BaseAddress] = []
57+
for info in infos:
58+
sockaddr = info[4]
59+
ip_str = sockaddr[0]
60+
ips.append(ipaddress.ip_address(ip_str))
61+
return ips
62+
63+
64+
def _is_ip_public(ip: ipaddress._BaseAddress) -> bool:
65+
if ip.is_loopback or ip.is_private or ip.is_link_local or ip.is_multicast or ip.is_reserved or ip.is_unspecified:
66+
return False
67+
return True
68+
69+
70+
def _enforce_ssrf_guard(url: str) -> None:
71+
if not SSRF_GUARD_ENABLED:
72+
return
73+
parsed = urlparse(url)
74+
hostname = parsed.hostname
75+
if not hostname:
76+
raise Exception("Invalid URL host")
77+
if hostname.lower() in ("localhost",):
78+
raise Exception("URL host not allowed")
79+
try:
80+
ips = _resolve_host_ips(hostname)
81+
except Exception:
82+
raise Exception("URL host could not be resolved")
83+
if not ips:
84+
raise Exception("URL host could not be resolved")
85+
for ip in ips:
86+
if not _is_ip_public(ip):
87+
raise Exception("URL host not allowed")
88+
5189

5290
class DownloadWorker:
5391
"""Worker class for processing download jobs"""
@@ -235,6 +273,8 @@ def _process_direct_download(self, job_id: str, job: dict):
235273
from ssl_adapter import create_legacy_session
236274

237275
try:
276+
_enforce_ssrf_guard(job["url"])
277+
238278
# Update status to downloading
239279
self.update_job_status(job_id, "downloading", progress=0)
240280
logger.info(f"Starting direct download: {job['url']}")
@@ -587,6 +627,8 @@ def _process_m3u8_download(self, job_id: str, job: dict):
587627
temp_dir = None
588628

589629
try:
630+
_enforce_ssrf_guard(job["url"])
631+
590632
# Update status to downloading
591633
self.update_job_status(job_id, "downloading", progress=0)
592634
logger.info(f"Starting m3u8 download: {job['url']}")

0 commit comments

Comments
 (0)