security improvements
This commit is contained in:
@@ -25,7 +25,7 @@ import hashlib
|
||||
import json
|
||||
import uuid
|
||||
from copy import deepcopy
|
||||
from collections import defaultdict, deque
|
||||
from collections import OrderedDict, defaultdict, deque
|
||||
from math import isfinite
|
||||
from threading import RLock
|
||||
from time import perf_counter
|
||||
@@ -55,9 +55,12 @@ class NodeExecutionError(Exception):
|
||||
class ExecutionEngine:
|
||||
"""Synchronous (blocking) graph executor. Run inside a thread pool from async code."""
|
||||
|
||||
NODE_CACHE_LIMIT = 256
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._node_cache: dict[str, dict[str, Any]] = {}
|
||||
self._node_cache: OrderedDict[str, dict[str, Any]] = OrderedDict()
|
||||
self._cache_lock = RLock()
|
||||
self._cache_warning_emitted = False
|
||||
|
||||
def execute(
|
||||
self,
|
||||
@@ -154,6 +157,7 @@ class ExecutionEngine:
|
||||
input_signature=input_signature,
|
||||
output_signatures=output_signatures,
|
||||
outputs=self._clone_cached_outputs(result),
|
||||
on_warning=on_warning,
|
||||
)
|
||||
|
||||
# Auto-preview: broadcast a thumbnail for any DATA_FIELD,
|
||||
@@ -275,6 +279,8 @@ class ExecutionEngine:
|
||||
return None
|
||||
if entry.get("input_signature") != input_signature:
|
||||
return None
|
||||
# Move to end for LRU ordering
|
||||
self._node_cache.move_to_end(node_id)
|
||||
return entry
|
||||
|
||||
def _store_cache_entry(
|
||||
@@ -285,14 +291,27 @@ class ExecutionEngine:
|
||||
input_signature: str,
|
||||
output_signatures: tuple[str, ...],
|
||||
outputs: tuple,
|
||||
on_warning: Callable[[str, str], None] | None = None,
|
||||
) -> None:
|
||||
with self._cache_lock:
|
||||
if node_id in self._node_cache:
|
||||
self._node_cache.move_to_end(node_id)
|
||||
self._node_cache[node_id] = {
|
||||
"class_name": class_name,
|
||||
"input_signature": input_signature,
|
||||
"output_signatures": output_signatures,
|
||||
"outputs": outputs,
|
||||
}
|
||||
if len(self._node_cache) > self.NODE_CACHE_LIMIT:
|
||||
self._node_cache.popitem(last=False)
|
||||
if not self._cache_warning_emitted and on_warning is not None:
|
||||
self._cache_warning_emitted = True
|
||||
on_warning(
|
||||
node_id,
|
||||
f"Node cache exceeded {self.NODE_CACHE_LIMIT} entries — "
|
||||
"oldest cached results are being evicted. "
|
||||
"Very large workflows may re-compute nodes that would otherwise be cached.",
|
||||
)
|
||||
|
||||
def _build_input_signature(
|
||||
self,
|
||||
|
||||
@@ -32,8 +32,10 @@ import asyncio
|
||||
import json
|
||||
import logging
|
||||
import math
|
||||
import re
|
||||
import secrets
|
||||
import sys
|
||||
import time
|
||||
from collections import defaultdict
|
||||
from copy import deepcopy
|
||||
from pathlib import Path
|
||||
@@ -141,6 +143,9 @@ def create_app(
|
||||
session_engines: dict[str, ExecutionEngine] = {}
|
||||
session_websockets: dict[str, set[web.WebSocketResponse]] = defaultdict(set)
|
||||
pending_downloads: dict[str, Path] = {}
|
||||
_last_download_token: dict[str, str] = {} # session_id → token (limit one per session)
|
||||
_prompt_last_time: dict[str, float] = {} # session_id → monotonic timestamp
|
||||
PROMPT_MIN_INTERVAL = 0.5 # seconds between /prompt submissions per session
|
||||
|
||||
def _is_link(value) -> bool:
|
||||
return (
|
||||
@@ -259,7 +264,12 @@ def create_app(
|
||||
def on_file_download(session_id: str, node_id: str, file_path: str) -> None:
|
||||
token = secrets.token_urlsafe(16)
|
||||
path = Path(file_path)
|
||||
# Evict the previous pending download for this session (limit one).
|
||||
prev_token = _last_download_token.pop(session_id, None)
|
||||
if prev_token:
|
||||
pending_downloads.pop(prev_token, None)
|
||||
pending_downloads[token] = path
|
||||
_last_download_token[session_id] = token
|
||||
broadcast(session_id, {"type": "file_download", "data": {"node_id": node_id, "token": token, "filename": path.name}})
|
||||
|
||||
async def index(request: web.Request) -> web.Response:
|
||||
@@ -469,9 +479,14 @@ def create_app(
|
||||
content_type="application/json",
|
||||
)
|
||||
|
||||
def _sanitize_filename(name: str, fallback: str = "download") -> str:
|
||||
"""Strip path separators and control characters from a filename."""
|
||||
clean = re.sub(r'[/\\:\x00-\x1f\x7f"*?<>|]', '_', str(name).strip())
|
||||
return clean or fallback
|
||||
|
||||
async def download_file(request: web.Request) -> web.Response:
|
||||
body = await request.read()
|
||||
filename = request.query.get("filename", "workflow.png")
|
||||
filename = _sanitize_filename(request.query.get("filename", "workflow.png"), "workflow.png")
|
||||
return web.Response(
|
||||
body=body,
|
||||
content_type="application/octet-stream",
|
||||
@@ -483,9 +498,10 @@ def create_app(
|
||||
path = pending_downloads.pop(token, None)
|
||||
if path is None or not path.is_file():
|
||||
raise web.HTTPNotFound(reason="File not found")
|
||||
filename = _sanitize_filename(path.name, "download")
|
||||
return web.FileResponse(
|
||||
path,
|
||||
headers={"Content-Disposition": f'attachment; filename="{path.name}"'},
|
||||
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
|
||||
)
|
||||
|
||||
async def save_workflow_png(request: web.Request) -> web.Response:
|
||||
@@ -519,6 +535,15 @@ def create_app(
|
||||
|
||||
async def submit_prompt(request: web.Request) -> web.Response:
|
||||
session_id = require_session_id(request)
|
||||
|
||||
now = time.monotonic()
|
||||
last = _prompt_last_time.get(session_id, 0.0)
|
||||
if now - last < PROMPT_MIN_INTERVAL:
|
||||
raise web.HTTPTooManyRequests(
|
||||
reason="Please wait before submitting another prompt",
|
||||
)
|
||||
_prompt_last_time[session_id] = now
|
||||
|
||||
body = await request.json()
|
||||
prompt = body.get("prompt")
|
||||
if not isinstance(prompt, dict) or not prompt:
|
||||
@@ -627,7 +652,7 @@ def create_app(
|
||||
except Exception:
|
||||
return web.json_response({"current": current, "latest": None, "update_available": False})
|
||||
|
||||
app = web.Application()
|
||||
app = web.Application(client_max_size=100 * 1024 * 1024) # 100 MB upload cap
|
||||
app["allow_local_filesystem"] = allow_local_filesystem
|
||||
|
||||
app.router.add_get("/", index)
|
||||
|
||||
Reference in New Issue
Block a user