security improvements

This commit is contained in:
2026-04-03 18:57:57 -07:00
parent c8d766677b
commit 7ecc225f8c
8 changed files with 105 additions and 12 deletions

View File

@@ -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,

View File

@@ -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)