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)

View File

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

View File

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

View File

@@ -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' });

View File

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

View File

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

View File

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