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

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