"""Opt-in WebUI extension hooks. This module intentionally provides a small, self-hosted extension surface: configured same-origin script/style injection plus sandboxed static file serving. It is disabled by default and never executes or fetches third-party URLs. """ import html import http.client import json import math import logging import os import re import socket import threading from pathlib import Path from typing import Any, Dict, List, Optional, Set, Tuple from urllib.parse import unquote, urlsplit import hashlib import io import time import zipfile from urllib.request import ( HTTPRedirectHandler, HTTPSHandler, build_opener, ) from api.helpers import _security_headers, j _log = logging.getLogger(__name__) # Sane bound on configured URLs — real extensions ship 1-3 files. Higher values # typically indicate a misconfiguration (one giant unsplit string, or a runaway # generator script that wrote an env-var template without filtering). Capping # avoids rendering tens of thousands of '.format(html.escape(url, quote=True)) for url in config["script_urls"] ] runtime_config = { "extensions": config.get("extensions", []), } runtime_json = json.dumps(runtime_config, ensure_ascii=False, separators=(",", ":")).replace("<", "\\u003c") runtime_tag = ( "" ).format(runtime_json) if stylesheet_tags: head_marker = "" block = "\n".join(stylesheet_tags) + "\n" if head_marker in result: result = result.replace(head_marker, block + head_marker, 1) else: result = block + result if runtime_config["extensions"] or script_tags: body_marker = "" block = runtime_tag + "\n" if script_tags: block += "\n".join(script_tags) + "\n" if body_marker in result: result = result.replace(body_marker, block + body_marker, 1) else: result = result + "\n" + block return result def _is_safe_relative_path(rel: str) -> bool: # Strict: reject empty, traversal, AND any dot-prefixed segment. This is shared # by static serving, asset URLs and manifest paths, where a hidden file must # never become reachable. Archive members use _is_safe_archive_member() below. if not rel or "\x00" in rel or "\\" in rel: return False for segment in rel.split("/"): if not segment or segment in (".", "..") or segment.startswith("."): return False return True # Benign hidden LEAF files that extension archives legitimately ship. A dot-prefixed # name is permitted only as the final path segment and only if it is one of these; # dot-directories (e.g. ".git/") and every other dotfile (".env", ".secret") stay rejected. _ALLOWED_ARCHIVE_DOTFILES = frozenset({".gitkeep", ".gitignore", ".gitattributes", ".env.example"}) def _is_safe_archive_member(rel: str) -> bool: """Path validator for archive install/uninstall ONLY. Same containment as _is_safe_relative_path(), but permits a narrow allowlist of benign hidden leaf files so extensions can ship ".gitkeep"/".env.example" without loosening the shared validator that also gates static serving.""" if not rel or "\x00" in rel or "\\" in rel: return False segments = rel.split("/") for segment in segments[:-1]: if not segment or segment in (".", "..") or segment.startswith("."): return False leaf = segments[-1] if not leaf or leaf in (".", ".."): return False if leaf.startswith(".") and leaf not in _ALLOWED_ARCHIVE_DOTFILES: return False return True def _not_found(handler) -> bool: j(handler, {"error": "not found"}, status=404) return True def serve_extension_static(handler, parsed) -> bool: """Serve a file from the configured extension directory. The function always returns True for /extensions/* requests: either a file response or a 404. It never reveals why a request failed, which avoids leaking local paths or extension configuration details. """ root = _extension_root() if root is None: return _not_found(handler) rel = unquote(parsed.path[len(EXTENSION_ROUTE_PREFIX) :]) if not _is_safe_relative_path(rel): return _not_found(handler) static_file = (root / rel).resolve() try: static_file.relative_to(root) except ValueError: return _not_found(handler) if not static_file.exists() or not static_file.is_file(): return _not_found(handler) ct = _EXTENSION_MIME.get(static_file.suffix.lower().lstrip("."), "text/plain") ct_header = "{}; charset=utf-8".format(ct) if ct in _TEXT_MIME_TYPES else ct try: raw = static_file.read_bytes() except OSError: return _not_found(handler) handler.send_response(200) handler.send_header("Content-Type", ct_header) handler.send_header("Cache-Control", "no-store") handler.send_header("Content-Length", str(len(raw))) _security_headers(handler) handler.end_headers() handler.wfile.write(raw) return True