Fix: security errors (#648)

* refactor: outdated usage

* fix: use JSON.stringify to prevent '`' break the string to execute any script

* fix: use json5 parse LLM tool calling

* fix  Potential file system race condition

* fix  Indirect uncontrolled command line

* Update callImageGenerationAPI.ts

* While the shell path is now validated, arguments_.join(' ') could still contain shell metacharacters that enable command injection. The arguments array should be validated or passed in a way that prevents shell interpretation, such as using the array form of execSync instead of string interpolation.

* directly Remove TiddlyWiki special characters that could cause parsing issues

* prevent race condition

* fix: subscribe to latest pauseNotification

* Return a lightweight mock notification object to avoid creating real browser notifications

* Update responsePatternUtility.ts
This commit is contained in:
lin onetwo 2025-10-24 22:16:03 +08:00 committed by GitHub
parent 69cc703b18
commit ea78df0ab3
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 797 additions and 126 deletions

View file

@ -0,0 +1,253 @@
import { describe, expect, it } from 'vitest';
/**
* Security tests for injection prevention
* These tests verify that the fixes for the RCE vulnerability are effective
*/
describe('Injection Prevention', () => {
describe('JSON.stringify escaping', () => {
it('should wrap input in quotes, making backticks safe', () => {
const maliciousInput = '`+alert(1)+`';
const escaped = JSON.stringify(maliciousInput);
// JSON.stringify returns a quoted string: "`+alert(1)+`"
// When used in code like: let x = ${escaped};
// It becomes: let x = "`+alert(1)+`";
// The backticks are inside quotes, so they're treated as regular characters
expect(escaped).toBe('"`+alert(1)+`"');
expect(escaped.startsWith('"')).toBe(true);
expect(escaped.endsWith('"')).toBe(true);
});
it('should escape template literal syntax safely', () => {
const maliciousInput = '${process.binding("spawn_sync")}';
const escaped = JSON.stringify(maliciousInput);
expect(escaped).toBe('"${process.binding(\\"spawn_sync\\")}"');
// Verify it's wrapped in quotes and inner quotes are escaped
expect(escaped.startsWith('"')).toBe(true);
expect(escaped.endsWith('"')).toBe(true);
});
it('should handle newlines safely', () => {
const maliciousInput = '`\nalert(1)\n`';
const escaped = JSON.stringify(maliciousInput);
// Newlines are escaped as \n in the JSON string
expect(escaped).toContain('\\n');
expect(escaped).toBe('"`\\nalert(1)\\n`"');
});
it('should escape quotes', () => {
const maliciousInput = '"; alert(1); "';
const escaped = JSON.stringify(maliciousInput);
// JSON.stringify escapes the quotes
expect(escaped).toContain('\\"');
expect(escaped.startsWith('"')).toBe(true);
expect(escaped.endsWith('"')).toBe(true);
});
it('should preserve CJK characters', () => {
const cjkInput = '新条目';
const escaped = JSON.stringify(cjkInput);
expect(escaped).toBe('"新条目"');
});
it('should preserve Unicode characters', () => {
const unicodeInput = 'Tëst Ñame 日本語 한글';
const escaped = JSON.stringify(unicodeInput);
expect(escaped).toContain('Tëst');
expect(escaped).toContain('日本語');
expect(escaped).toContain('한글');
});
});
describe('Script generation safety', () => {
it('should prevent template literal breakout in openTiddler', () => {
const maliciousName = '`+void window.service.wiki.wikiOperationInServer()+`';
// Simulate the fixed version
const script = `
let trimmedTiddlerName = ${JSON.stringify(maliciousName)};
let currentHandlerWidget = $tw.rootWidget;
`;
// The malicious code should be trapped inside a quoted string
// It becomes: let trimmedTiddlerName = "`+void...+`";
// The backticks are inside quotes, so they're just characters
expect(script).toContain('"`+void window.service');
expect(script).not.toContain('void window.service.wiki.wikiOperationInServer()+`;\n');
});
it('should prevent injection in setTiddlerText', () => {
const maliciousTitle = 'dummy`),process.binding("spawn_sync"),console.log(1);//';
const maliciousValue = 'New content';
// Simulate the fixed version
const script = `return $tw.wiki.setText(${JSON.stringify(maliciousTitle)}, 'text', undefined, ${JSON.stringify(maliciousValue)});`;
// The script should have quoted strings, not executable code
expect(script).toContain('"dummy`),process.binding');
expect(script).toContain('"New content"');
});
it('should prevent injection in renderWikiText', () => {
const maliciousContent = '`+alert(1)+`';
// Simulate the fixed version
const script = `return $tw.wiki.renderText("text/html", "text/vnd.tiddlywiki", ${JSON.stringify(maliciousContent)});`;
// alert(1) should be inside a quoted string
expect(script).toContain('"`+alert(1)+`"');
});
});
describe('Real-world attack vectors', () => {
it('should block the reported PoC payload', () => {
const pocPayload =
"dummy`),process.binding('spawn_sync').spawn({file:'/usr/bin/open',args:['/usr/bin/open','/System/Applications/Calculator.app/Contents/MacOS/Calculator'],stdio:[{type:'pipe',readable:!0},{type:'pipe',writable:!0},{type:'pipe',writable:!0}]}),console.log(1);//";
const escaped = JSON.stringify(pocPayload);
// Should be wrapped in quotes, making the backticks safe
expect(escaped.startsWith('"')).toBe(true);
expect(escaped.endsWith('"')).toBe(true);
// The payload is now a string literal, not executable code
expect(escaped).toContain('"dummy`),process.binding');
});
it('should block XSS in renderer via openTiddler', () => {
const xssPayload = "`+alert('XSS')+`";
const script = `let trimmedTiddlerName = ${JSON.stringify(xssPayload)};`;
// alert('XSS') should be inside a quoted string
expect(script).toContain('"`+alert(\'XSS\')+`"');
});
it('should block various injection techniques', () => {
const payloads = [
'`+alert(1)+`',
'${alert(1)}',
'\\`+alert(1)+\\`',
"'; alert(1); '",
'"); alert(1); ("',
'`; alert(1); `',
'`\\nalert(1)\\n`',
];
for (const payload of payloads) {
const escaped = JSON.stringify(payload);
// All should be wrapped in quotes, making them safe string literals
expect(escaped.startsWith('"')).toBe(true);
expect(escaped.endsWith('"')).toBe(true);
}
});
});
describe('Deep link sanitization', () => {
/**
* Simulates the sanitizeTiddlerName method from DeepLinkService
*/
function sanitizeTiddlerName(tiddlerName: string): string {
let sanitized = tiddlerName;
// Remove backticks and template literal syntax
sanitized = sanitized.replace(/[`${}]/g, '');
// Remove HTML tags and script tags
sanitized = sanitized.replace(/<\/?[^>]+(>|$)/g, '');
// Remove newlines and other control characters
sanitized = sanitized.replace(/[\r\n\t]/g, ' ');
// Remove null bytes
sanitized = sanitized.replace(/\0/g, '');
// Trim whitespace
sanitized = sanitized.trim();
// Limit length to prevent DoS
if (sanitized.length > 1000) {
sanitized = sanitized.substring(0, 1000);
}
return sanitized;
}
it('should remove backticks', () => {
const input = '`malicious`';
const sanitized = sanitizeTiddlerName(input);
expect(sanitized).toBe('malicious');
expect(sanitized).not.toContain('`');
});
it('should remove template literal syntax', () => {
const input = '${process.binding()}';
const sanitized = sanitizeTiddlerName(input);
expect(sanitized).toBe('process.binding()');
expect(sanitized).not.toContain('$');
expect(sanitized).not.toContain('{');
expect(sanitized).not.toContain('}');
});
it('should remove HTML tags', () => {
const input = '<script>alert(1)</script>';
const sanitized = sanitizeTiddlerName(input);
expect(sanitized).toBe('alert(1)');
expect(sanitized).not.toContain('<script>');
});
it('should remove control characters', () => {
const input = 'line1\nline2\ttab';
const sanitized = sanitizeTiddlerName(input);
// Control characters should be replaced with spaces
expect(sanitized).toBe('line1 line2 tab');
});
it('should preserve normal tiddler names', () => {
const normalNames = [
'MyTiddler',
'My Tiddler',
'My-Tiddler',
'My_Tiddler',
'Tiddler 123',
];
for (const name of normalNames) {
const sanitized = sanitizeTiddlerName(name);
expect(sanitized).toBe(name);
}
});
it('should preserve CJK characters', () => {
const cjkNames = [
'新条目',
'日本語タイトル',
'한글 제목',
'中文标题',
];
for (const name of cjkNames) {
const sanitized = sanitizeTiddlerName(name);
expect(sanitized).toBe(name);
}
});
it('should limit length to prevent DoS', () => {
const longInput = 'a'.repeat(2000);
const sanitized = sanitizeTiddlerName(longInput);
expect(sanitized.length).toBe(1000);
});
it('should block the PoC attack', () => {
const pocInput = '`+void window.service.wiki.wikiOperationInServer()';
const sanitized = sanitizeTiddlerName(pocInput);
// Backticks, dollar signs, and braces should be removed
expect(sanitized).not.toContain('`');
expect(sanitized).not.toContain('$');
expect(sanitized).not.toContain('{');
expect(sanitized).not.toContain('}');
// But the text content remains (though harmless)
expect(sanitized).toBe('+void window.service.wiki.wikiOperationInServer()');
});
});
});

View file

@ -1,10 +1,8 @@
import { Channels, WorkspaceChannel } from '@/constants/channels';
import { webFrame } from 'electron';
import '../services/wiki/wikiOperations/executor/wikiOperationInBrowser';
import type { IPossibleWindowMeta, WindowMeta } from '@services/windows/WindowProperties';
import { WindowNames } from '@services/windows/WindowProperties';
import { browserViewMetaData, windowName } from './common/browserViewMetaData';
import { menu, preference, workspace, workspaceView } from './common/services';
let handled = false;
const handleLoaded = (event: string): void => {
@ -20,11 +18,6 @@ const handleLoaded = (event: string): void => {
};
async function executeJavaScriptInBrowserView(): Promise<void> {
// Fix Can't show file list of Google Drive
// https://github.com/electron/electron/issues/16587
// Fix chrome.runtime.sendMessage is undefined for FastMail
// https://github.com/atomery/singlebox/issues/21
const initialShouldPauseNotifications = await preference.get('pauseNotifications');
const { workspaceID } = browserViewMetaData as IPossibleWindowMeta<WindowMeta[WindowNames.view]>;
try {
@ -32,22 +25,55 @@ async function executeJavaScriptInBrowserView(): Promise<void> {
(function() {
// Customize Notification behavior
// https://stackoverflow.com/questions/53390156/how-to-override-javascript-web-api-notification-object
// TODO: fix logic here, get latest pauseNotifications from preference, and focusWorkspace
const oldNotification = window.Notification;
// Cache pause status and keep it in sync with preferences observable
let pauseNotifications = false;
window.observables?.preference?.preference$?.subscribe?.((preferences) => {
pauseNotifications = preferences?.pauseNotifications || false;
});
let shouldPauseNotifications = ${
typeof initialShouldPauseNotifications === 'string' && initialShouldPauseNotifications.length > 0 ? `"${initialShouldPauseNotifications}"` : 'undefined'
};
window.Notification = function() {
if (!shouldPauseNotifications) {
const notification = new oldNotification(...arguments);
notification.addEventListener('click', () => {
window.postMessage({ type: '${WorkspaceChannel.focusWorkspace}', workspaceID: "${workspaceID ?? '-'}" });
});
return notification;
// Use modern rest parameters instead of arguments for better performance and clarity
window.Notification = function(...args) {
// Check cached value first to avoid notification flash
if (pauseNotifications) {
// Return a lightweight mock notification object to avoid creating real browser notifications
// This mock implements the minimal Notification API that calling code might expect
return {
close: () => {},
addEventListener: () => {},
removeEventListener: () => {},
dispatchEvent: () => true,
// Common Notification properties that might be accessed
title: args[0] || '',
body: (args[1] && args[1].body) || '',
tag: 'paused',
// Prevent any actual notification behavior
onclick: null,
onclose: null,
onerror: null,
onshow: null
};
}
return null;
// Create and show notification
const notification = new oldNotification(...args);
// Add click handler to focus workspace
notification.addEventListener('click', async () => {
const workspaceID = ${JSON.stringify(workspaceID ?? '-')};
try {
const targetWorkspace = await window.service.workspace.get(workspaceID);
if (targetWorkspace !== undefined) {
await window.service.workspaceView.setActiveWorkspaceView(workspaceID);
await window.service.menu.buildMenu();
}
} catch (error) {
console.error('Failed to handle notification click:', error);
}
});
return notification;
}
window.Notification.requestPermission = oldNotification.requestPermission;
Object.defineProperty(Notification, 'permission', {
@ -73,14 +99,4 @@ if (windowName === WindowNames.view) {
window.addEventListener('load', () => {
handleLoaded('window.on("onload")');
});
window.addEventListener('message', async (event?: MessageEvent<{ type?: Channels; workspaceID?: string } | undefined>) => {
// set workspace to active when its notification is clicked
if (event?.data?.type === WorkspaceChannel.focusWorkspace) {
const id = event.data.workspaceID;
if (id !== undefined && (await workspace.get(id)) !== undefined) {
await workspaceView.setActiveWorkspaceView(id);
await menu.buildMenu();
}
}
});
}

View file

@ -0,0 +1,290 @@
import { describe, expect, it } from 'vitest';
import { matchToolCalling } from '../responsePatternUtility';
/**
* Security tests for agent response parsing
* These tests verify that malicious AI responses cannot execute arbitrary code
*/
describe('Agent Response Parsing Security', () => {
describe('Dangerous pattern detection', () => {
it('should reject parameters with require() calls', () => {
const responseText = `
<tool_use name="malicious-tool">
{
test: require('child_process').execSync('whoami')
}
</tool_use>
`;
const result = matchToolCalling(responseText);
expect(result.found).toBe(true);
// The dangerous code should not be executed, fallback to string
expect(result.parameters).toEqual({
input: expect.stringContaining('require'),
});
});
it('should reject parameters with process.binding', () => {
const responseText = `
<tool_use name="malicious-tool">
{
exploit: process.binding('spawn_sync').spawn({file:'/usr/bin/whoami'})
}
</tool_use>
`;
const result = matchToolCalling(responseText);
expect(result.found).toBe(true);
expect(result.parameters).toEqual({
input: expect.stringContaining('process.binding'),
});
});
it('should reject parameters with eval()', () => {
const responseText = `
<tool_use name="malicious-tool">
{
code: eval('malicious code here')
}
</tool_use>
`;
const result = matchToolCalling(responseText);
expect(result.found).toBe(true);
expect(result.parameters).toEqual({
input: expect.stringContaining('eval'),
});
});
it('should reject parameters with Function constructor', () => {
const responseText = `
<tool_use name="malicious-tool">
{
fn: new Function('return process')()
}
</tool_use>
`;
const result = matchToolCalling(responseText);
expect(result.found).toBe(true);
expect(result.parameters).toEqual({
input: expect.stringContaining('Function'),
});
});
it('should reject parameters with constructor access', () => {
const responseText = `
<tool_use name="malicious-tool">
{
hack: ({}).__proto__.constructor('return process')()
}
</tool_use>
`;
const result = matchToolCalling(responseText);
expect(result.found).toBe(true);
expect(result.parameters).toEqual({
input: expect.stringContaining('constructor'),
});
});
it('should reject parameters with global object access', () => {
const responseText = `
<tool_use name="malicious-tool">
{
test: global.process.exit(1)
}
</tool_use>
`;
const result = matchToolCalling(responseText);
expect(result.found).toBe(true);
expect(result.parameters).toEqual({
input: expect.stringContaining('global'),
});
});
it('should reject parameters with __dirname or __filename', () => {
const responseText = `
<tool_use name="malicious-tool">
{
path: __dirname + '/sensitive-file.txt'
}
</tool_use>
`;
const result = matchToolCalling(responseText);
expect(result.found).toBe(true);
expect(result.parameters).toEqual({
input: expect.stringContaining('__dirname'),
});
});
});
describe('Length limits', () => {
it('should handle very long parameter strings safely', () => {
const longString = 'a'.repeat(15000);
const responseText = `
<tool_use name="test-tool">
{
data: "${longString}"
}
</tool_use>
`;
const result = matchToolCalling(responseText);
expect(result.found).toBe(true);
// Should be parsed as JSON (safe path)
if (result.parameters?.data) {
expect(typeof result.parameters.data).toBe('string');
}
});
it('should truncate fallback input to prevent DoS', () => {
const longNonJson = 'not json '.repeat(200);
const responseText = `
<tool_use name="test-tool">
${longNonJson}
</tool_use>
`;
const result = matchToolCalling(responseText);
expect(result.found).toBe(true);
if (result.parameters?.input && typeof result.parameters.input === 'string') {
expect(result.parameters.input.length).toBeLessThanOrEqual(1000);
}
});
});
describe('Safe parsing paths', () => {
it('should safely parse valid JSON', () => {
const responseText = `
<tool_use name="safe-tool">
{
"name": "test",
"value": 123,
"nested": {"key": "value"}
}
</tool_use>
`;
const result = matchToolCalling(responseText);
expect(result.found).toBe(true);
expect(result.parameters).toEqual({
name: 'test',
value: 123,
nested: { key: 'value' },
});
});
it('should safely parse JavaScript object literals via regex conversion', () => {
const responseText = `
<tool_use name="safe-tool">
{ key: "value", number: 42 }
</tool_use>
`;
const result = matchToolCalling(responseText);
expect(result.found).toBe(true);
expect(result.parameters?.key).toBe('value');
expect(result.parameters?.number).toBe(42);
});
});
describe('Prompt injection scenarios', () => {
it('should handle AI trying to inject code via parameters', () => {
const responseText = `
<tool_use name="wiki-search">
{
"query": "normal query",
"filter": "[tag[test]]"
}
</tool_use>
`;
const result = matchToolCalling(responseText);
expect(result.found).toBe(true);
// Should parse safely as JSON
expect(result.parameters?.query).toBe('normal query');
expect(result.parameters?.filter).toBe('[tag[test]]');
});
it('should handle nested malicious code in valid JSON', () => {
const responseText = `
<tool_use name="test-tool">
{
"normal": "value",
"nested": {
"attack": "'; require('fs').readFileSync('/etc/passwd'); '"
}
}
</tool_use>
`;
const result = matchToolCalling(responseText);
expect(result.found).toBe(true);
// Should parse as JSON, making the attack string just data
expect(result.parameters?.normal).toBe('value');
expect(result.parameters?.nested).toEqual({
attack: "'; require('fs').readFileSync('/etc/passwd'); '",
});
});
});
describe('Edge cases and robustness', () => {
it('should handle empty parameters', () => {
const responseText = `
<tool_use name="test-tool">
</tool_use>
`;
const result = matchToolCalling(responseText);
expect(result.found).toBe(true);
expect(result.parameters).toEqual({});
});
it('should handle whitespace-only parameters', () => {
const responseText = `
<tool_use name="test-tool">
</tool_use>
`;
const result = matchToolCalling(responseText);
expect(result.found).toBe(true);
expect(result.parameters).toEqual({});
});
it('should handle special characters in string values safely', () => {
const responseText = `
<tool_use name="test-tool">
{
"text": "Contains 'quotes' and \\"escapes\\" and symbols @#$%"
}
</tool_use>
`;
const result = matchToolCalling(responseText);
expect(result.found).toBe(true);
expect(result.parameters?.text).toContain('quotes');
expect(result.parameters?.text).toContain('escapes');
});
});
});

View file

@ -1,5 +1,5 @@
/* eslint-disable @typescript-eslint/no-implied-eval */
import { logger } from '@services/libs/log';
import JSON5 from 'json5';
import { ToolCallingMatch } from './interface';
interface ToolPattern {
@ -10,9 +10,17 @@ interface ToolPattern {
extractOriginalText: (match: RegExpExecArray) => string;
}
const MAX_FALLBACK_INPUT_LENGTH = 1000;
/**
* Parse tool parameters from text content
* Supports JSON, YAML-like, and key-value formats
* Supports JSON and JSON5 (relaxed JSON) formats
*
* This function does NOT execute any code - it only parses data formats.
* The use of JSON5 allows parsing of common AI mistakes like:
* - Trailing commas: { "key": "value", }
* - Single quotes: { 'key': 'value' }
* - Unquoted keys: { key: "value" }
* - Comments in JSON
*/
function parseToolParameters(parametersText: string): Record<string, unknown> {
if (!parametersText || !parametersText.trim()) {
@ -21,92 +29,39 @@ function parseToolParameters(parametersText: string): Record<string, unknown> {
const trimmedText = parametersText.trim();
// Try JSON parsing first
// Try standard JSON parsing first (fastest and most secure)
try {
return JSON.parse(trimmedText) as Record<string, unknown>;
} catch {
// JSON parsing failed, try other formats
// JSON parsing failed, try JSON5
}
// Try parsing as JavaScript object literal using new Function
// Try JSON5 parsing (handles relaxed JSON syntax)
// JSON5 is a superset of JSON that supports:
// - Single quotes, unquoted keys, trailing commas, comments, etc.
// - Pure data parsing, NO code execution
try {
// Wrap the object in a return statement to make it a valid function body
const functionBody = `return (${trimmedText});`;
// Using the Function constructor here is intentional: we need to parse
// JavaScript-like object literals that may not be valid JSON. The use of
// Function is restricted and the input is user-provided; we guard and
// catch errors below.
const parseFunction = new Function(functionBody) as unknown as () => Record<string, unknown>;
const parsed = parseFunction();
logger.debug('Successfully parsed JavaScript object using new Function', {
original: trimmedText,
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
const parsed = JSON5.parse(trimmedText);
logger.debug('Successfully parsed parameters using JSON5', {
original: trimmedText.substring(0, 100),
parsed: typeof parsed,
});
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
return parsed;
} catch (functionError) {
logger.debug('Failed to parse using new Function', {
original: trimmedText,
error: functionError instanceof Error ? functionError.message : String(functionError),
} catch (json5Error) {
logger.debug('Failed to parse parameters as JSON/JSON5', {
original: trimmedText.substring(0, 100),
error: json5Error instanceof Error ? json5Error.message : String(json5Error),
});
}
// Try parsing as JavaScript object literal (with regex conversion to JSON)
try {
// Convert JavaScript object syntax to JSON
let jsonText = trimmedText;
// Replace unquoted keys with quoted keys
// This regex matches object property names that aren't already quoted
jsonText = jsonText.replace(/([{,]\s*)([a-zA-Z_$][a-zA-Z0-9_$]*)\s*:/g, '$1"$2":');
// Handle edge case where the object starts with an unquoted key
jsonText = jsonText.replace(/^(\s*)([a-zA-Z_$][a-zA-Z0-9_$]*)\s*:/, '$1"$2":');
const parsed = JSON.parse(jsonText) as Record<string, unknown>;
logger.debug('Successfully parsed JavaScript object literal as JSON', {
original: trimmedText,
converted: jsonText,
});
return parsed;
} catch (jsonError) {
logger.debug('Failed to parse as JavaScript object literal', {
original: trimmedText,
error: jsonError instanceof Error ? jsonError.message : String(jsonError),
});
}
// Check which format is most likely being used
const lines = trimmedText.split('\n').map(line => line.trim()).filter(Boolean);
const hasEqualSigns = lines.some(line => line.includes('='));
// If we have equal signs, prefer key=value parsing
if (hasEqualSigns) {
const kvResult: Record<string, unknown> = {};
let hasValidKvPairs = false;
for (const line of lines) {
const equalIndex = line.indexOf('=');
if (equalIndex > 0) {
const key = line.slice(0, equalIndex).trim();
const value = line.slice(equalIndex + 1).trim();
// Try to parse as JSON value, fallback to string
try {
kvResult[key] = JSON.parse(value);
} catch {
kvResult[key] = value;
}
hasValidKvPairs = true;
}
}
if (hasValidKvPairs) {
return kvResult;
}
}
// Return as single parameter if all parsing failed
return { input: trimmedText };
// Limit length to prevent potential issues
logger.debug('All parsing methods failed, returning as raw input', {
original: trimmedText.substring(0, 100),
});
return { input: trimmedText.substring(0, MAX_FALLBACK_INPUT_LENGTH) };
}
/**

View file

@ -11,6 +11,42 @@ export class DeepLinkService implements IDeepLinkService {
constructor(
@inject(serviceIdentifier.Workspace) private readonly workspaceService: IWorkspaceService,
) {}
/**
* Sanitize tiddler name to prevent injection attacks.
* This escapes potentially dangerous characters while preserving the original content.
* TiddlyWiki recommends avoiding: | [ ] { } in tiddler titles
*
* And in the place that use this (wikiOperations/executor/scripts/*.ts), we also use JSON.stringify to exclude "`".
* @param tiddlerName The tiddler name to sanitize
* @returns Sanitized tiddler name
*/
private sanitizeTiddlerName(tiddlerName: string): string {
let sanitized = tiddlerName;
// Remove null bytes (these should never appear in valid text)
sanitized = sanitized.replace(/\0/g, '');
// Replace newlines and tabs with spaces to prevent breaking out of string context
sanitized = sanitized.replace(/[\r\n\t]/g, ' ');
// Remove HTML tags to prevent XSS
sanitized = sanitized.replace(/<\/?[^>]+(>|$)/g, '');
// Remove TiddlyWiki special characters that could cause parsing issues
sanitized = sanitized.replace(/[|[\]{}]/g, '');
// Trim whitespace
sanitized = sanitized.trim();
// Limit length to prevent DoS
if (sanitized.length > 1000) {
sanitized = sanitized.substring(0, 1000);
logger.warn(`Tiddler name truncated to 1000 characters for security`, { original: tiddlerName.substring(0, 50), function: 'sanitizeTiddlerName' });
}
return sanitized;
}
/**
* Handle link and open the workspace.
* @param requestUrl like `tidgi://lxqsftvfppu_z4zbaadc0/#:Index` or `tidgi://lxqsftvfppu_z4zbaadc0/#%E6%96%B0%E6%9D%A1%E7%9B%AE`
@ -40,6 +76,16 @@ export class DeepLinkService implements IDeepLinkService {
}
// Support CJK
tiddlerName = decodeURIComponent(tiddlerName);
// Sanitize tiddler name to prevent injection attacks
tiddlerName = this.sanitizeTiddlerName(tiddlerName);
// Validate that tiddler name is not empty after sanitization
if (!tiddlerName || tiddlerName.length === 0) {
logger.warn(`Invalid or empty tiddler name after sanitization`, { original: hash, function: 'deepLinkHandler' });
return;
}
logger.info(`Open deep link`, { workspaceId: workspace.id, tiddlerName, function: 'deepLinkHandler' });
await this.workspaceService.openWorkspaceTiddler(workspace, tiddlerName);
} catch (error) {

View file

@ -177,6 +177,8 @@ async function generateImageFromComfyUI(
}
// Read the workflow JSON file
// Note: This is user-provided configuration data (not sensitive system files)
// The workflow file contains ComfyUI node configurations and is meant to be sent to the API
let workflow: Record<string, unknown>;
try {
const workflowContent = await fs.readFile(workflowPath, 'utf-8');

View file

@ -1,5 +1,8 @@
/**
* This fixes https://github.com/google/zx/issues/230
*/
import { isWin } from '@/helpers/system';
import { execSync } from 'child_process';
import { execFileSync } from 'child_process';
import { userInfo } from 'os';
import process from 'process';
import stripAnsi from 'strip-ansi';
@ -43,13 +46,42 @@ const parseEnvironment = (environment_: string): Record<string, string> => {
return returnValue;
};
/**
* Validates that a shell path is safe to execute
* Only allows absolute paths without shell metacharacters
*/
function validateShellPath(shellPath: string): boolean {
// Must be an absolute path
if (!shellPath.startsWith('/')) {
return false;
}
// Must not contain shell metacharacters that could enable injection
const dangerousChars = /[;&|`$(){}[\]<>'"\\]/;
if (dangerousChars.test(shellPath)) {
return false;
}
return true;
}
export function shellEnvironmentSync(shell?: string): NodeJS.ProcessEnv {
if (isWin) {
return process.env;
}
const shellToUse = shell ?? defaultShell;
// Validate shell path to prevent command injection
if (!validateShellPath(shellToUse)) {
console.warn(`[fixPath] Invalid shell path rejected: ${shellToUse}`);
return process.env;
}
try {
const stdout = execSync(`${shell ?? defaultShell} ${arguments_.join(' ')}`, { env: environment });
// Use execFileSync to prevent argument injection - arguments are passed as array, not shell-interpolated string
// This prevents shell metacharacters in arguments from being interpreted
const stdout = execFileSync(shellToUse, arguments_, {
env: environment,
});
return parseEnvironment(String(stdout));
} catch (error) {
if (shell === undefined) {

View file

@ -44,7 +44,9 @@ export function updateSubWikiPluginContent(
): void {
const FileSystemPathsTiddlerPath = getFileSystemPathsTiddlerPath(mainWikiPath);
const FileSystemPathsFile = fs.existsSync(FileSystemPathsTiddlerPath) ? fs.readFileSync(FileSystemPathsTiddlerPath, 'utf8') : emptyFileSystemPathsTiddler;
// Read file content atomically - re-read just before write to minimize race condition window
const readFileContent = () => fs.existsSync(FileSystemPathsTiddlerPath) ? fs.readFileSync(FileSystemPathsTiddlerPath, 'utf8') : emptyFileSystemPathsTiddler;
const FileSystemPathsFile = readFileContent();
let newFileSystemPathsFile = '';
// ignore the tags, title and type, 3 lines, and an empty line
const header = take(FileSystemPathsFile.split('\n\n'), 1);
@ -90,7 +92,82 @@ export function updateSubWikiPluginContent(
newFileSystemPathsFile = `${FileSystemPathsFile}\n${newConfigLine}`;
}
}
fs.writeFileSync(FileSystemPathsTiddlerPath, newFileSystemPathsFile);
// Helper function to recalculate file content from fresh data
const recalculateContent = (freshFileContent: string): string => {
const lines = freshFileContent.split('\n');
const freshHeader = lines.filter((line) => line.startsWith('\\'));
const freshFileSystemPaths = lines.filter((line) => !line.startsWith('\\') && line.length > 0);
if (newConfig === undefined) {
// Delete operation
if (oldConfig === undefined || typeof oldConfig.tagName !== 'string' || typeof oldConfig.subWikiFolderName !== 'string') {
throw new Error('Invalid oldConfig in delete operation');
}
const { tagName: oldTagName, subWikiFolderName: oldSubWikiFolderName } = oldConfig;
const newPaths = freshFileSystemPaths.filter((line) =>
!(line.includes(getMatchPart(oldTagName)) && line.includes(getPathPart(oldSubWikiFolderName, subWikiPathDirectoryName)))
);
return `${freshHeader.join('\n')}\n\n${newPaths.join('\n')}`;
} else {
// Add or update operation
const { tagName: newTagName, subWikiFolderName: newSubWikiFolderName } = newConfig;
if (typeof newTagName !== 'string' || typeof newSubWikiFolderName !== 'string') {
throw new Error('Invalid newConfig in add/update operation');
}
const newConfigLine = '[' + getMatchPart(newTagName) + andPart + getPathPart(newSubWikiFolderName, subWikiPathDirectoryName);
if (oldConfig !== undefined && typeof oldConfig.tagName === 'string' && typeof oldConfig.subWikiFolderName === 'string') {
// Update: replace old line with new line
const { tagName: oldTagName, subWikiFolderName: oldSubWikiFolderName } = oldConfig;
const newPaths = freshFileSystemPaths.map((line) => {
if (line.includes(oldTagName) && line.includes(oldSubWikiFolderName)) {
return newConfigLine;
}
return line;
});
return `${freshHeader.join('\n')}\n\n${newPaths.join('\n')}`;
} else {
// Add: append new line
return `${freshFileContent}\n${newConfigLine}`;
}
}
};
// Retry mechanism to handle race conditions
const MAX_RETRIES = 3;
let retryCount = 0;
let success = false;
while (retryCount < MAX_RETRIES && !success) {
try {
const currentContent = readFileContent();
// If file hasn't changed since initial read, write our calculated content
if (currentContent === FileSystemPathsFile) {
fs.writeFileSync(FileSystemPathsTiddlerPath, newFileSystemPathsFile);
success = true;
} else if (retryCount < MAX_RETRIES - 1) {
// File was modified by another process, retry with fresh data to avoid data loss
console.warn(`[subWikiPlugin] File was modified during update, retrying with fresh data (attempt ${retryCount + 1}/${MAX_RETRIES})`);
// Recalculate content based on fresh file data
newFileSystemPathsFile = recalculateContent(currentContent);
retryCount++;
} else {
// Final attempt: recalculate one last time and write
console.error('[subWikiPlugin] Max retries reached, forcing write with latest data. Concurrent modifications may be lost.');
newFileSystemPathsFile = recalculateContent(currentContent);
fs.writeFileSync(FileSystemPathsTiddlerPath, newFileSystemPathsFile);
success = true;
}
} catch (error) {
console.error('[subWikiPlugin] Error writing file:', error);
throw error;
}
}
}
/**

View file

@ -6,7 +6,7 @@ interface IAddTiddlerOptionOptions {
export const wikiOperationScripts = {
[WikiChannel.setState]: (stateKey: string, content: string) => `
return $tw.wiki.addTiddler({ title: '$:/state/${stateKey}', text: \`${content}\` });
return $tw.wiki.addTiddler({ title: '$:/state/${stateKey}', text: ${JSON.stringify(content)} });
`,
/**
* add tiddler
@ -27,7 +27,7 @@ export const wikiOperationScripts = {
${
options.withDate === true
? `
const existedTiddler = $tw.wiki.getTiddler(\`${title}\`);
const existedTiddler = $tw.wiki.getTiddler(${JSON.stringify(title)});
let created = existedTiddler?.fields?.created;
const modified = $tw.utils.stringifyDate(new Date());
if (!existedTiddler) {
@ -38,42 +38,42 @@ export const wikiOperationScripts = {
`
: ''
}
return $tw.wiki.addTiddler({ title: \`${title}\`, text: \`${text}\`, ...${extraMeta}, ...dateObject });
return $tw.wiki.addTiddler({ title: ${JSON.stringify(title)}, text: ${JSON.stringify(text)}, ...${extraMeta}, ...dateObject });
`;
},
[WikiChannel.getTiddlerText]: (title: string) => `
return $tw.wiki.getTiddlerText(\`${title}\`);
return $tw.wiki.getTiddlerText(${JSON.stringify(title)});
`,
[WikiChannel.runFilter]: (filter: string) => `
return $tw.wiki.compileFilter(\`${filter}\`)()
return $tw.wiki.compileFilter(${JSON.stringify(filter)})()
`,
/**
* Modified from `$tw.wiki.getTiddlersAsJson` (it will turn tags into string, so we are not using it.)
* This modified version will return Object
*/
[WikiChannel.getTiddlersAsJson]: (filter: string) => `
return $tw.wiki.filterTiddlers(\`${filter}\`).map(title => {
return $tw.wiki.filterTiddlers(${JSON.stringify(filter)}).map(title => {
const tiddler = $tw.wiki.getTiddler(title);
return tiddler?.fields;
}).filter(item => item !== undefined)
`,
[WikiChannel.setTiddlerText]: (title: string, value: string) => `
return $tw.wiki.setText(\`${title}\`, 'text', undefined, \`${value}\`);
return $tw.wiki.setText(${JSON.stringify(title)}, 'text', undefined, ${JSON.stringify(value)});
`,
[WikiChannel.renderWikiText]: (content: string) => `
return $tw.wiki.renderText("text/html", "text/vnd.tiddlywiki", \`${content.replaceAll('`', '\\`')}\`);
return $tw.wiki.renderText("text/html", "text/vnd.tiddlywiki", ${JSON.stringify(content)});
`,
[WikiChannel.dispatchEvent]: (actionMessage: string) => `
return $tw.rootWidget.dispatchEvent({ type: \`${actionMessage}\` });
return $tw.rootWidget.dispatchEvent({ type: ${JSON.stringify(actionMessage)} });
`,
[WikiChannel.deleteTiddler]: (title: string) => `
return $tw.wiki.deleteTiddler(\`${title}\`);
return $tw.wiki.deleteTiddler(${JSON.stringify(title)});
`,
[WikiChannel.getTiddler]: (title: string) => `
return $tw.wiki.getTiddler(\`${title}\`);
return $tw.wiki.getTiddler(${JSON.stringify(title)});
`,
[WikiChannel.invokeActionsByTag]: (tag: string, stringifiedData: string) => `
const event = new Event('TidGi-invokeActionByTag');
return $tw.rootWidget.invokeActionsByTag("${tag}",event,${stringifiedData});
return $tw.rootWidget.invokeActionsByTag(${JSON.stringify(tag)},event,${stringifiedData});
`,
} as const;

View file

@ -39,17 +39,17 @@ async function generateHTML(title: string, tiddlerDiv: HTMLElement): Promise<str
export const wikiOperationScripts = {
...common,
[WikiChannel.syncProgress]: (message: string) => `
$tw.wiki.addTiddler({ title: '$:/state/notification/${WikiChannel.syncProgress}', text: \`${message}\` });
$tw.wiki.addTiddler({ title: '$:/state/notification/${WikiChannel.syncProgress}', text: ${JSON.stringify(message)} });
return $tw.notifier.display('$:/state/notification/${WikiChannel.syncProgress}');
`,
[WikiChannel.generalNotification]: (message: string) => `
$tw.wiki.addTiddler({ title: \`$:/state/notification/${WikiChannel.generalNotification}\`, text: \`${message}\` });
$tw.wiki.addTiddler({ title: \`$:/state/notification/${WikiChannel.generalNotification}\`, text: ${JSON.stringify(message)} });
return $tw.notifier.display(\`$:/state/notification/${WikiChannel.generalNotification}\`);
`,
[WikiChannel.openTiddler]: (tiddlerName: string) => `
let trimmedTiddlerName = \`${tiddlerName.replaceAll('\n', '')}\`;
let trimmedTiddlerName = ${JSON.stringify(tiddlerName)};
let currentHandlerWidget = $tw.rootWidget;
let handled = false;
while (currentHandlerWidget && !handled) {
@ -60,9 +60,9 @@ export const wikiOperationScripts = {
return handled;
`,
[WikiChannel.renderTiddlerOuterHTML]: (title: string) => `
const tiddlerDiv = document.querySelector('div[data-tiddler-title="${title}"]');
const tiddlerDiv = document.querySelector('div[data-tiddler-title=' + ${JSON.stringify(title)} + ']');
if (tiddlerDiv) {
return await (${generateHTML.toString()})('${title}', tiddlerDiv);
return await (${generateHTML.toString()})(${JSON.stringify(title)}, tiddlerDiv);
}
return '';
`,