Files
brantje-ComfyUI-api-tools/api_server.py
Sander 15aed8c3bb Implement model path normalization and enhance security checks in API endpoints
Added a new helper function to normalize user-provided model paths, ensuring consistent handling of path separators and rejecting invalid paths. Updated the `remove_model` and `download_model` functions to utilize this normalization, improving security by validating paths against allowed directories and preventing directory traversal attacks. This enhances the robustness of file handling in the API.
2026-01-08 21:23:15 +01:00

334 lines
12 KiB
Python

import logging
import os
import traceback
from urllib.parse import urlparse
import mimetypes
from aiohttp.web import FileResponse, Request, json_response
from server import PromptServer
import folder_paths
from .model_utils.refresh import refresh_folder
from .metrics.prometheus import get_metrics
from .model_utils.install import install_model_url
routes = PromptServer.instance.routes
def _normalize_rel_model_path(p: str) -> str:
"""
Normalize a user-provided relative model path for comparison.
- Accept both '/' and '\\' as separators (Windows clients often send '\\').
- Reject absolute paths, traversal, and suspicious segments.
"""
if not isinstance(p, str):
raise ValueError("invalid model path")
if p.startswith(("/", "\\")):
raise ValueError("invalid model path")
# Normalize separators to posix for consistent matching.
p2 = p.replace("\\", "/")
# Reject drive letters / URI-like / ADS-like patterns.
if ":" in p2:
raise ValueError("invalid model path")
parts = p2.split("/")
if any(part in ("", ".", "..") for part in parts):
raise ValueError("invalid model path")
return "/".join(parts)
def success_resp(**kwargs):
return json_response({"code": 200, "message": "success", **kwargs})
def error_resp(code, message, **kwargs):
return json_response({"code": code, "message": message, **kwargs})
@routes.get("/api-tools/v1/models")
async def get_model_folders(request: Request):
""" List all model folders """
folders = list(folder_paths.folder_names_and_paths.keys())
return success_resp(result={"folders": folders })
@routes.post("/api-tools/v1/models/install")
async def install_model(request: Request):
"""Install a model from url, filtered with a allow list """
json_data = await request.json()
url = json_data.get('url')
allowed_domains = ["civitai.com", "github.com", "raw.githubusercontent.com", "huggingface.co"]
if url:
try:
parsed_url = urlparse(url)
domain = parsed_url.netloc
if domain.startswith('www.'):
domain = domain[4:]
# Check if the domain is in the allowed list
if domain not in allowed_domains:
return error_resp(403, f"Domain '{domain}' is not in the allowed domains list")
except Exception as e:
return error_resp(400, f"Invalid URL format: {str(e)}")
else:
return error_resp(400, "URL is required")
success, message = install_model_url(json_data)
if not success:
return error_resp(409, message)
return success_resp()
@routes.get("/api-tools/v1/models/{folder}")
async def get_model_folder(request: Request):
""" List all models in a folder """
folder = request.match_info['folder']
try:
checkpoints = folder_paths.get_filename_list(folder)
result = [
{
"filename": os.path.basename(ckpt),
"path": ckpt,
"full_path": folder_paths.get_full_path(folder, ckpt) or ckpt,
}
for ckpt in checkpoints
]
return success_resp(result=result)
except Exception as e:
return error_resp(500, str('Path not found'))
@routes.post("/api-tools/v1/models/{folder}/refresh")
async def refresh_checkpoints(request: Request):
"""Refresh the checkpoints list and return the updated list(maybe useful for models that stored in a network storage)"""
folder = request.match_info['folder']
try:
data = refresh_folder(folder)
return success_resp(data=data)
except Exception as e:
return error_resp(500, str(e))
@routes.delete("/api-tools/v1/models/{folder}/{model:.+}")
async def remove_model(request: Request):
"""Removes a model from the file system """
input_folder = request.match_info['folder']
model_input = request.match_info['model']
try:
# Basic traversal checks
try:
normalized_model_input = _normalize_rel_model_path(model_input)
except ValueError as e:
return error_resp(400, str(e))
# Get the base folder path for this model type
folder_paths_list = folder_paths.folder_names_and_paths.get(input_folder)
if not folder_paths_list:
return error_resp(404, f"Folder '{input_folder}' not found")
base_folder_path = folder_paths_list[0][0] # First path in the list
# Walk through the folder tree to find the matching file
matched_full_path = None
for root, dirs, files in os.walk(base_folder_path):
for file in files:
file_path = os.path.join(root, file)
# Get the relative path from the base folder
rel_path = os.path.relpath(file_path, base_folder_path)
# Normalize separators for comparison
rel_path_normalized = rel_path.replace("\\", "/")
# Check if this file matches the requested model path
if (rel_path == model_input or
rel_path == normalized_model_input or
rel_path_normalized == normalized_model_input):
matched_full_path = file_path
break
if matched_full_path:
break
if matched_full_path is None:
return error_resp(404, 'Model not found')
# Additional security check: ensure the matched path is still within the base folder
if not os.path.commonpath([os.path.abspath(matched_full_path), os.path.abspath(base_folder_path)]) == os.path.abspath(base_folder_path):
return error_resp(403, "Access denied")
if not os.path.isfile(matched_full_path):
return error_resp(404, 'Not Found')
os.remove(matched_full_path)
return success_resp(result=matched_full_path)
except Exception as e:
return error_resp(500, str(e))
@routes.get("/api-tools/v1/models/{folder}/{model:.+}/download")
async def download_model(request: Request):
"""Download a model file from the file system."""
input_folder = request.match_info.get("folder")
model_input = request.match_info.get("model")
if not input_folder:
return error_resp(400, "folder is required")
if not model_input:
return error_resp(400, "model is required")
# Basic traversal checks; final enforcement is done by validating the path is present in ComfyUI's model index.
try:
normalized_model_input = _normalize_rel_model_path(model_input)
except ValueError as e:
return error_resp(400, str(e))
try:
folder_paths_list = folder_paths.folder_names_and_paths.get(input_folder)
if not folder_paths_list:
return error_resp(404, f"Folder '{input_folder}' not found")
base_folder_path = folder_paths_list[0][0] # First path in the list
# Walk through the folder tree to find the matching file
# This handles subdirectories properly for both Windows and Linux
matched_full_path = None
for root, dirs, files in os.walk(base_folder_path):
for file in files:
file_path = os.path.join(root, file)
# Get the relative path from the base folder
rel_path = os.path.relpath(file_path, base_folder_path)
# Normalize separators for comparison
rel_path_normalized = rel_path.replace("\\", "/")
# Check if this file matches the requested model path
if (rel_path == model_input or
rel_path == normalized_model_input or
rel_path_normalized == normalized_model_input):
matched_full_path = file_path
break
if matched_full_path:
break
if matched_full_path is None:
return error_resp(404, "Model not found")
# Additional security check: ensure the matched path is still within the base folder
if not os.path.commonpath([os.path.abspath(matched_full_path), os.path.abspath(base_folder_path)]) == os.path.abspath(base_folder_path):
return error_resp(403, "Access denied")
if not os.path.isfile(matched_full_path):
return error_resp(404, "Not Found")
content_type, _ = mimetypes.guess_type(matched_full_path)
resp = FileResponse(path=matched_full_path)
resp.headers["Content-Disposition"] = f'attachment; filename="{os.path.basename(matched_full_path)}"'
if content_type:
resp.content_type = content_type
return resp
except Exception as e:
logging.error("Error downloading model", exc_info=True)
return error_resp(500, str(e))
@routes.get("/api-tools/v1/images/output")
async def get_output_images(request: Request):
try:
is_temp = request.rel_url.query.get("temp", "false") == "true"
folder = (
folder_paths.get_temp_directory()
if is_temp
else folder_paths.get_output_directory()
)
# iterate through the folder and get the list of images
images = []
for root, dirs, files in os.walk(folder):
for file in files:
if file.endswith(".png") or file.endswith(".jpg"):
image = {"name": file, "full_path": os.path.join(root, file)}
images.append(image)
return success_resp(images=images)
except Exception as e:
return error_resp(500, str(e))
@routes.delete("/api-tools/v1/images/output/{filename}")
async def delete_output_images(request: Request):
try:
filename = request.match_info.get("filename")
if filename is None:
return error_resp(400, "filename is required")
if filename[0] == "/" or ".." in filename:
return error_resp(400, "invalid filename")
is_temp = request.rel_url.query.get("temp", "false") == "true"
annotated_file = f"{filename} [{'temp' if is_temp else 'output'}]"
if not folder_paths.exists_annotated_filepath(annotated_file):
return error_resp(404, f"file {filename} not found")
filepath = folder_paths.get_annotated_filepath(annotated_file)
os.remove(filepath)
return success_resp()
except Exception as e:
return error_resp(500, str(e))
@routes.get("/api-tools/v1/images/input")
async def get_input_images(request: Request):
try:
folder = folder_paths.get_input_directory()
# iterate through the folder and get the list of images
images = []
for root, dirs, files in os.walk(folder):
for file in files:
if file.endswith(".png") or file.endswith(".jpg"):
image = {"name": file, "full_path": os.path.join(root, file)}
images.append(image)
return success_resp(images=images)
except Exception as e:
return error_resp(500, str(e))
@routes.delete("/api-tools/v1/images/input/{filename}")
async def delete_input_images(request: Request):
try:
filename = request.match_info.get("filename")
if filename is None:
return error_resp(400, "filename is required")
if filename[0] == '/' or '..' in filename:
return error_resp(400, "invalid filename")
is_temp = request.rel_url.query.get("temp", "false") == "true"
annotated_file = f"{filename} [{'temp' if is_temp else 'input'}]"
if not folder_paths.exists_annotated_filepath(annotated_file):
return error_resp(404, f"file {filename} not found")
filepath = folder_paths.get_annotated_filepath(annotated_file)
os.remove(filepath)
return success_resp()
except Exception as e:
return error_resp(500, str(e))
@routes.get("/api-tools/v1/metrics")
async def get_prometheus_metrics(request: Request):
"""Return Prometheus metrics"""
try:
metrics_text = get_metrics()
return json_response(text=metrics_text, content_type="text/plain")
except Exception as e:
logging.error(f"Error generating metrics: {str(e)}")
return error_resp(500, f"Error generating metrics: {str(e)}")
def run_comfyui_api_tools():
print("extra API server started")