diff --git a/photomap/backend/args.py b/photomap/backend/args.py index 5e2a2c9..01db64a 100644 --- a/photomap/backend/args.py +++ b/photomap/backend/args.py @@ -72,6 +72,6 @@ def get_args() -> argparse.Namespace: "--inline-upgrade", action=argparse.BooleanOptionalAction, default=True, - help="Perform inline database upgrades", + help="Allow inline package upgrades from the About dialog (default: True). Overridden by PHOTOMAP_INLINE_UPGRADE env var.", ) return parser.parse_args() diff --git a/photomap/backend/photomap_server.py b/photomap/backend/photomap_server.py index ea463a5..b7ef875 100644 --- a/photomap/backend/photomap_server.py +++ b/photomap/backend/photomap_server.py @@ -202,7 +202,7 @@ def main(): # Convert list of album keys to comma-separated string os.environ["PHOTOMAP_ALBUM_LOCKED"] = ",".join(args.album_locked) - os.environ["PHOTOMAP_INLINE_UPGRADE"] = "1" if args.inline_upgrade else "0" + os.environ.setdefault("PHOTOMAP_INLINE_UPGRADE", "1" if args.inline_upgrade else "0") app_url = get_app_url(host, port) diff --git a/photomap/backend/routers/invoke.py b/photomap/backend/routers/invoke.py index da78dfb..359145e 100644 --- a/photomap/backend/routers/invoke.py +++ b/photomap/backend/routers/invoke.py @@ -30,6 +30,7 @@ import time from collections.abc import Awaitable, Callable from pathlib import Path +from urllib.parse import urlsplit import httpx from fastapi import APIRouter, HTTPException @@ -212,6 +213,44 @@ async def _do(headers: dict[str, str]) -> httpx.Response: return resp.status_code == 200 +def _validate_invokeai_url(url: str | None) -> str | None: + """Reject non-http(s) schemes so configured URLs cannot be used for SSRF. + + The configured URL is later concatenated into outbound requests for + ``/status``, ``/boards``, ``/recall`` and ``/use_ref_image``; ``httpx`` + already refuses non-http(s) schemes, but validating up front returns + a clean 400 to the settings panel rather than a 502 at call time, and + blocks obviously-wrong values like ``file://`` or ``javascript:`` from + ever reaching the config file. + + Empty / None is allowed — that's "not configured yet". + """ + if not url: + return url + try: + parts = urlsplit(url) + except ValueError as exc: + raise HTTPException( + status_code=400, detail=f"Invalid InvokeAI URL: {exc}" + ) from exc + if parts.scheme not in {"http", "https"}: + raise HTTPException( + status_code=400, + detail="InvokeAI URL must use http:// or https://", + ) + if not parts.netloc: + raise HTTPException( + status_code=400, detail="InvokeAI URL must include a host" + ) + return url + + +# InvokeAI queue ids are short opaque tokens (e.g. ``default``); restrict +# the pattern so a caller can't splice ``../`` into the outbound URL path +# and reach an arbitrary endpoint on the configured backend. +_QUEUE_ID_PATTERN = r"^[A-Za-z0-9_.-]{1,64}$" + + class InvokeAISettings(BaseModel): """Mirrors the config fields we expose via the settings panel.""" @@ -230,7 +269,11 @@ class RecallRequest(BaseModel): True, description="If False, omit the seed from the recall payload (remix mode)", ) - queue_id: str = Field("default", description="InvokeAI queue id to target") + queue_id: str = Field( + "default", + description="InvokeAI queue id to target", + pattern=_QUEUE_ID_PATTERN, + ) class UseRefImageRequest(BaseModel): @@ -238,7 +281,11 @@ class UseRefImageRequest(BaseModel): album_key: str = Field(..., description="Album containing the image") index: int = Field(..., ge=0, description="Image index within the album") - queue_id: str = Field("default", description="InvokeAI queue id to target") + queue_id: str = Field( + "default", + description="InvokeAI queue id to target", + pattern=_QUEUE_ID_PATTERN, + ) @invoke_router.get("/config") @@ -265,13 +312,14 @@ async def set_invokeai_config(settings: InvokeAISettings) -> dict: untouched so the settings panel can PATCH individual fields without clobbering what was saved. Send an empty string to explicitly clear. """ + url = _validate_invokeai_url(settings.url) _invalidate_token_cache() existing = config_manager.get_invokeai_settings() password = settings.password if settings.password is not None else existing["password"] board_id = settings.board_id if settings.board_id is not None else existing["board_id"] try: config_manager.set_invokeai_settings( - url=settings.url, + url=url, username=settings.username, password=password, board_id=board_id, @@ -312,15 +360,16 @@ async def invokeai_status() -> dict: "reachable": False, "detail": f"Backend returned HTTP {resp.status_code}", } + not_invokeai = "Server is reachable but doesn't appear to be an InvokeAI backend" try: payload = resp.json() except ValueError: - return {"reachable": False, "detail": "Backend did not return JSON"} + return {"reachable": False, "detail": not_invokeai} version = payload.get("version") if not version: # A non-InvokeAI server happening to have /api/v1/app/version would # almost certainly not return a version field. - return {"reachable": False, "detail": "Response missing version field"} + return {"reachable": False, "detail": not_invokeai} return {"reachable": True, "version": version} diff --git a/photomap/backend/routers/search.py b/photomap/backend/routers/search.py index 7eb37c7..784acca 100644 --- a/photomap/backend/routers/search.py +++ b/photomap/backend/routers/search.py @@ -7,6 +7,7 @@ import base64 import json +import re import zipfile from io import BytesIO from logging import getLogger @@ -18,6 +19,7 @@ from pydantic import BaseModel from ..config import get_config_manager +from ..embeddings import SUPPORTED_EXTENSIONS from ..metadata_modules import SlideSummary from .album import ( get_embeddings_for_album, @@ -29,6 +31,15 @@ search_router = APIRouter() logger = getLogger(__name__) +# The ``color`` query param is interpolated into the on-disk thumbnail +# cache filename, so anything that survives here becomes a path segment. +# Accept only a 6-digit hex literal (with or without ``#``) or an +# ``r,g,b`` CSV of three 0-255 integers — reject everything else so a +# value like ``../../evil`` cannot escape the thumbnail cache dir. +_COLOR_RE = re.compile(r"\A#?[0-9A-Fa-f]{6}\Z|\A\d{1,3},\d{1,3},\d{1,3}\Z") +_MAX_THUMB_SIZE = 2048 +_MAX_THUMB_RADIUS = 512 + # Response Models class SearchResult(BaseModel): @@ -181,6 +192,13 @@ async def serve_thumbnail( radius: int = 12, # Add a radius parameter for rounded corners ) -> FileResponse: """Serve a reduced-size thumbnail for an image by index, with optional colored border.""" + if size <= 0 or size > _MAX_THUMB_SIZE: + raise HTTPException(status_code=400, detail="Invalid thumbnail size") + if radius < 0 or radius > _MAX_THUMB_RADIUS: + raise HTTPException(status_code=400, detail="Invalid thumbnail radius") + if color is not None and not _COLOR_RE.match(color): + raise HTTPException(status_code=400, detail="Invalid color parameter") + embeddings = get_embeddings_for_album(album_key) try: image_path = embeddings.get_image_path(index) @@ -266,6 +284,14 @@ async def serve_image(album_key: str, path: str): if not validate_image_access(album_config, image_path): raise HTTPException(status_code=403, detail="Access denied") + # Enforce the image-extension allowlist on any file-serving endpoint. + # ``add_album`` accepts arbitrary absolute ``image_paths``; without this + # check a caller could point an album at ``/etc`` and then read + # ``/images//passwd`` (the ``is_relative_to`` guard above only + # checks *location*, not type). + if image_path.suffix.lower() not in SUPPORTED_EXTENSIONS: + raise HTTPException(status_code=403, detail="Unsupported image type") + if not image_path.exists() or not image_path.is_file(): raise HTTPException(status_code=404, detail="File not found") @@ -345,6 +371,9 @@ async def get_image_by_name(album_key: str, filename: str) -> FileResponse: """ Serve an image by its filename within the specified album. """ + if Path(filename).suffix.lower() not in SUPPORTED_EXTENSIONS: + raise HTTPException(status_code=403, detail="Unsupported image type") + embeddings = get_embeddings_for_album(album_key) if not embeddings: raise HTTPException(status_code=404, detail="Album not found") diff --git a/photomap/backend/routers/upgrade.py b/photomap/backend/routers/upgrade.py index 4354a9d..7b27e22 100644 --- a/photomap/backend/routers/upgrade.py +++ b/photomap/backend/routers/upgrade.py @@ -8,7 +8,7 @@ from logging import getLogger import requests -from fastapi import APIRouter +from fastapi import APIRouter, HTTPException, Request from fastapi.responses import JSONResponse from packaging import version as pversion @@ -16,6 +16,38 @@ logger = getLogger(__name__) +def _require_inline_upgrades_enabled() -> None: + """Honour the ``PHOTOMAP_INLINE_UPGRADE`` deployment switch. + + The flag is set from ``--inline-upgrade`` / env on startup; when the + operator has explicitly disabled it the UI hides the button, but the + endpoint was previously still callable. Enforce it here so the backend + is the source of truth. + """ + if os.environ.get("PHOTOMAP_INLINE_UPGRADE", "1") != "1": + raise HTTPException( + status_code=403, + detail="Inline upgrades are disabled on this deployment.", + ) + + +def _require_same_origin_header(request: Request) -> None: + """Reject requests that lack the ``X-Requested-With`` marker. + + The update and restart endpoints perform side effects (pip install, + process kill) and have no authentication. A cross-origin page could + otherwise submit a CSRF-able simple POST to ``http://localhost:8050`` + and trigger either action. Requiring a non-standard request header + forces the caller through a CORS preflight, which our server does not + answer — so only same-origin JS with an explicit header succeeds. + """ + if request.headers.get("x-requested-with") != "photomap": + raise HTTPException( + status_code=403, + detail="Missing required X-Requested-With header.", + ) + + @upgrade_router.get("/version/check", tags=["Upgrade"]) async def check_version(): """Check if a newer version is available on PyPI""" @@ -55,8 +87,10 @@ async def check_version(): @upgrade_router.post("/version/update", tags=["Upgrade"]) -async def update_version(): +async def update_version(request: Request): """Update PhotoMapAI to the latest version using pip""" + _require_inline_upgrades_enabled() + _require_same_origin_header(request) try: # Run pip install --upgrade photomapai result = subprocess.run( @@ -98,8 +132,10 @@ async def update_version(): @upgrade_router.post("/version/restart", tags=["Upgrade"]) -async def restart_server(): +async def restart_server(request: Request): """Restart the server after update""" + _require_inline_upgrades_enabled() + _require_same_origin_header(request) def delayed_restart(): time.sleep(2) # Give time for response to be sent diff --git a/photomap/frontend/static/javascript/about.js b/photomap/frontend/static/javascript/about.js index 466cede..270a938 100644 --- a/photomap/frontend/static/javascript/about.js +++ b/photomap/frontend/static/javascript/about.js @@ -116,6 +116,7 @@ class AboutManager { try { const response = await fetch("version/update", { method: "POST", + headers: { "X-Requested-With": "photomap" }, }); const data = await response.json(); @@ -128,7 +129,10 @@ class AboutManager { if (data.restart_available) { setTimeout(async () => { try { - await fetch("version/restart", { method: "POST" }); + await fetch("version/restart", { + method: "POST", + headers: { "X-Requested-With": "photomap" }, + }); updateStatus.textContent = "Server restarting... Waiting for server to come back online..."; // Wait 5 seconds before starting to poll diff --git a/photomap/frontend/static/javascript/settings.js b/photomap/frontend/static/javascript/settings.js index a2ad311..f159af6 100644 --- a/photomap/frontend/static/javascript/settings.js +++ b/photomap/frontend/static/javascript/settings.js @@ -326,9 +326,40 @@ export async function loadInvokeAISettings() { await refreshInvokeAIStatus(); } +// Captured lazily on first use so setInvokeAIUrlError can restore the original +// hint after clearing a validation error. Has to be lazy because cacheElements() +// runs after module import. +let _defaultInvokeAIHintHTML = null; + +function setInvokeAIUrlError(message) { + const hint = elements.invokeaiStatusHint; + if (!hint) { + return; + } + if (_defaultInvokeAIHintHTML === null) { + _defaultInvokeAIHintHTML = hint.innerHTML; + } + if (message) { + hint.style.color = "#c0392b"; + hint.setAttribute("role", "alert"); + hint.textContent = ""; + const icon = document.createElement("span"); + icon.setAttribute("aria-hidden", "true"); + icon.textContent = "⚠ "; + hint.appendChild(icon); + hint.appendChild(document.createTextNode(message)); + } else { + hint.style.color = "#666"; + hint.removeAttribute("role"); + hint.innerHTML = _defaultInvokeAIHintHTML; + } +} + +// Returns null on success, or the backend's error detail string on failure, +// so the caller can render it inline under the URL field. async function saveInvokeAISettings() { if (!elements.invokeaiUrlInput) { - return; + return null; } const body = { url: elements.invokeaiUrlInput.value, @@ -343,13 +374,27 @@ async function saveInvokeAISettings() { body.board_id = elements.invokeaiBoardSelect.value || ""; } try { - await fetch("invokeai/config", { + const response = await fetch("invokeai/config", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body), }); + if (!response.ok) { + let detail = `Save failed (${response.status})`; + try { + const data = await response.json(); + if (data && data.detail) { + detail = String(data.detail); + } + } catch { + // Non-JSON body — keep the generic message. + } + return detail; + } + return null; } catch (error) { console.error("Failed to save InvokeAI settings:", error); + return error.message || "Save failed"; } } @@ -362,26 +407,33 @@ function setInvokeAIAuthSectionVisible(visible) { export async function refreshInvokeAIStatus() { // Don't even try to probe when the URL is blank — keep the auth rows - // hidden so the UI stays calm for users who don't run InvokeAI. + // hidden so the UI stays calm for users who don't run InvokeAI. Also + // clear any stale error from a previous typing session. if (!elements.invokeaiUrlInput || !elements.invokeaiUrlInput.value.trim()) { setInvokeAIAuthSectionVisible(false); + setInvokeAIUrlError(null); return; } try { const response = await fetch("invokeai/status"); if (!response.ok) { setInvokeAIAuthSectionVisible(false); + setInvokeAIUrlError(`Status check failed (HTTP ${response.status})`); return; } const data = await response.json(); const reachable = !!data.reachable; setInvokeAIAuthSectionVisible(reachable); if (reachable) { + setInvokeAIUrlError(null); await loadInvokeAIBoards(); + } else { + setInvokeAIUrlError(data.detail || "InvokeAI backend is not reachable"); } } catch (error) { console.error("Failed to probe InvokeAI status:", error); setInvokeAIAuthSectionVisible(false); + setInvokeAIUrlError("Could not contact the status endpoint"); } } @@ -448,24 +500,29 @@ function setupInvokeAISettingsControls() { }; // Every credential/URL edit has to save first and then probe status: the // status endpoint reads the persisted config, not whatever's in the field. - const debouncedSaveAndRefresh = debounced(async () => { - await saveInvokeAISettings(); - await refreshInvokeAIStatus(); - }); + // If the save was rejected (e.g. invalid URL scheme), skip the probe — the + // persisted config still holds the previous value, so a probe would be + // misleading — and surface the error inline under the URL field instead. + const saveAndRefresh = async () => { + const error = await saveInvokeAISettings(); + setInvokeAIUrlError(error); + if (!error) { + await refreshInvokeAIStatus(); + } + }; + const debouncedSaveAndRefresh = debounced(saveAndRefresh); const textInputs = [elements.invokeaiUrlInput, elements.invokeaiUsernameInput, elements.invokeaiPasswordInput].filter( Boolean ); textInputs.forEach((input) => { input.addEventListener("input", debouncedSaveAndRefresh); - input.addEventListener("blur", async () => { - await saveInvokeAISettings(); - await refreshInvokeAIStatus(); - }); + input.addEventListener("blur", saveAndRefresh); }); if (elements.invokeaiBoardSelect) { elements.invokeaiBoardSelect.addEventListener("change", async () => { invokeaiSelectedBoardId = elements.invokeaiBoardSelect.value || ""; - await saveInvokeAISettings(); + const error = await saveInvokeAISettings(); + setInvokeAIUrlError(error); }); } } diff --git a/tests/backend/test_image_type_guard.py b/tests/backend/test_image_type_guard.py new file mode 100644 index 0000000..5d41484 --- /dev/null +++ b/tests/backend/test_image_type_guard.py @@ -0,0 +1,77 @@ +"""Image-extension allowlist on the file-serving endpoints. + +``add_album`` accepts arbitrary absolute ``image_paths``; without an +extension guard a caller could point an album at, say, ``/etc`` and +then ``GET /images//passwd`` to read any file the server user can +open. These tests lock down both ``serve_image`` and +``get_image_by_name`` so only files with a known image suffix can be +served. +""" + +from __future__ import annotations + +from pathlib import Path + +from fastapi.testclient import TestClient + + +def _add_album(client: TestClient, key: str, image_path: Path, index_path: Path) -> None: + """Create an album pointing at an arbitrary directory (no indexing).""" + response = client.post( + "/add_album/", + json={ + "key": key, + "name": "chain", + "image_paths": [image_path.as_posix()], + "index": index_path.as_posix(), + "umap_eps": 0.1, + "description": "", + }, + ) + assert response.status_code == 201, response.text + + +def test_serve_image_rejects_non_image_extension(client, tmp_path): + """The add_album → serve_image arbitrary-file-read chain is closed.""" + secret_dir = tmp_path / "secrets" + secret_dir.mkdir() + secret_file = secret_dir / "passwd" + secret_file.write_text("root:x:0:0:/root:/bin/bash\n") + + try: + _add_album( + client, + "chain_album", + secret_dir, + tmp_path / "chain.npz", + ) + + # Even though the file exists and is inside the configured album + # path, its extension is not in the allowlist — so the request is + # refused rather than the raw file being returned. + response = client.get("/images/chain_album/passwd") + assert response.status_code == 403, response.text + assert "unsupported" in response.json()["detail"].lower() + + # And a file with a disallowed ``.txt`` extension is refused even + # when the path looks innocent. + (secret_dir / "notes.txt").write_text("nothing interesting") + response = client.get("/images/chain_album/notes.txt") + assert response.status_code == 403 + finally: + client.delete("/delete_album/chain_album") + + +def test_image_by_name_rejects_non_image_extension(client, tmp_path): + """Defense-in-depth: ``get_image_by_name`` also refuses non-image suffixes.""" + secret_dir = tmp_path / "secrets2" + secret_dir.mkdir() + (secret_dir / "passwd").write_text("fake") + try: + _add_album( + client, "name_album", secret_dir, tmp_path / "name.npz" + ) + response = client.get("/image_by_name/name_album/passwd") + assert response.status_code == 403 + finally: + client.delete("/delete_album/name_album") diff --git a/tests/backend/test_invoke_router.py b/tests/backend/test_invoke_router.py index b1b931d..34a1b36 100644 --- a/tests/backend/test_invoke_router.py +++ b/tests/backend/test_invoke_router.py @@ -1400,3 +1400,65 @@ async def post(self, url, **kwargs): body = response.json() assert body["reused_existing"] is False assert body["uploaded_image_name"] == "uploaded.png" + + +# ── SSRF / URL-scheme hardening ─────────────────────────────────────── + + +@pytest.mark.parametrize( + "bad_url", + [ + "file:///etc/passwd", + "javascript:alert(1)", + "ftp://example.com/", + "://no-scheme", + "http://", # missing host + ], +) +def test_set_config_rejects_unsafe_url(client, clear_invokeai_config, bad_url): + response = client.post("/invokeai/config", json={"url": bad_url}) + assert response.status_code == 400, response.text + # And nothing was persisted. + assert get_config_manager().get_invokeai_settings()["url"] is None + + +@pytest.mark.parametrize( + "good_url", + [ + "http://localhost:9090", + "https://invoke.example.com", + "http://127.0.0.1:9090/", + ], +) +def test_set_config_accepts_http_and_https(client, clear_invokeai_config, good_url): + response = client.post("/invokeai/config", json={"url": good_url}) + assert response.status_code == 200, response.text + assert get_config_manager().get_invokeai_settings()["url"] == good_url + + +@pytest.mark.parametrize( + "bad_queue", + ["../auth/login", "default/../foo", "has space", "with/slash", ""], +) +def test_recall_rejects_unsafe_queue_id(client, clear_invokeai_config, bad_queue): + client.post("/invokeai/config", json={"url": "http://localhost:9090"}) + response = client.post( + "/invokeai/recall", + json={"album_key": "any", "index": 0, "queue_id": bad_queue}, + ) + assert response.status_code == 422, response.text + + +@pytest.mark.parametrize( + "bad_queue", + ["../auth/login", "default/../foo", "has space", "with/slash"], +) +def test_use_ref_image_rejects_unsafe_queue_id( + client, clear_invokeai_config, bad_queue +): + client.post("/invokeai/config", json={"url": "http://localhost:9090"}) + response = client.post( + "/invokeai/use_ref_image", + json={"album_key": "any", "index": 0, "queue_id": bad_queue}, + ) + assert response.status_code == 422, response.text diff --git a/tests/backend/test_thumbnail_validation.py b/tests/backend/test_thumbnail_validation.py new file mode 100644 index 0000000..edd7157 --- /dev/null +++ b/tests/backend/test_thumbnail_validation.py @@ -0,0 +1,69 @@ +"""Input validation for the ``/thumbnails/{album}/{index}`` endpoint. + +The ``color`` query parameter is interpolated into the on-disk thumbnail +cache filename, so unsanitised values like ``../../evil`` would have +escaped the cache directory and caused arbitrary-location writes. These +tests lock down the whitelist. +""" + +from __future__ import annotations + +import pytest +from fixtures import build_index + + +@pytest.fixture +def indexed_album(client, new_album, monkeypatch): + build_index(client, new_album, monkeypatch) + return new_album + + +@pytest.mark.parametrize( + "bad_color", + [ + "../../evil", + "#../foo", + "red", # not hex / rgb triple + "#xyz", + "#abc", # short-form hex — not supported by our 6-digit parser + "256,256,256" + "," * 20, + "#1234567", # wrong length + "1,2", # too few components + ], +) +def test_thumbnail_rejects_unsafe_color(client, indexed_album, bad_color): + response = client.get( + f"/thumbnails/{indexed_album['key']}/0", + params={"color": bad_color}, + ) + assert response.status_code == 400, response.text + + +@pytest.mark.parametrize( + "good_color", + ["#ff00aa", "ff00aa", "255,128,0"], +) +def test_thumbnail_accepts_valid_color(client, indexed_album, good_color): + response = client.get( + f"/thumbnails/{indexed_album['key']}/0", + params={"color": good_color}, + ) + assert response.status_code == 200 + + +@pytest.mark.parametrize("bad_size", [0, -1, 10_000, 99_999]) +def test_thumbnail_rejects_out_of_range_size(client, indexed_album, bad_size): + response = client.get( + f"/thumbnails/{indexed_album['key']}/0", + params={"size": bad_size}, + ) + assert response.status_code == 400 + + +@pytest.mark.parametrize("bad_radius", [-1, 10_000]) +def test_thumbnail_rejects_out_of_range_radius(client, indexed_album, bad_radius): + response = client.get( + f"/thumbnails/{indexed_album['key']}/0", + params={"radius": bad_radius}, + ) + assert response.status_code == 400 diff --git a/tests/backend/test_upgrade_router.py b/tests/backend/test_upgrade_router.py new file mode 100644 index 0000000..c23702e --- /dev/null +++ b/tests/backend/test_upgrade_router.py @@ -0,0 +1,104 @@ +"""Tests for the upgrade router's auth/origin gating. + +The update and restart endpoints have no authentication; both the +``PHOTOMAP_INLINE_UPGRADE`` deployment switch and the +``X-Requested-With`` header requirement must be enforced server-side. +""" + +from __future__ import annotations + +import pytest + + +@pytest.fixture +def inline_upgrades_enabled(monkeypatch): + monkeypatch.setenv("PHOTOMAP_INLINE_UPGRADE", "1") + + +@pytest.fixture +def inline_upgrades_disabled(monkeypatch): + monkeypatch.setenv("PHOTOMAP_INLINE_UPGRADE", "0") + + +@pytest.fixture +def no_pip(monkeypatch): + """Stub out ``subprocess.run`` so tests never actually invoke pip.""" + from photomap.backend.routers import upgrade as upgrade_module + + class _FakeCompleted: + returncode = 0 + stdout = "stub" + stderr = "" + + def _fake_run(*args, **kwargs): + return _FakeCompleted() + + monkeypatch.setattr(upgrade_module.subprocess, "run", _fake_run) + + +@pytest.fixture +def no_restart(monkeypatch): + """Stub out the ``os.kill`` path so tests never actually restart.""" + from photomap.backend.routers import upgrade as upgrade_module + + class _FakeThread: + def __init__(self, *args, **kwargs): + pass + + def start(self): + pass + + monkeypatch.setattr(upgrade_module.threading, "Thread", _FakeThread) + + +def test_update_rejects_missing_xrw_header(client, inline_upgrades_enabled, no_pip): + response = client.post("/version/update") + assert response.status_code == 403 + assert "x-requested-with" in response.json()["detail"].lower() + + +def test_update_accepts_xrw_header(client, inline_upgrades_enabled, no_pip): + response = client.post( + "/version/update", headers={"X-Requested-With": "photomap"} + ) + assert response.status_code == 200 + + +def test_update_blocked_when_inline_disabled( + client, inline_upgrades_disabled, no_pip +): + response = client.post( + "/version/update", headers={"X-Requested-With": "photomap"} + ) + assert response.status_code == 403 + assert "disabled" in response.json()["detail"].lower() + + +def test_restart_rejects_missing_xrw_header( + client, inline_upgrades_enabled, no_restart +): + response = client.post("/version/restart") + assert response.status_code == 403 + + +def test_restart_accepts_xrw_header(client, inline_upgrades_enabled, no_restart): + response = client.post( + "/version/restart", headers={"X-Requested-With": "photomap"} + ) + assert response.status_code == 200 + + +def test_restart_blocked_when_inline_disabled( + client, inline_upgrades_disabled, no_restart +): + response = client.post( + "/version/restart", headers={"X-Requested-With": "photomap"} + ) + assert response.status_code == 403 + + +def test_check_version_is_still_unauthenticated(client, inline_upgrades_enabled): + """The read-only check endpoint stays open — only state-changing ones are gated.""" + # May return 200 or 503 depending on network; either is fine, just not 403. + response = client.get("/version/check") + assert response.status_code != 403