fix: autotag endpoints no longer read arbitrary paths or run on GET

This commit is contained in:
Vito Sansevero
2026-10-08 18:01:34 -07:00
parent 4a8dd545d8
commit bcb2713baa
2 changed files with 437 additions and 43 deletions
+141 -43
View File
@@ -2,11 +2,23 @@
import asyncio
import json
import os
from pathlib import Path
from typing import Optional
from aiohttp import web
def _public_error(exc: Exception) -> str:
"""Describe an exception without leaking absolute server paths."""
if isinstance(exc, OSError) and exc.strerror:
filename = exc.filename
if filename:
return f"{exc.strerror}: {os.path.basename(str(filename))}"
return exc.strerror
return str(exc)
class AutotagRoutesMixin:
"""Mixin providing auto-tagging API endpoints."""
@@ -15,11 +27,11 @@ class AutotagRoutesMixin:
async def get_autotag_models_route(request):
return await self.get_autotag_models(request)
@routes.get("/prompt_manager/autotag/download/{model_type}")
@routes.post("/prompt_manager/autotag/download/{model_type}")
async def download_autotag_model_route(request):
return await self.download_autotag_model(request)
@routes.get("/prompt_manager/autotag/start")
@routes.post("/prompt_manager/autotag/start")
async def start_autotag_route(request):
return await self.start_autotag(request)
@@ -116,17 +128,39 @@ class AutotagRoutesMixin:
await response.write_eof()
return response
async def _read_start_params(self, request) -> Optional[dict]:
"""Merge query-string and optional JSON-body parameters for autotag/start.
Returns None when a body is present but is not a JSON object.
"""
params = dict(request.query)
if request.can_read_body:
try:
body = await request.json()
except (json.JSONDecodeError, UnicodeDecodeError):
return None
if not isinstance(body, dict):
return None
params.update(body)
return params
async def start_autotag(self, request):
"""Start batch auto-tagging with streaming progress."""
model_type = request.query.get("model_type", "gguf")
custom_prompt = request.query.get("prompt", "")
skip_tagged = request.query.get("skip_tagged", "true").lower() == "true"
keep_in_memory = request.query.get("keep_in_memory", "true").lower() == "true"
"""Start batch auto-tagging with streaming progress (POST only)."""
params = await self._read_start_params(request)
if params is None:
return web.json_response(
{"success": False, "error": "Request body must be a JSON object"},
status=400,
)
model_type = params.get("model_type", "gguf")
custom_prompt = params.get("prompt", "")
skip_tagged = str(params.get("skip_tagged", "true")).lower() == "true"
keep_in_memory = str(params.get("keep_in_memory", "true")).lower() == "true"
use_gpu = True
# WD14 threshold params
general_threshold = request.query.get("general_threshold")
character_threshold = request.query.get("character_threshold")
general_threshold = params.get("general_threshold")
character_threshold = params.get("character_threshold")
try:
if general_threshold is not None:
general_threshold = float(general_threshold)
@@ -326,11 +360,90 @@ class AutotagRoutesMixin:
await response.write_eof()
return response
def _resolve_allowed_image_path(self, raw_path: str) -> Optional[Path]:
"""Return the resolved path when it lies inside an output directory."""
try:
resolved = Path(raw_path).resolve()
except (OSError, RuntimeError):
return None
for output_dir in self._get_all_output_dirs():
try:
if resolved.is_relative_to(Path(output_dir).resolve()):
return resolved
except (OSError, RuntimeError):
continue
return None
async def _select_autotag_target(self, data: dict):
"""Pick the image to tag from an autotag/single body.
Returns ``(image_path, prompt_id, None)`` on success or
``(None, None, error_response)`` when the request must be rejected.
"""
image_id = data.get("image_id")
if image_id is not None:
if isinstance(image_id, bool) or not isinstance(image_id, (int, str)):
return None, None, self._bad_request("image_id must be an integer")
try:
image_id = int(image_id)
except (TypeError, ValueError):
return None, None, self._bad_request("image_id must be an integer")
record = await self._run_in_executor(self.db.get_image_by_id, image_id)
if not record or not record.get("image_path"):
return (
None,
None,
web.json_response(
{"success": False, "error": f"Image {image_id} not found"},
status=404,
),
)
return record["image_path"], record.get("prompt_id"), None
raw_path = data.get("path") or data.get("image_path")
if not raw_path or not isinstance(raw_path, str):
return None, None, self._bad_request("image_id or path is required")
resolved = self._resolve_allowed_image_path(raw_path)
if resolved is None:
return (
None,
None,
web.json_response(
{
"success": False,
"error": (
f"{os.path.basename(raw_path)} is outside the "
"configured output directories"
),
},
status=403,
),
)
prompt_id = None
try:
prompt_id = await self._run_in_executor(
self.db.get_prompt_id_for_image, raw_path
)
if prompt_id is None and str(resolved) != raw_path:
prompt_id = await self._run_in_executor(
self.db.get_prompt_id_for_image, str(resolved)
)
except Exception as e:
self.logger.warning(f"Could not find linked prompt: {e}")
return str(resolved), prompt_id, None
@staticmethod
def _bad_request(message: str):
return web.json_response({"success": False, "error": message}, status=400)
async def autotag_single(self, request):
"""Generate tags for a single image."""
"""Generate tags for a single image selected by image_id or gallery path."""
try:
data = await request.json()
image_path = data.get("image_path")
if not isinstance(data, dict):
return self._bad_request("Request body must be a JSON object")
model_type = data.get("model_type", "gguf")
custom_prompt = data.get("prompt")
use_gpu = data.get("use_gpu", True)
@@ -342,18 +455,13 @@ class AutotagRoutesMixin:
if character_threshold is not None:
character_threshold = float(character_threshold)
except (ValueError, TypeError):
return web.json_response(
{
"success": False,
"error": "general_threshold and character_threshold must be numeric",
},
status=400,
return self._bad_request(
"general_threshold and character_threshold must be numeric"
)
if not image_path:
return web.json_response(
{"success": False, "error": "image_path is required"}, status=400
)
image_path, prompt_id, error = await self._select_autotag_target(data)
if error is not None:
return error
from ..autotag import get_autotag_service
@@ -363,44 +471,34 @@ class AutotagRoutesMixin:
not service.is_model_loaded()
or service.get_loaded_model_type() != model_type
):
loop = asyncio.get_event_loop()
await loop.run_in_executor(
None, lambda: service.load_model(model_type, use_gpu)
)
await self._run_in_executor(service.load_model, model_type, use_gpu)
if custom_prompt:
service.custom_prompt = custom_prompt
loop = asyncio.get_event_loop()
tags = await loop.run_in_executor(
None,
lambda: service.generate_tags(
image_path,
general_threshold=general_threshold,
character_threshold=character_threshold,
),
tags = await self._run_in_executor(
service.generate_tags,
image_path,
general_threshold=general_threshold,
character_threshold=character_threshold,
)
prompt_id = None
try:
prompt_id = await self._run_in_executor(
self.db.get_prompt_id_for_image, image_path
)
except Exception as e:
self.logger.warning(f"Could not find linked prompt: {e}")
return web.json_response(
{
"success": True,
"tags": tags,
"prompt_id": prompt_id,
"image_path": image_path,
"filename": os.path.basename(image_path),
}
)
except json.JSONDecodeError:
return self._bad_request("Request body must be valid JSON")
except Exception as e:
self.logger.error(f"AutoTag single error: {e}")
return web.json_response({"success": False, "error": str(e)}, status=500)
return web.json_response(
{"success": False, "error": _public_error(e)}, status=500
)
async def apply_autotag(self, request):
"""Apply selected tags to a prompt."""
+296
View File
@@ -0,0 +1,296 @@
"""Route tests for the autotag endpoints.
The ML engine is replaced by a fake service so no model is ever loaded and
no network request is made.
"""
import json
import os
import shutil
import sys
import tempfile
import types
import unittest
from pathlib import Path
from unittest.mock import patch
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
if "folder_paths" not in sys.modules:
_stub = types.ModuleType("folder_paths")
_stub.models_dir = tempfile.gettempdir()
_stub.base_path = tempfile.gettempdir()
_stub.get_output_directory = lambda: tempfile.gettempdir()
sys.modules["folder_paths"] = _stub
from aiohttp import web
from aiohttp.test_utils import AioHTTPTestCase
from database.operations import PromptDatabase
from py.api import PromptManagerAPI
from utils.hashing import generate_prompt_hash
class FakeAutotagService:
"""Stand-in for AutoTagService that records calls and returns fixed tags."""
def __init__(self, tags=None, downloaded=True):
self.tags = tags if tags is not None else ["1girl", "smile"]
self.downloaded = downloaded
self.generate_calls = []
self.load_calls = []
self.download_calls = []
self.unload_calls = 0
self.custom_prompt = ""
self.default_prompt = "default prompt"
self.wd14_general_threshold = 0.35
self.wd14_character_threshold = 0.85
self.models_config = {"gguf": {}, "wd14-vit": {}}
self._loaded_type = None
self.generate_error = None
self.download_result = True
self.load_error = None
def get_models_status(self):
return {
name: {"downloaded": self.downloaded, "model_path": None}
for name in self.models_config
}
def is_model_loaded(self):
return self._loaded_type is not None
def get_loaded_model_type(self):
return self._loaded_type
def load_model(self, model_type, use_gpu=True):
if self.load_error is not None:
raise self.load_error
self.load_calls.append((model_type, use_gpu))
self._loaded_type = model_type
return True
def unload_model(self):
self.unload_calls += 1
self._loaded_type = None
def download_model(self, model_type, progress_callback=None):
self.download_calls.append(model_type)
if progress_callback:
progress_callback("Downloading...", 50)
return self.download_result
def generate_tags(
self, image_path, prompt=None, general_threshold=None, character_threshold=None
):
self.generate_calls.append(
(image_path, prompt, general_threshold, character_threshold)
)
if self.generate_error is not None:
raise self.generate_error
return list(self.tags)
class AutotagAPITestCase(AioHTTPTestCase):
"""Stands up the PromptManager routes with a temp DB, temp output dir and a fake engine."""
async def get_application(self):
self._temp_db = tempfile.NamedTemporaryFile(delete=False, suffix=".db")
self._temp_db.close()
self.tmp_root = Path(tempfile.mkdtemp())
self.output_dir = self.tmp_root / "output"
self.output_dir.mkdir()
self.secret_dir = self.tmp_root / "secret"
self.secret_dir.mkdir()
self.service = FakeAutotagService()
self._service_patch = patch(
"py.autotag.get_autotag_service", return_value=self.service
)
self._service_patch.start()
app = web.Application()
routes = web.RouteTableDef()
self.api = PromptManagerAPI()
self.api.db = PromptDatabase(self._temp_db.name)
self.api._get_all_output_dirs = lambda: [self.output_dir]
self.api.add_routes(routes)
app.router.add_routes(routes)
return app
async def tearDownAsync(self):
self._service_patch.stop()
for path in (
self._temp_db.name,
self._temp_db.name + "-wal",
self._temp_db.name + "-shm",
):
if os.path.exists(path):
os.unlink(path)
shutil.rmtree(self.tmp_root, ignore_errors=True)
# ── helpers ────────────────────────────────────────────────────────
def _save_prompt(self, text="Test prompt", tags=None):
return self.api.db.save_prompt(
text=text,
category=None,
tags=tags or [],
rating=None,
prompt_hash=generate_prompt_hash(text),
)
def _make_image(self, directory, name="img.png"):
path = directory / name
path.write_bytes(b"\x89PNG\r\n\x1a\n")
return path
def _link_image(self, prompt_id, path):
return self.api.db.link_image_to_prompt(prompt_id, str(path))
async def _post_json(self, url, body):
return await self.client.request(
"POST",
url,
data=json.dumps(body),
headers={"Content-Type": "application/json"},
)
@staticmethod
def _sse_events(text):
events = []
for line in text.splitlines():
if line.startswith("data: "):
events.append(json.loads(line[len("data: ") :]))
return events
class TestAutotagSingleContainment(AutotagAPITestCase):
async def test_image_id_tags_the_linked_image(self):
prompt_id = self._save_prompt()
image = self._make_image(self.output_dir)
image_id = self._link_image(prompt_id, image)
resp = await self._post_json(
"/prompt_manager/autotag/single", {"image_id": image_id}
)
self.assertEqual(resp.status, 200)
data = await resp.json()
self.assertTrue(data["success"])
self.assertEqual(data["tags"], ["1girl", "smile"])
self.assertEqual(data["prompt_id"], prompt_id)
self.assertEqual(len(self.service.generate_calls), 1)
self.assertEqual(
os.path.realpath(self.service.generate_calls[0][0]),
os.path.realpath(str(image)),
)
async def test_path_inside_output_dir_is_tagged(self):
image = self._make_image(self.output_dir)
resp = await self._post_json(
"/prompt_manager/autotag/single", {"path": str(image)}
)
self.assertEqual(resp.status, 200)
data = await resp.json()
self.assertTrue(data["success"])
self.assertEqual(data["tags"], ["1girl", "smile"])
self.assertEqual(len(self.service.generate_calls), 1)
async def test_path_outside_output_dir_is_forbidden(self):
secret = self._make_image(self.secret_dir, "password.png")
resp = await self._post_json(
"/prompt_manager/autotag/single", {"path": str(secret)}
)
self.assertEqual(resp.status, 403)
data = await resp.json()
self.assertFalse(data["success"])
self.assertNotIn(str(self.secret_dir), data["error"])
self.assertEqual(self.service.generate_calls, [])
async def test_traversal_path_is_forbidden(self):
self._make_image(self.secret_dir, "password.png")
traversal = os.path.join(str(self.output_dir), "..", "secret", "password.png")
resp = await self._post_json(
"/prompt_manager/autotag/single", {"path": traversal}
)
self.assertEqual(resp.status, 403)
self.assertEqual(self.service.generate_calls, [])
async def test_legacy_image_path_field_is_still_contained(self):
secret = self._make_image(self.secret_dir, "password.png")
resp = await self._post_json(
"/prompt_manager/autotag/single", {"image_path": str(secret)}
)
self.assertEqual(resp.status, 403)
self.assertEqual(self.service.generate_calls, [])
async def test_missing_selector_is_400(self):
resp = await self._post_json("/prompt_manager/autotag/single", {})
self.assertEqual(resp.status, 400)
self.assertFalse((await resp.json())["success"])
async def test_unknown_image_id_is_404(self):
resp = await self._post_json(
"/prompt_manager/autotag/single", {"image_id": 9999}
)
self.assertEqual(resp.status, 404)
self.assertFalse((await resp.json())["success"])
async def test_non_integer_image_id_is_400(self):
resp = await self._post_json(
"/prompt_manager/autotag/single", {"image_id": "abc"}
)
self.assertEqual(resp.status, 400)
class TestAutotagSideEffectRoutesArePostOnly(AutotagAPITestCase):
async def test_get_download_is_405(self):
resp = await self.client.request("GET", "/prompt_manager/autotag/download/gguf")
self.assertEqual(resp.status, 405)
self.assertEqual(self.service.download_calls, [])
async def test_get_start_is_405(self):
resp = await self.client.request("GET", "/prompt_manager/autotag/start")
self.assertEqual(resp.status, 405)
self.assertEqual(self.service.load_calls, [])
async def test_post_download_streams_completion(self):
resp = await self.client.request(
"POST", "/prompt_manager/autotag/download/gguf"
)
self.assertEqual(resp.status, 200)
events = self._sse_events(await resp.text())
self.assertEqual(events[-1]["type"], "complete")
self.assertEqual(self.service.download_calls, ["gguf"])
async def test_post_start_with_no_images_completes(self):
resp = await self.client.request(
"POST", "/prompt_manager/autotag/start?model_type=gguf"
)
self.assertEqual(resp.status, 200)
events = self._sse_events(await resp.text())
self.assertEqual(events[-1]["type"], "complete")
self.assertEqual(events[-1]["processed"], 0)
self.assertEqual(self.service.load_calls, [("gguf", True)])
async def test_models_status_stays_get(self):
resp = await self.client.request("GET", "/prompt_manager/autotag/models")
self.assertEqual(resp.status, 200)
self.assertTrue((await resp.json())["success"])
if __name__ == "__main__":
unittest.main()