security improvements
This commit is contained in:
@@ -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,
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
30
frontend/package-lock.json
generated
30
frontend/package-lock.json
generated
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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' });
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
|||||||
@@ -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 ───────────────────────────────────────────────────────
|
||||||
|
|||||||
Reference in New Issue
Block a user