From 73d1b0ed38a3ddb331c044a91eb396bd6e6fb7ed Mon Sep 17 00:00:00 2001 From: sascha Date: Sat, 5 Sep 2026 07:59:52 +0200 Subject: [PATCH 1/2] feat: add restricted media handoff bridge --- app.py | 68 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 67 insertions(+), 1 deletion(-) diff --git a/app.py b/app.py index 0a3b88b..af8f8c4 100644 --- a/app.py +++ b/app.py @@ -12,13 +12,14 @@ from contextlib import asynccontextmanager from contextvars import ContextVar log = logging.getLogger("butler") -VERSION = "2.3.5" +VERSION = "2.3.6" API_DIR = os.environ.get("API_KEY_DIR", "/data/api") VAULT_CACHE_DIR = os.environ.get("VAULT_CACHE_DIR", "/data/vault-cache") BUTLER_TOKEN = os.environ.get("BUTLER_TOKEN", "") CONFIG_PATH = os.environ.get("BUTLER_CONFIG", "/data/butler.yaml") UI_PATH = os.environ.get("BUTLER_UI_PATH", os.path.join(os.path.dirname(__file__), "ui.html")) +MEDIA_HANDOFF_ALLOWED_NETWORKS = os.environ.get("MEDIA_HANDOFF_ALLOWED_NETWORKS", "10.2.1.119/32") # --- Config loading --- @@ -3395,6 +3396,71 @@ async def tts_health(_=Depends(_verify)): return results +class MediaHandoffPayload(BaseModel): + action: Literal["start", "moved", "status", "fail"] + category: str | None = Field(None, max_length=32) + directory: str | None = Field(None, max_length=1000) + release: str | None = Field(None, max_length=500) + cleanName: str | None = Field(None, max_length=500) + jobId: str | None = Field(None, max_length=80) + reason: str | None = Field(None, max_length=300) + + +def _media_handoff_caller_allowed(request: Request) -> bool: + auth = request.headers.get("authorization", "") + if BUTLER_TOKEN and secrets.compare_digest(auth, f"Bearer {BUTLER_TOKEN}"): + return True + try: + caller = ipaddress.ip_address(request.client.host if request.client else "") + networks = [ + ipaddress.ip_network(value.strip(), strict=False) + for value in MEDIA_HANDOFF_ALLOWED_NETWORKS.split(",") + if value.strip() + ] + except ValueError: + return False + return any(caller in network for network in networks) + + +@app.post("/media/handoff") +async def media_handoff(payload: MediaHandoffPayload, request: Request): + """Narrow SABnzbd-to-n8n bridge; no generic unauthenticated proxy access.""" + if not _media_handoff_caller_allowed(request): + raise HTTPException(403, "Media handoff caller is not allowed") + + allowed_categories = {"serien4k", "serien", "video4k", "video"} + if payload.action == "start": + if payload.category not in allowed_categories: + raise HTTPException(422, "Unsupported media category") + expected_prefix = f"/usenet/complete/{payload.category}/" + if not payload.directory or not payload.directory.startswith(expected_prefix): + raise HTTPException(422, "Invalid media handoff directory") + if not payload.release: + raise HTTPException(422, "Release name is required") + else: + if not payload.jobId or not re.fullmatch(r"[a-z0-9-]{8,80}", payload.jobId): + raise HTTPException(422, "Valid jobId is required") + + cfg = SERVICES.get("n8n") + if not cfg or not cfg.get("url"): + raise HTTPException(503, "n8n service is not configured") + target = f"{cfg['url'].rstrip('/')}/webhook/media-handoff" + body = payload.model_dump(exclude_none=True) if hasattr(payload, "model_dump") else payload.dict(exclude_none=True) + try: + async with httpx.AsyncClient(verify=False, timeout=15) as client: + response = await client.post(target, json=body, headers={"Content-Type": "application/json"}) + except httpx.HTTPError as exc: + _audit("/media/handoff", "POST", 502, f"action={payload.action} error={type(exc).__name__}") + raise HTTPException(502, "n8n media handoff is unavailable") from exc + + try: + result = response.json() + except Exception: + result = {"ok": False, "error": "invalid_n8n_response"} + _audit("/media/handoff", "POST", response.status_code, f"action={payload.action}") + return JSONResponse(content=result, status_code=response.status_code) + + @app.api_route("/{service}/{path:path}", methods=["GET", "POST", "PUT", "DELETE", "PATCH"]) async def proxy(service: str, path: str, request: Request, _=Depends(_verify)): SKIP_SERVICES = {"vm", "inventory", "ansible", "debug", "tts", "status", "audit", "config"} -- 2.49.1 From 197208c57186d10054ae282d42b10686fb51c8f3 Mon Sep 17 00:00:00 2001 From: sascha Date: Sat, 5 Sep 2026 07:59:53 +0200 Subject: [PATCH 2/2] feat: add restricted media handoff bridge --- tests/test_app.py | 71 ++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 70 insertions(+), 1 deletion(-) diff --git a/tests/test_app.py b/tests/test_app.py index 161ded6..ef136db 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -43,7 +43,76 @@ def test_health_exposes_current_version(): with TestClient(app.app) as client: response = client.get("/health") assert response.status_code == 200 - assert response.json()["version"] == app.VERSION == "2.3.5" + assert response.json()["version"] == app.VERSION == "2.3.6" + + +def test_media_handoff_proxies_strict_category_contract(monkeypatch): + captured = {} + + class FakeResponse: + status_code = 200 + + def json(self): + return {"ok": True, "jobId": "test-job-1234", "state": "registered"} + + class FakeClient: + def __init__(self, **kwargs): + captured["client"] = kwargs + + async def __aenter__(self): + return self + + async def __aexit__(self, *_args): + return None + + async def post(self, url, json, headers): + captured.update(url=url, json=json, headers=headers) + return FakeResponse() + + monkeypatch.setattr(app.httpx, "AsyncClient", FakeClient) + with TestClient(app.app) as client: + monkeypatch.setattr(app, "SERVICES", {"n8n": {"url": "http://n8n:5678", "auth": "n8n"}}) + response = client.post( + "/media/handoff", + headers={"Authorization": "Bearer test-token"}, + json={ + "action": "start", + "category": "serien4k", + "directory": "/usenet/complete/serien4k/Show.S01E01", + "release": "Show.S01E01-GRP", + "cleanName": "Show S01E01", + }, + ) + assert response.status_code == 200 + assert response.json()["state"] == "registered" + assert captured["url"] == "http://n8n:5678/webhook/media-handoff" + assert captured["json"]["category"] == "serien4k" + + +def test_media_handoff_rejects_untrusted_caller_before_proxy(monkeypatch): + monkeypatch.setattr(app.httpx, "AsyncClient", lambda **_kwargs: (_ for _ in ()).throw(AssertionError("must not proxy"))) + with TestClient(app.app) as client: + response = client.post( + "/media/handoff", + json={"action": "status", "jobId": "test-job-1234"}, + ) + assert response.status_code == 403 + + +def test_media_handoff_rejects_wrong_category_path(monkeypatch): + monkeypatch.setattr(app.httpx, "AsyncClient", lambda **_kwargs: (_ for _ in ()).throw(AssertionError("must not proxy"))) + with TestClient(app.app) as client: + response = client.post( + "/media/handoff", + headers={"Authorization": "Bearer test-token"}, + json={ + "action": "start", + "category": "video4k", + "directory": "/usenet/complete/serien4k/Wrong", + "release": "Wrong", + }, + ) + assert response.status_code == 422 def test_paperless_import_queues_pdf_through_butler(monkeypatch): -- 2.49.1