Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion photomap/backend/args.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
2 changes: 1 addition & 1 deletion photomap/backend/photomap_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
59 changes: 54 additions & 5 deletions photomap/backend/routers/invoke.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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."""

Expand All @@ -230,15 +269,23 @@ 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):
"""Payload posted by the drawer's "Use Ref Image" button."""

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")
Expand All @@ -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,
Expand Down Expand Up @@ -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}


Expand Down
29 changes: 29 additions & 0 deletions photomap/backend/routers/search.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@

import base64
import json
import re
import zipfile
from io import BytesIO
from logging import getLogger
Expand All @@ -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,
Expand All @@ -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):
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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/<key>/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")

Expand Down Expand Up @@ -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")
Expand Down
42 changes: 39 additions & 3 deletions photomap/backend/routers/upgrade.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,14 +8,46 @@
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

upgrade_router = APIRouter()
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"""
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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
Expand Down
6 changes: 5 additions & 1 deletion photomap/frontend/static/javascript/about.js
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand All @@ -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
Expand Down
Loading
Loading