fix: security hardening and code review improvements
Build and Push Docker Image / build (push) Successful in 12s

- hls proxy now signs full URL instead of hostname only (SSRF)
- viewer token required to start session (prevents stats forgery and password bypass)
- weak SECRET_KEY placeholder replaced with REPLACE_ME, startup warning added
- api key auth drops query-string fallback, Bearer header only
- hls variant playlist DRM detection fetches first variant
- dash manifest DRM detection added
- android webview detection restricted to Telegram only
- privacy comment removed from ip hash (ip is stored in plaintext)
- csv export adds range filter (today/7d/30d/all), defaults to 30d
- analytics export button integrated as dropdown with range selection
- docker-compose placeholder defaults updated
- readme and readme.zh-cn updated for v1.2.0 changes
This commit is contained in:
Stardream
2026-05-24 01:40:03 +10:00
parent 601eb0247f
commit 2b0aaf3a5d
7 changed files with 162 additions and 50 deletions
+5 -4
View File
@@ -28,7 +28,7 @@
## ✨ Features
- **Public stream list** - Live and archive tabs, password-protected streams, custom site branding, bilingual UI (Chinese / English) with per-language site description
- **Player** - HLS, FLV, MPEG-DASH playback via ArtPlayer; AES-128 key override and DASH ClearKey support
- **Player** - HLS, FLV, MPEG-DASH playback via ArtPlayer; AES-128 key override and DASH ClearKey support; Widevine and FairPlay DRM playback via Shaka Player (multi-DRM configs per source, Android Telegram WebView detection)
- **Admin panel** - Add, edit, reorder, enable/disable streams; manage sources; drag-and-drop ordering
- **Viewer analytics** - Session tracking, unique visitors, peak concurrent viewers, average watch duration, device / browser / OS / geography breakdown, real-time dashboard, CSV export
- **Telegram notifications** - Per-stream push messages on stream start and stop
@@ -36,6 +36,7 @@
- **VOD / file serving** - Signed `/video/` URLs with HTTP Range support (seek-capable); publish any local video file or folder as an archive stream directly from the file browser
- **HLS proxy** - Signed `/proxy/hls/` routes for cross-origin HLS playback
- **API key auth** - Generate per-key tokens in the admin panel for programmatic access to all admin and analytics endpoints
- **Mobile responsive** - Admin panel sidebar, file browser rows, and push directory sidebar all collapse gracefully on narrow screens
<div align="right">
@@ -142,7 +143,7 @@ Set these environment variables in `docker-compose.yml`:
| Variable | Default | Required | Description |
|---|---|---|---|
| `SECRET_KEY` | `change-this-secret` | **Yes** | HMAC signing key for sessions and stream routes |
| `SECRET_KEY` | `REPLACE_ME` | **Yes** | HMAC signing key for sessions and stream routes. Generate with `openssl rand -hex 32` |
| `DATABASE_URL` | see compose file | **Yes** | PostgreSQL connection string |
| `POSTGRES_PASSWORD` | see compose file | **Yes** | PostgreSQL password |
| `TZ` | `UTC` | No | Container timezone, e.g. `Asia/Shanghai` |
@@ -234,7 +235,7 @@ The hidden public HLS URL (`/h/<slug>/...`) routes through nginx on port `8889`,
### Authentication
- **Browser session** - cookie set by `POST /api?action=login`
- **API key** - send `Authorization: Bearer <token>` header, or append `?api_key=<token>` to any admin endpoint. Keys are managed in the admin panel under **Site Settings → API Keys**.
- **API key** - send `Authorization: Bearer <token>` header. Keys are managed in the admin panel under **Site Settings → API Keys**.
### Public Endpoints
@@ -280,7 +281,7 @@ The hidden public HLS URL (`/h/<slug>/...`) routes through nginx on port `8889`,
| `GET` | `/api?action=stats_geo` | Country-level visitor counts |
| `GET` | `/api?action=stats_stream_detail&id=<id>` | Single-stream detail |
| `GET` | `/api?action=stats_sessions_page&id=<id>` | Paginated session list |
| `GET` | `/api?action=stats_export_csv` | Export sessions as CSV |
| `GET` | `/api?action=stats_export_csv&range=<range>` | Export sessions as CSV (`range`: `today`, `7d`, `30d` (default), `all`) |
<div align="right">
+5 -4
View File
@@ -28,7 +28,7 @@
## ✨ 功能特性
- **公开直播列表** - 直播 / 存档双 Tab,支持密码保护、自定义站点品牌,内置中英双语界面(含分语言站点简介)
- **播放器** - 基于 ArtPlayer,支持 HLS、FLV、MPEG-DASH 播放;支持 AES-128 密钥覆盖及 DASH ClearKey
- **播放器** - 基于 ArtPlayer,支持 HLS、FLV、MPEG-DASH 播放;支持 AES-128 密钥覆盖及 DASH ClearKey;通过 Shaka Player 支持 Widevine 和 FairPlay DRM 播放(每路播放源可独立配置多 DRM 方案,内置 Android Telegram WebView 检测)
- **管理后台** - 直播的增删改查、启用/禁用、拖拽排序;多播放源管理
- **观看统计** - 会话追踪、独立访客数、峰值并发、平均时长、设备 / 浏览器 / 操作系统 / 地理分布实时看板,支持 CSV 导出
- **Telegram 推送** - 可按直播单独配置,开播 / 关播自动发送通知
@@ -36,6 +36,7 @@
- **VOD 点播 / 视频服务** - 带 HMAC 签名的 `/video/` URL,支持 HTTP Range 请求(可 seek);文件浏览器中可直接将视频文件或文件夹发布为归档直播
- **HLS 代理** - 带签名验证的 `/proxy/hls/` 路由,解决跨域 HLS 播放问题
- **API 密钥鉴权** - 在后台生成 Token,可通过 API 密钥对所有管理及统计接口进行程序化访问
- **移动端适配** - 管理后台侧边栏、文件浏览器行、推流目录侧边栏均可在窄屏设备上自适应折叠
<div align="right">
@@ -142,7 +143,7 @@ python server.py
| 变量 | 默认值 | 是否必填 | 说明 |
|---|---|---|---|
| `SECRET_KEY` | `change-this-secret` | **必填** | 会话与推流路由的 HMAC 签名密钥 |
| `SECRET_KEY` | `REPLACE_ME` | **必填** | 会话与推流路由的 HMAC 签名密钥。可用 `openssl rand -hex 32` 生成 |
| `DATABASE_URL` | 见 compose 文件 | **必填** | PostgreSQL 连接字符串 |
| `POSTGRES_PASSWORD` | 见 compose 文件 | **必填** | PostgreSQL 数据库密码 |
| `TZ` | `UTC` | 否 | 容器时区,如 `Asia/Shanghai` |
@@ -234,7 +235,7 @@ RTMP 服务器: rtmp://HOST:1935/live
### 鉴权方式
- **浏览器会话** - 通过 `POST /api?action=login` 登录后设置的 Cookie
- **API 密钥** - 在请求头携带 `Authorization: Bearer <token>`,或在 URL 追加 `?api_key=<token>`。密钥在后台**网站设置 → API 密钥**处管理。
- **API 密钥** - 在请求头携带 `Authorization: Bearer <token>`。密钥在后台**网站设置 → API 密钥**处管理。
### 公开接口
@@ -280,7 +281,7 @@ RTMP 服务器: rtmp://HOST:1935/live
| `GET` | `/api?action=stats_geo` | 地理分布(国家级) |
| `GET` | `/api?action=stats_stream_detail&id=<id>` | 单场直播详情 |
| `GET` | `/api?action=stats_sessions_page&id=<id>` | 分页会话列表 |
| `GET` | `/api?action=stats_export_csv` | 导出会话 CSV |
| `GET` | `/api?action=stats_export_csv&range=<range>` | 导出会话 CSV`range``today``7d``30d`(默认)、`all` |
<div align="right">
+4 -3
View File
@@ -10,8 +10,9 @@ services:
ports:
- "8085:8080"
environment:
SECRET_KEY: "change-this-secret"
DATABASE_URL: "postgresql://streamhall:streamhall_pg_password@postgres:5432/streamhall"
# Generate with: openssl rand -hex 32
SECRET_KEY: "REPLACE_ME"
DATABASE_URL: "postgresql://streamhall:REPLACE_DB_PASSWORD@postgres:5432/streamhall"
TZ: "UTC"
RTMP_HOST: "srs"
# Comma-separated list of video directories (optional label:path format)
@@ -29,7 +30,7 @@ services:
environment:
POSTGRES_DB: "streamhall"
POSTGRES_USER: "streamhall"
POSTGRES_PASSWORD: "streamhall_pg_password"
POSTGRES_PASSWORD: "REPLACE_DB_PASSWORD"
TZ: "UTC"
volumes:
- ./postgres-data:/var/lib/postgresql/data
+34 -10
View File
@@ -390,8 +390,9 @@
}
.link-remove-btn {
align-self: start;
margin-top: 0;
grid-column: -1;
grid-row: 1 / -1;
align-self: center;
min-height: 42px;
}
@@ -1144,6 +1145,8 @@
.dash-range-btn { border:1px solid var(--line); border-radius:5px; padding:4px 10px; color:var(--muted); background:transparent; font-size:.78rem; font-weight:800; cursor:pointer; box-shadow:none; transform:none !important; }
.dash-range-btn:hover { color:var(--blue); border-color:rgba(78,126,232,.3); background:rgba(78,126,232,.06); filter:none; }
.dash-range-btn.active { color:#fff; background:var(--blue); border-color:transparent; }
.dash-export-range-item { display:block; width:100%; text-align:left; padding:7px 14px; background:none; border:none; color:var(--text); cursor:pointer; font-size:.84em; box-shadow:none; transform:none !important; }
.dash-export-range-item:hover { background:rgba(78,126,232,.1); filter:none; }
.dash-bar-chart { display:flex; align-items:flex-end; gap:2px; height:110px; padding-bottom:3px; }
.dash-bar-col-wrap { flex:1; display:flex; flex-direction:column; align-items:center; justify-content:flex-end; height:100%; position:relative; }
.dash-bar-col { width:100%; min-height:2px; border-radius:3px 3px 0 0; background:var(--blue); opacity:.75; transition:height .4s cubic-bezier(.22,1,.36,1); cursor:default; }
@@ -1605,7 +1608,17 @@
<h2 style="margin-top:0;margin-bottom:18px;" data-i18n="section.analytics">数据看板</h2>
<div class="dash-toolbar">
<span id="dash-live-indicator" class="live-badge" data-state="connecting">连接中...</span>
<button type="button" id="dash-export-btn" class="btn-success" data-i18n="dash.export">⬇ 导出 CSV</button>
<div style="position:relative;display:inline-block;">
<button type="button" id="dash-export-btn" class="btn-success" style="display:flex;align-items:center;gap:5px;">
<span data-i18n="dash.export">导出 CSV</span><span style="font-size:.9em;opacity:.75;"></span>
</button>
<div id="dash-export-menu" style="display:none;position:absolute;right:0;top:calc(100% + 4px);background:var(--panel);border:1px solid var(--line);border-radius:8px;overflow:hidden;z-index:999;min-width:108px;box-shadow:0 4px 16px rgba(0,0,0,.18);">
<button type="button" class="dash-export-range-item" data-range="today" data-i18n="range.today">今日</button>
<button type="button" class="dash-export-range-item" data-range="7d" data-i18n="range.7d">近 7 天</button>
<button type="button" class="dash-export-range-item" data-range="30d" data-i18n="range.30d">近 30 天</button>
<button type="button" class="dash-export-range-item" data-range="all" data-i18n="range.all">全部</button>
</div>
</div>
</div>
<div class="dash-card-grid">
<div class="dash-card dash-accent-mint">
@@ -1807,7 +1820,7 @@
'dash.total_sub': '所有观看会话',
'dash.unique': '独立访客',
'dash.unique_sub': '去重 Visitor ID',
'dash.export': '导出 CSV',
'dash.export': '导出 CSV',
'dash.exporting': '导出中...',
'dash.stream_detail': '直播统计明细',
'dash.per_page': '每页',
@@ -2207,7 +2220,7 @@
'dash.total_sub': 'All sessions',
'dash.unique': 'Unique Visitors',
'dash.unique_sub': 'Deduped visitor IDs',
'dash.export': 'Export CSV',
'dash.export': 'Export CSV',
'dash.exporting': 'Exporting...',
'dash.stream_detail': 'Stream Statistics',
'dash.per_page': 'Per page',
@@ -5029,18 +5042,29 @@
});
// Dashboard export CSV
document.getElementById('dash-export-btn')?.addEventListener('click', async () => {
const exportMenu = document.getElementById('dash-export-menu');
document.getElementById('dash-export-btn')?.addEventListener('click', (e) => {
e.stopPropagation();
exportMenu.style.display = exportMenu.style.display === 'none' ? 'block' : 'none';
});
document.addEventListener('click', () => { if (exportMenu) exportMenu.style.display = 'none'; });
exportMenu?.addEventListener('click', async (e) => {
const item = e.target.closest('.dash-export-range-item');
if (!item) return;
exportMenu.style.display = 'none';
const range = item.dataset.range || '30d';
const btn = document.getElementById('dash-export-btn');
btn.disabled = true; btn.textContent = t('dash.exporting');
const label = btn.querySelector('[data-i18n="dash.export"]');
btn.disabled = true; if (label) label.textContent = t('dash.exporting');
try {
const res = await fetch(`/api?action=stats_export_csv&_t=${Date.now()}`);
const res = await fetch(`/api?action=stats_export_csv&range=${range}&_t=${Date.now()}`);
if (!res.ok) throw new Error('Export failed');
const blob = await res.blob(), url = URL.createObjectURL(blob);
const a = document.createElement('a'); a.href = url;
a.download = `viewer_stats_${new Date().toISOString().slice(0,10)}.csv`;
a.download = `viewer_stats_${range}_${new Date().toISOString().slice(0,10)}.csv`;
a.click(); URL.revokeObjectURL(url);
} catch (e) { showToast(e.message, 'error'); }
finally { btn.disabled = false; btn.textContent = t('dash.export'); }
finally { btn.disabled = false; if (label) label.textContent = t('dash.export'); }
});
// Modal range tabs
+6 -2
View File
@@ -402,7 +402,7 @@
function isAndroidRestrictedWebView() {
const ua = navigator.userAgent || '';
if (!/Android/i.test(ua)) return false;
return /Telegram/i.test(ua) || /; wv\)/i.test(ua) || /\bwv\b/i.test(ua);
return /Telegram/i.test(ua);
}
function selectDrmConfig(link) {
@@ -555,6 +555,7 @@
let viewerHeartbeatTimer = null;
let viewerSessionId = '';
let viewerVisitorId = localStorage.getItem('streamhall_visitor_id') || '';
let viewerToken = '';
let playerInstance = null;
let playbackMisses = 0;
if (!viewerVisitorId) {
@@ -632,6 +633,7 @@
const data = await postViewerEvent('viewer_start', {
id: streamId,
visitorId: viewerVisitorId,
viewerToken,
referer: document.referrer || '',
state: viewerState()
});
@@ -739,6 +741,7 @@
els.pwdModal.classList.remove('hidden');
document.getElementById('pwd-in').focus();
} else {
viewerToken = data.viewerToken || '';
startViewerStats();
waitForLiveAndStart(data);
}
@@ -754,6 +757,7 @@
applyPlayerTitle(data);
applySiteIcon(data.siteIconUrl || '');
els.pwdModal.classList.add('hidden');
viewerToken = data.viewerToken || '';
startViewerStats();
waitForLiveAndStart(data, pwd);
} catch (err) {
@@ -814,7 +818,7 @@
quality: quality,
title: data.eventName,
autoplay: true,
isLive: linkUsesShakaDrm(initialLink),
isLive: data.streamLabel === 'LIVE',
volume: 0.5,
autoSize: true,
fullscreen: true,
+2 -1
View File
@@ -1,8 +1,9 @@
/*
@license
Shaka Player
Shaka Player v4.10.6
Copyright 2016 Google LLC
SPDX-License-Identifier: Apache-2.0
Source: https://github.com/shaka-project/shaka-player/releases/tag/v4.10.6
*/
(function(){var innerGlobal=typeof window!="undefined"?window:global;var exportTo={};(function(window,global,module){/*
+106 -26
View File
@@ -90,6 +90,7 @@ VIDEO_EXTS = frozenset({".mp4", ".mkv", ".avi", ".flv", ".ts", ".mov", ".wmv", "
active_pushes: dict[str, dict] = {}
_pushes_lock = threading.Lock()
HLS_PROXY_PREFIX = "/proxy/hls"
VIEWER_TOKEN_TTL = 300 # seconds
HLS_URI_RE = re.compile(r'URI="([^"]+)"') # matches URI="..." attributes in HLS tag lines (e.g. EXT-X-KEY)
DEFAULT_SITE_SETTINGS = {
@@ -421,14 +422,37 @@ def decode_proxy_target(value: str) -> str:
return base64.urlsafe_b64decode(padded.encode("ascii")).decode("utf-8")
def hls_proxy_host_token(host: str) -> str:
return sign(f"hls-proxy-host:{host.lower()}")
def hls_proxy_url_token(url: str) -> str:
return sign(f"hls-proxy-url:{url}")
def make_viewer_token(stream_id: int) -> str:
expiry = now() + VIEWER_TOKEN_TTL
payload = f"{stream_id}:{expiry}"
encoded = base64.urlsafe_b64encode(payload.encode()).decode().rstrip("=")
return f"{encoded}.{sign(f'viewer-token:{payload}')}"
def verify_viewer_token(token: str, stream_id: int) -> bool:
if not token or "." not in token:
return False
encoded, sig = token.rsplit(".", 1)
try:
padded = encoded + "=" * (-len(encoded) % 4)
payload = base64.urlsafe_b64decode(padded.encode()).decode()
tid_str, expiry_str = payload.split(":", 1)
except Exception:
return False
if int(tid_str) != stream_id:
return False
if now() > int(expiry_str):
return False
return hmac.compare_digest(sig, sign(f"viewer-token:{payload}"))
def hls_proxy_path(url: str) -> str:
host = urlparse(url).netloc
encoded = encode_proxy_target(url)
return f"{HLS_PROXY_PREFIX}/{hls_proxy_host_token(host)}/{encoded}"
return f"{HLS_PROXY_PREFIX}/{hls_proxy_url_token(url)}/{encoded}"
PLAYABLE_VIDEO_EXTS = frozenset({".mp4", ".mkv", ".mov", ".webm", ".m4v"})
@@ -712,6 +736,25 @@ def hls_manifest_drm_types(text: str) -> set[str]:
return types
def hls_first_variant_url(text: str, base_url: str) -> str | None:
lines = text.splitlines()
for i, line in enumerate(lines):
if line.strip().startswith("#EXT-X-STREAM-INF"):
for j in range(i + 1, len(lines)):
candidate = lines[j].strip()
if candidate and not candidate.startswith("#"):
return urljoin(base_url, candidate)
return None
def dash_manifest_drm_types(text: str) -> set[str]:
lower = text.lower()
types: set[str] = set()
if "urn:uuid:edef8ba9-79d6-4ace-a3c8-27dcd51d21ed" in lower or "com.widevine" in lower:
types.add("widevine")
return types
def drm_config_types(configs: object) -> set[str]:
normalized: set[str] = set()
if not isinstance(configs, list):
@@ -813,11 +856,24 @@ def probe_stream_url(
for marker in ("#EXTINF", "#EXT-X-STREAM-INF", "#EXT-X-MEDIA-SEQUENCE", "#EXT-X-PART")
)
drm_types = hls_manifest_drm_types(text)
if "#EXT-X-STREAM-INF" in text:
variant_url = hls_first_variant_url(text, url)
if variant_url:
try:
with urlopen(Request(variant_url, headers=headers), timeout=STREAM_PROBE_TIMEOUT) as vresp:
vdata = vresp.read(65536)
drm_types |= hls_manifest_drm_types(decode_probe_text(vdata))
except Exception:
pass
configured_types = drm_config_types(drm_configs)
return stream_probe_drm_response(has_playlist and has_live_media, status_code, drm_types, configured_types)
if is_dash or "dash+xml" in content_type:
return stream_probe_response(b"<MPD" in data[:2048], status_code)
text = decode_probe_text(data)
valid = "<MPD" in text[:2048]
drm_types = dash_manifest_drm_types(text)
configured_types = drm_config_types(drm_configs)
return stream_probe_drm_response(valid, status_code, drm_types, configured_types)
if is_flv or "flv" in content_type:
return stream_probe_response(data.startswith(b"FLV") or len(data) > 0, status_code)
@@ -916,8 +972,6 @@ def verify_admin_password(password: str) -> bool:
def client_ip_hash(headers: object) -> str:
# Hash the client IP with HMAC-SHA256 so unique visitors can be counted
# without storing the raw IP address in the database.
raw = headers.get("CF-Connecting-IP") or headers.get("X-Forwarded-For") or headers.get("X-Real-IP") or ""
ip = str(raw).split(",", 1)[0].strip()
if not ip:
@@ -1066,9 +1120,11 @@ def player_data(row: dict[str, object], settings: dict[str, str] | None = None)
site = settings or site_settings()
return {
"eventName": row["event_name"],
"streamLabel": normalize_stream_label(row["stream_label"]),
"siteTitle": site["site_title"],
"siteIconUrl": site.get("site_icon_url", ""),
"links": add_playback_urls(normalize_links(links)),
"viewerToken": make_viewer_token(int(row["id"])),
}
@@ -1333,13 +1389,10 @@ class StreamHallHandler(BaseHTTPRequestHandler):
return self._verify_api_key()
def _verify_api_key(self) -> bool:
token = None
auth = self.headers.get("Authorization", "")
if auth.startswith("Bearer "):
token = auth[7:].strip()
if not token:
qs = parse_qs(urlparse(self.path).query)
token = qs.get("api_key", [""])[0]
if not auth.startswith("Bearer "):
return False
token = auth[7:].strip()
if not token:
return False
token_hash = hashlib.sha256(token.encode("utf-8")).hexdigest()
@@ -1646,6 +1699,7 @@ class StreamHallHandler(BaseHTTPRequestHandler):
def api_viewer_start(self) -> None:
body = self.read_json()
viewer_token = str(body.get("viewerToken", "")).strip()
visitor_id = str(body.get("visitorId", "")).strip()[:120] or secrets.token_urlsafe(18)
stream_ref = body.get("id", "")
referer = str(body.get("referer", self.headers.get("Referer", ""))).strip()[:500]
@@ -1653,6 +1707,8 @@ class StreamHallHandler(BaseHTTPRequestHandler):
row = find_stream(conn, stream_ref)
if not row or int(row["is_enabled"] or 0) != 1:
raise AppError("stream_not_found_or_disabled")
if not verify_viewer_token(viewer_token, int(row["id"])):
raise AppError("auth_required")
session_id = secrets.token_urlsafe(18)
timestamp = now()
conn.execute(
@@ -2164,18 +2220,37 @@ class StreamHallHandler(BaseHTTPRequestHandler):
}})
def api_stats_export_csv(self) -> None:
qs = parse_qs(urlparse(self.path).query)
range_param = qs.get("range", ["30d"])[0]
since_map = {"today": 86400, "7d": 7 * 86400, "30d": 30 * 86400}
since = now() - since_map[range_param] if range_param in since_map else 0
with db() as conn:
rows = conn.execute(
"""
SELECT vs.session_id, vs.visitor_id, s.event_name,
vs.device_type, vs.referer,
vs.started_at, vs.ended_at, vs.last_seen_at,
vs.is_active, vs.play_state
FROM viewer_sessions vs
LEFT JOIN streams s ON vs.stream_id = s.id
ORDER BY vs.started_at DESC
"""
).fetchall()
if since:
rows = conn.execute(
"""
SELECT vs.session_id, vs.visitor_id, s.event_name,
vs.device_type, vs.referer,
vs.started_at, vs.ended_at, vs.last_seen_at,
vs.is_active, vs.play_state
FROM viewer_sessions vs
LEFT JOIN streams s ON vs.stream_id = s.id
WHERE vs.started_at >= ?
ORDER BY vs.started_at DESC
""",
(since,),
).fetchall()
else:
rows = conn.execute(
"""
SELECT vs.session_id, vs.visitor_id, s.event_name,
vs.device_type, vs.referer,
vs.started_at, vs.ended_at, vs.last_seen_at,
vs.is_active, vs.play_state
FROM viewer_sessions vs
LEFT JOIN streams s ON vs.stream_id = s.id
ORDER BY vs.started_at DESC
"""
).fetchall()
buf = io.StringIO()
writer = csv.writer(buf)
writer.writerow(["session_id", "visitor_id", "event_name", "device_type", "referer",
@@ -2650,7 +2725,7 @@ class StreamHallHandler(BaseHTTPRequestHandler):
def proxy_hls_route(self, request_path: str, send_body: bool = True) -> None:
# URL format: /proxy/hls/<token>/<base64-encoded-url>
# The token is an HMAC of the target hostname; it ensures that only URLs
# The token is an HMAC of the full target URL; it ensures that only URLs
# generated by hls_proxy_path() can be proxied, preventing open-proxy abuse.
parts = request_path.strip("/").split("/")
if len(parts) != 4 or parts[0] != "proxy" or parts[1] != "hls":
@@ -2666,7 +2741,7 @@ class StreamHallHandler(BaseHTTPRequestHandler):
if parsed.scheme not in ("http", "https"):
self.send_error(HTTPStatus.BAD_REQUEST)
return
if not hmac.compare_digest(token, hls_proxy_host_token(parsed.netloc)):
if not hmac.compare_digest(token, hls_proxy_url_token(target_url)):
self.send_error(HTTPStatus.FORBIDDEN)
return
@@ -3202,7 +3277,12 @@ def resume_push_jobs() -> None:
pass
_WEAK_KEYS = {b"change-this-secret", b"REPLACE_ME"}
def main() -> None:
if SECRET_KEY in _WEAK_KEYS:
print("WARNING: SECRET_KEY is set to a known default value. "
"Generate a secure key with: openssl rand -hex 32", flush=True)
init_db()
threading.Thread(target=resume_push_jobs, daemon=True).start()
threading.Thread(target=monitor_streams_loop, daemon=True).start()