From 28cb433724b776d41f754004eb2204cac0ee7721 Mon Sep 17 00:00:00 2001 From: Pierre Date: Sun, 26 Apr 2026 15:26:17 +0200 Subject: [PATCH] add auth --- src/server.py | 78 ++++++++++++++++++++++++++++++++++++++++++++------ www/index.html | 5 ++-- 2 files changed, 73 insertions(+), 10 deletions(-) diff --git a/src/server.py b/src/server.py index ddb705e..9e61db8 100644 --- a/src/server.py +++ b/src/server.py @@ -28,6 +28,7 @@ import json import signal import sys import base64 +import hashlib from http.server import HTTPServer, BaseHTTPRequestHandler from urllib.parse import urlparse, unquote import threading @@ -44,6 +45,23 @@ SCHEME = os.environ.get('SCHEME', 'auto').lower().strip() META_CORE_PATH = os.environ.get('META_CORE_PATH', '/meta-core') BASE_URL = os.environ.get('BASE_URL', '') +# Stremio addon path-token protection. When HASH_API_SEED is set, every addon +# path (manifest, catalog, meta, stream, transcode, direct, file, poster) must +# be prefixed with /s/. Dashboard paths bypass this — Caddy + the +# nginx-hash-lock sidecar gate those with OIDC, since browsers can carry +# cookies but Stremio clients cannot. +HASH_API_SEED = os.environ.get('HASH_API_SEED', '').strip() +API_KEY = hashlib.sha256(HASH_API_SEED.encode()).hexdigest()[:16] if HASH_API_SEED else '' +API_PREFIX = f'/s/{API_KEY}' if API_KEY else '' + +# Paths the addon serves for browsers, not Stremio clients. These are gated +# upstream by hash-lock/OIDC instead of the path-token. +DASHBOARD_PATHS = frozenset({ + '/', '/index.html', '/configure', '/health', + '/api/stats', '/api/library', '/api/services', '/api/languages', + '/transcode/metrics', +}) + # Initialize storage storage = stremio.init_storage() @@ -133,10 +151,32 @@ class Handler(BaseHTTPRequestHandler): self.send_header('Access-Control-Allow-Headers', 'Content-Type, Range') self.end_headers() + def strip_api_prefix(self, path: str) -> str | None: + """Remove the /s/ prefix from addon paths. + + Returns the de-prefixed path, or None when the token is missing/wrong + (caller should send 401). Dashboard paths are returned untouched — + they are protected by hash-lock/OIDC upstream, not by the token. + """ + if not API_KEY: + return path + if path in DASHBOARD_PATHS: + return path + if path.startswith(API_PREFIX + '/'): + return path[len(API_PREFIX):] + if path == API_PREFIX: + return '/' + return None + def do_HEAD(self): parsed = urlparse(self.path) path = unquote(parsed.path) + path = self.strip_api_prefix(path) + if path is None: + self.send_error(401, "Unauthorized") + return + if path.startswith('/stremio/') or path == '/' or path.startswith('/manifest.json'): self.send_response(200) self.send_header('Access-Control-Allow-Origin', '*') @@ -181,6 +221,11 @@ class Handler(BaseHTTPRequestHandler): parsed = urlparse(self.path) path = unquote(parsed.path) + path = self.strip_api_prefix(path) + if path is None: + self.send_error(401, "Unauthorized") + return + # Reset metrics if path == '/transcode/reset-metrics': transcoder.reset_metrics() @@ -192,8 +237,16 @@ class Handler(BaseHTTPRequestHandler): parsed = urlparse(self.path) raw_path = unquote(parsed.path) + # Strip path-token (when HASH_API_SEED is set) before any further + # parsing; the existing base64-config-in-path scheme then runs on the + # tail, so URLs look like /s///manifest.json. + stripped = self.strip_api_prefix(raw_path) + if stripped is None: + self.send_error(401, "Unauthorized") + return + # Parse config from path (e.g., /eyJsYW5n.../manifest.json) - path, config = parse_config_from_path(raw_path) + path, config = parse_config_from_path(stripped) # Root - serve setup page if path == '/' or path == '/index.html': @@ -325,11 +378,14 @@ class Handler(BaseHTTPRequestHandler): if os.path.exists(html_path): with open(html_path, 'r', encoding='utf-8') as f: content = f.read() + prefix_script = f'' + content = content.replace('', f'{prefix_script}\n', 1) + encoded = content.encode('utf-8') self.send_response(200) self.send_header('Content-Type', 'text/html') - self.send_header('Content-Length', len(content.encode('utf-8'))) + self.send_header('Content-Length', len(encoded)) self.end_headers() - self.wfile.write(content.encode('utf-8')) + self.wfile.write(encoded) else: # Fallback minimal page content = f""" @@ -338,7 +394,7 @@ class Handler(BaseHTTPRequestHandler):

Meta-Stremio

Install URL: {self.get_base_url()}/manifest.json

-

Install in Stremio

+

Install in Stremio

""" self.send_response(200) @@ -364,6 +420,7 @@ class Handler(BaseHTTPRequestHandler): # Inline configure page (fallback) base_url = self.get_base_url() host = self.headers.get('Host', 'localhost') + api_prefix = API_PREFIX languages = stremio.get_supported_languages() lang_options = '\n'.join( @@ -508,6 +565,7 @@ class Handler(BaseHTTPRequestHandler):