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 json
import uuid import uuid
from copy import deepcopy from copy import deepcopy
from collections import defaultdict, deque from collections import OrderedDict, defaultdict, deque
from math import isfinite from math import isfinite
from threading import RLock from threading import RLock
from time import perf_counter from time import perf_counter
@@ -55,9 +55,12 @@ class NodeExecutionError(Exception):
class ExecutionEngine: class ExecutionEngine:
"""Synchronous (blocking) graph executor. Run inside a thread pool from async code.""" """Synchronous (blocking) graph executor. Run inside a thread pool from async code."""
NODE_CACHE_LIMIT = 256
def __init__(self) -> None: 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_lock = RLock()
self._cache_warning_emitted = False
def execute( def execute(
self, self,
@@ -154,6 +157,7 @@ class ExecutionEngine:
input_signature=input_signature, input_signature=input_signature,
output_signatures=output_signatures, output_signatures=output_signatures,
outputs=self._clone_cached_outputs(result), outputs=self._clone_cached_outputs(result),
on_warning=on_warning,
) )
# Auto-preview: broadcast a thumbnail for any DATA_FIELD, # Auto-preview: broadcast a thumbnail for any DATA_FIELD,
@@ -275,6 +279,8 @@ class ExecutionEngine:
return None return None
if entry.get("input_signature") != input_signature: if entry.get("input_signature") != input_signature:
return None return None
# Move to end for LRU ordering
self._node_cache.move_to_end(node_id)
return entry return entry
def _store_cache_entry( def _store_cache_entry(
@@ -285,14 +291,27 @@ class ExecutionEngine:
input_signature: str, input_signature: str,
output_signatures: tuple[str, ...], output_signatures: tuple[str, ...],
outputs: tuple, outputs: tuple,
on_warning: Callable[[str, str], None] | None = None,
) -> None: ) -> None:
with self._cache_lock: with self._cache_lock:
if node_id in self._node_cache:
self._node_cache.move_to_end(node_id)
self._node_cache[node_id] = { self._node_cache[node_id] = {
"class_name": class_name, "class_name": class_name,
"input_signature": input_signature, "input_signature": input_signature,
"output_signatures": output_signatures, "output_signatures": output_signatures,
"outputs": outputs, "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( def _build_input_signature(
self, self,

View File

@@ -32,8 +32,10 @@ import asyncio
import json import json
import logging import logging
import math import math
import re
import secrets import secrets
import sys import sys
import time
from collections import defaultdict from collections import defaultdict
from copy import deepcopy from copy import deepcopy
from pathlib import Path from pathlib import Path
@@ -141,6 +143,9 @@ def create_app(
session_engines: dict[str, ExecutionEngine] = {} session_engines: dict[str, ExecutionEngine] = {}
session_websockets: dict[str, set[web.WebSocketResponse]] = defaultdict(set) session_websockets: dict[str, set[web.WebSocketResponse]] = defaultdict(set)
pending_downloads: dict[str, Path] = {} 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: def _is_link(value) -> bool:
return ( return (
@@ -259,7 +264,12 @@ def create_app(
def on_file_download(session_id: str, node_id: str, file_path: str) -> None: def on_file_download(session_id: str, node_id: str, file_path: str) -> None:
token = secrets.token_urlsafe(16) token = secrets.token_urlsafe(16)
path = Path(file_path) 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 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}}) broadcast(session_id, {"type": "file_download", "data": {"node_id": node_id, "token": token, "filename": path.name}})
async def index(request: web.Request) -> web.Response: async def index(request: web.Request) -> web.Response:
@@ -469,9 +479,14 @@ def create_app(
content_type="application/json", 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: async def download_file(request: web.Request) -> web.Response:
body = await request.read() 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( return web.Response(
body=body, body=body,
content_type="application/octet-stream", content_type="application/octet-stream",
@@ -483,9 +498,10 @@ def create_app(
path = pending_downloads.pop(token, None) path = pending_downloads.pop(token, None)
if path is None or not path.is_file(): if path is None or not path.is_file():
raise web.HTTPNotFound(reason="File not found") raise web.HTTPNotFound(reason="File not found")
filename = _sanitize_filename(path.name, "download")
return web.FileResponse( return web.FileResponse(
path, 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: 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: async def submit_prompt(request: web.Request) -> web.Response:
session_id = require_session_id(request) 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() body = await request.json()
prompt = body.get("prompt") prompt = body.get("prompt")
if not isinstance(prompt, dict) or not prompt: if not isinstance(prompt, dict) or not prompt:
@@ -627,7 +652,7 @@ def create_app(
except Exception: except Exception:
return web.json_response({"current": current, "latest": None, "update_available": False}) 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["allow_local_filesystem"] = allow_local_filesystem
app.router.add_get("/", index) app.router.add_get("/", index)

View File

@@ -7,6 +7,7 @@
"name": "tono-frontend", "name": "tono-frontend",
"dependencies": { "dependencies": {
"@xyflow/react": "^12.0.0", "@xyflow/react": "^12.0.0",
"dompurify": "^3.3.3",
"html-to-image": "^1.11.13", "html-to-image": "^1.11.13",
"marked": "^17.0.5", "marked": "^17.0.5",
"react": "^18.3.0", "react": "^18.3.0",
@@ -15,6 +16,7 @@
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^9.39.4", "@eslint/js": "^9.39.4",
"@types/dompurify": "^3.0.5",
"@types/react": "^19.2.14", "@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3", "@types/react-dom": "^19.2.3",
"@types/three": "^0.183.1", "@types/three": "^0.183.1",
@@ -28,7 +30,7 @@
"vite": "^8.0.3" "vite": "^8.0.3"
}, },
"engines": { "engines": {
"node": ">=18.0.0", "node": ">=24.0.0",
"npm": ">=9.0.0" "npm": ">=9.0.0"
} }
}, },
@@ -787,6 +789,16 @@
"@types/d3-selection": "*" "@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": { "node_modules/@types/estree": {
"version": "1.0.8", "version": "1.0.8",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
@@ -851,6 +863,13 @@
"meshoptimizer": "~1.0.1" "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": { "node_modules/@types/webxr": {
"version": "0.5.24", "version": "0.5.24",
"resolved": "https://registry.npmjs.org/@types/webxr/-/webxr-0.5.24.tgz", "resolved": "https://registry.npmjs.org/@types/webxr/-/webxr-0.5.24.tgz",
@@ -1965,6 +1984,15 @@
"node": ">=0.10.0" "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": { "node_modules/dunder-proto": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",

View File

@@ -16,6 +16,7 @@
}, },
"dependencies": { "dependencies": {
"@xyflow/react": "^12.0.0", "@xyflow/react": "^12.0.0",
"dompurify": "^3.3.3",
"html-to-image": "^1.11.13", "html-to-image": "^1.11.13",
"marked": "^17.0.5", "marked": "^17.0.5",
"react": "^18.3.0", "react": "^18.3.0",
@@ -24,6 +25,7 @@
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^9.39.4", "@eslint/js": "^9.39.4",
"@types/dompurify": "^3.0.5",
"@types/react": "^19.2.14", "@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3", "@types/react-dom": "^19.2.3",
"@types/three": "^0.183.1", "@types/three": "^0.183.1",

View File

@@ -13,7 +13,7 @@ import HelpPanelManager from './HelpPanelManager';
import ContextMenu from './ContextMenu'; import ContextMenu from './ContextMenu';
import * as api from './api'; import * as api from './api';
import { pickNativeDirectorySelection, pickNativeFileSelection } from './nativePicker'; import { pickNativeDirectorySelection, pickNativeFileSelection } from './nativePicker';
import { embedWorkflow, extractWorkflow } from './pngMetadata'; import { embedWorkflow, extractWorkflow, sanitizeJson } from './pngMetadata';
import { captureViewportBlob as captureWorkflowViewportBlob } from './workflowCapture'; import { captureViewportBlob as captureWorkflowViewportBlob } from './workflowCapture';
import tonoIconUrl from '../../resources/icon_1024.png'; import tonoIconUrl from '../../resources/icon_1024.png';
import { hydrateWorkflowState } from './workflowHydration'; import { hydrateWorkflowState } from './workflowHydration';
@@ -1708,7 +1708,7 @@ function Flow() {
return; return;
} }
} else { } else {
data = JSON.parse(await file.text()); data = sanitizeJson(JSON.parse(await file.text()));
} }
await applyMaybePackedWorkflow(data); await applyMaybePackedWorkflow(data);
setStatus({ text: 'Workflow loaded.', level: 'info' }); setStatus({ text: 'Workflow loaded.', level: 'info' });

View File

@@ -1,6 +1,7 @@
import React, { useEffect, useMemo, useRef, useState } from 'react'; import React, { useEffect, useMemo, useRef, useState } from 'react';
import ReactDOM from 'react-dom'; import ReactDOM from 'react-dom';
import { marked } from 'marked'; import { marked } from 'marked';
import DOMPurify from 'dompurify';
// Open external links in new tabs // Open external links in new tabs
const renderer = new marked.Renderer(); const renderer = new marked.Renderer();
@@ -172,7 +173,7 @@ function HelpContent({ content, onOpenDoc }: HelpContentProps) {
const headings = useMemo(() => parseHeadings(md), [md]); const headings = useMemo(() => parseHeadings(md), [md]);
const html = useMemo(() => { const html = useMemo(() => {
let rendered: string; 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); return injectHeadingIds(rendered, headings);
}, [md, headings]); }, [md, headings]);
@@ -207,7 +208,7 @@ function JournalTab({ content, onChange, onOpenDoc }: JournalTabProps) {
let headings: Heading[] = []; let headings: Heading[] = [];
if (!isEditing && content?.trim()) { if (!isEditing && content?.trim()) {
headings = parseHeadings(content); 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 ( return (

View File

@@ -1,6 +1,7 @@
import React, { useContext, useRef, useState, useEffect, useCallback, useMemo } from 'react'; import React, { useContext, useRef, useState, useEffect, useCallback, useMemo } from 'react';
import { NodeResizeControl, useStore } from '@xyflow/react'; import { NodeResizeControl, useStore } from '@xyflow/react';
import { marked } from 'marked'; import { marked } from 'marked';
import DOMPurify from 'dompurify';
import { NodeContext } from './CustomNode'; import { NodeContext } from './CustomNode';
import type { NodeContextValue } from './types'; import type { NodeContextValue } from './types';
@@ -81,7 +82,7 @@ function TextNoteNode({ id, data }: TextNoteNodeProps) {
const renderedHtml = useMemo(() => { const renderedHtml = useMemo(() => {
if (!text.trim()) return ''; if (!text.trim()) return '';
return marked.parse(text); return DOMPurify.sanitize(marked.parse(text) as string);
}, [text]); }, [text]);
return ( return (

View File

@@ -90,7 +90,24 @@ function parseTextChunk(type: string, chunkData: Uint8Array) {
const translatedEnd = chunkData.indexOf(0, offset); const translatedEnd = chunkData.indexOf(0, offset);
if (translatedEnd === -1) return null; 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<string, unknown> = {};
for (const [k, v] of Object.entries(value as Record<string, unknown>)) {
if (!DANGEROUS_KEYS.has(k)) result[k] = sanitizeJson(v);
}
return result;
} }
// ── Public API ─────────────────────────────────────────────────────── // ── Public API ───────────────────────────────────────────────────────