From 7ecc225f8cabfbf0cd5ef509b2cc73f5f736dfcf Mon Sep 17 00:00:00 2001 From: matei jordache Date: Fri, 3 Apr 2026 18:57:57 -0700 Subject: [PATCH] security improvements --- backend/execution.py | 23 +++++++++++++++++++++-- backend/server.py | 31 ++++++++++++++++++++++++++++--- frontend/package-lock.json | 30 +++++++++++++++++++++++++++++- frontend/package.json | 2 ++ frontend/src/App.tsx | 4 ++-- frontend/src/HelpPanelManager.tsx | 5 +++-- frontend/src/TextNoteNode.tsx | 3 ++- frontend/src/pngMetadata.ts | 19 ++++++++++++++++++- 8 files changed, 105 insertions(+), 12 deletions(-) diff --git a/backend/execution.py b/backend/execution.py index b9bdef1..6d41bff 100644 --- a/backend/execution.py +++ b/backend/execution.py @@ -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, diff --git a/backend/server.py b/backend/server.py index 8c2f909..a4a857f 100644 --- a/backend/server.py +++ b/backend/server.py @@ -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) diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 515eb0c..6633e17 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -7,6 +7,7 @@ "name": "tono-frontend", "dependencies": { "@xyflow/react": "^12.0.0", + "dompurify": "^3.3.3", "html-to-image": "^1.11.13", "marked": "^17.0.5", "react": "^18.3.0", @@ -15,6 +16,7 @@ }, "devDependencies": { "@eslint/js": "^9.39.4", + "@types/dompurify": "^3.0.5", "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", "@types/three": "^0.183.1", @@ -28,7 +30,7 @@ "vite": "^8.0.3" }, "engines": { - "node": ">=18.0.0", + "node": ">=24.0.0", "npm": ">=9.0.0" } }, @@ -787,6 +789,16 @@ "@types/d3-selection": "*" } }, + "node_modules/@types/dompurify": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@types/dompurify/-/dompurify-3.0.5.tgz", + "integrity": "sha512-1Wg0g3BtQF7sSb27fJQAKck1HECM6zV1EB66j8JH9i3LCjYabJa0FSdiSgsD5K/RbrsR0SiraKacLB+T8ZVYAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/trusted-types": "*" + } + }, "node_modules/@types/estree": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", @@ -851,6 +863,13 @@ "meshoptimizer": "~1.0.1" } }, + "node_modules/@types/trusted-types": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", + "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", + "devOptional": true, + "license": "MIT" + }, "node_modules/@types/webxr": { "version": "0.5.24", "resolved": "https://registry.npmjs.org/@types/webxr/-/webxr-0.5.24.tgz", @@ -1965,6 +1984,15 @@ "node": ">=0.10.0" } }, + "node_modules/dompurify": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.3.3.tgz", + "integrity": "sha512-Oj6pzI2+RqBfFG+qOaOLbFXLQ90ARpcGG6UePL82bJLtdsa6CYJD7nmiU8MW9nQNOtCHV3lZ/Bzq1X0QYbBZCA==", + "license": "(MPL-2.0 OR Apache-2.0)", + "optionalDependencies": { + "@types/trusted-types": "^2.0.7" + } + }, "node_modules/dunder-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", diff --git a/frontend/package.json b/frontend/package.json index 2aa84e2..d17bc7d 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -16,6 +16,7 @@ }, "dependencies": { "@xyflow/react": "^12.0.0", + "dompurify": "^3.3.3", "html-to-image": "^1.11.13", "marked": "^17.0.5", "react": "^18.3.0", @@ -24,6 +25,7 @@ }, "devDependencies": { "@eslint/js": "^9.39.4", + "@types/dompurify": "^3.0.5", "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", "@types/three": "^0.183.1", diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index a45cf59..0302b62 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -13,7 +13,7 @@ import HelpPanelManager from './HelpPanelManager'; import ContextMenu from './ContextMenu'; import * as api from './api'; import { pickNativeDirectorySelection, pickNativeFileSelection } from './nativePicker'; -import { embedWorkflow, extractWorkflow } from './pngMetadata'; +import { embedWorkflow, extractWorkflow, sanitizeJson } from './pngMetadata'; import { captureViewportBlob as captureWorkflowViewportBlob } from './workflowCapture'; import tonoIconUrl from '../../resources/icon_1024.png'; import { hydrateWorkflowState } from './workflowHydration'; @@ -1708,7 +1708,7 @@ function Flow() { return; } } else { - data = JSON.parse(await file.text()); + data = sanitizeJson(JSON.parse(await file.text())); } await applyMaybePackedWorkflow(data); setStatus({ text: 'Workflow loaded.', level: 'info' }); diff --git a/frontend/src/HelpPanelManager.tsx b/frontend/src/HelpPanelManager.tsx index f8fe29e..c685f99 100644 --- a/frontend/src/HelpPanelManager.tsx +++ b/frontend/src/HelpPanelManager.tsx @@ -1,6 +1,7 @@ import React, { useEffect, useMemo, useRef, useState } from 'react'; import ReactDOM from 'react-dom'; import { marked } from 'marked'; +import DOMPurify from 'dompurify'; // Open external links in new tabs const renderer = new marked.Renderer(); @@ -172,7 +173,7 @@ function HelpContent({ content, onOpenDoc }: HelpContentProps) { const headings = useMemo(() => parseHeadings(md), [md]); const html = useMemo(() => { let rendered: string; - try { rendered = marked.parse(md) as string; } catch { rendered = md; } + try { rendered = DOMPurify.sanitize(marked.parse(md) as string); } catch { rendered = md; } return injectHeadingIds(rendered, headings); }, [md, headings]); @@ -207,7 +208,7 @@ function JournalTab({ content, onChange, onOpenDoc }: JournalTabProps) { let headings: Heading[] = []; if (!isEditing && content?.trim()) { headings = parseHeadings(content); - try { renderedHtml = injectHeadingIds(marked.parse(content) as string, headings); } catch { renderedHtml = content; } + try { renderedHtml = injectHeadingIds(DOMPurify.sanitize(marked.parse(content) as string), headings); } catch { renderedHtml = content; } } return ( diff --git a/frontend/src/TextNoteNode.tsx b/frontend/src/TextNoteNode.tsx index 7bfbd8e..cfdc2ba 100644 --- a/frontend/src/TextNoteNode.tsx +++ b/frontend/src/TextNoteNode.tsx @@ -1,6 +1,7 @@ import React, { useContext, useRef, useState, useEffect, useCallback, useMemo } from 'react'; import { NodeResizeControl, useStore } from '@xyflow/react'; import { marked } from 'marked'; +import DOMPurify from 'dompurify'; import { NodeContext } from './CustomNode'; import type { NodeContextValue } from './types'; @@ -81,7 +82,7 @@ function TextNoteNode({ id, data }: TextNoteNodeProps) { const renderedHtml = useMemo(() => { if (!text.trim()) return ''; - return marked.parse(text); + return DOMPurify.sanitize(marked.parse(text) as string); }, [text]); return ( diff --git a/frontend/src/pngMetadata.ts b/frontend/src/pngMetadata.ts index 7efd6a1..e51c26c 100644 --- a/frontend/src/pngMetadata.ts +++ b/frontend/src/pngMetadata.ts @@ -90,7 +90,24 @@ function parseTextChunk(type: string, chunkData: Uint8Array) { const translatedEnd = chunkData.indexOf(0, offset); if (translatedEnd === -1) return null; - return JSON.parse(decoder.decode(chunkData.subarray(translatedEnd + 1))); + return sanitizeJson(JSON.parse(decoder.decode(chunkData.subarray(translatedEnd + 1)))); +} + +// ── JSON sanitisation ──────────────────────────────────────────────── + +const DANGEROUS_KEYS = new Set(['__proto__', 'constructor', 'prototype']); + +/** + * Recursively strip keys that could cause prototype pollution. + */ +export function sanitizeJson(value: unknown): unknown { + if (value === null || typeof value !== 'object') return value; + if (Array.isArray(value)) return value.map(sanitizeJson); + const result: Record = {}; + for (const [k, v] of Object.entries(value as Record)) { + if (!DANGEROUS_KEYS.has(k)) result[k] = sanitizeJson(v); + } + return result; } // ── Public API ───────────────────────────────────────────────────────