From 5c90a1fa3053b99cf6b897530fa13b34a1ca044b Mon Sep 17 00:00:00 2001 From: rishu685 Date: Sun, 6 Sep 2026 22:01:41 +0530 Subject: [PATCH] fix(notifications): harden Windows native notifications (#1138) --- ...ndows-notification-powershell-injection.md | 6 + source/utils/notifications.spec.ts | 121 ++++++++++++++++++ source/utils/notifications.ts | 65 ++++++++-- 3 files changed, 182 insertions(+), 10 deletions(-) create mode 100644 .changeset/fix-windows-notification-powershell-injection.md diff --git a/.changeset/fix-windows-notification-powershell-injection.md b/.changeset/fix-windows-notification-powershell-injection.md new file mode 100644 index 000000000..3599eb0a3 --- /dev/null +++ b/.changeset/fix-windows-notification-powershell-injection.md @@ -0,0 +1,6 @@ +--- +"@nanocollective/nanocoder": patch +--- + +Hardened Windows native notifications. Notification title and message are no longer interpolated into the PowerShell script; they are passed out-of-band as environment variables and read by a static script. Fixes rendering and parsing edge cases with backticks, quotes and other special characters. Closes #1138. + diff --git a/source/utils/notifications.spec.ts b/source/utils/notifications.spec.ts index 3a30d6e27..9e6a75f11 100644 --- a/source/utils/notifications.spec.ts +++ b/source/utils/notifications.spec.ts @@ -1,5 +1,7 @@ +import childProcess from 'child_process'; import test from 'ava'; import { + buildWindowsNotificationPayload, getNotificationsConfig, sendNotification, setNotificationsConfig, @@ -107,6 +109,125 @@ test.serial('sendNotification handles undefined events gracefully', (t) => { t.notThrows(() => sendNotification('toolConfirmation')); }); +// ============================================================================ +// Windows Notification Tests & Security Verification +// ============================================================================ + +test.serial( + 'buildWindowsNotificationPayload generates static script and out-of-band env vars', + (t) => { + const title = 'Test Title `whoami` & $env:TEMP'; + const message = 'Line 1\nLine 2 with "quotes" and \'single quotes\' πŸš€'; + + const payload = buildWindowsNotificationPayload(title, message); + + t.is(payload.command, 'powershell'); + t.is(payload.args[0], '-NoProfile'); + t.is(payload.args[1], '-NonInteractive'); + t.is(payload.args[2], '-EncodedCommand'); + t.true(payload.options.windowsHide); + + // Verify encoded script decodes properly and does not interpolate user strings + const decodedScript = Buffer.from(payload.args[3], 'base64').toString( + 'utf16le', + ); + t.true(decodedScript.includes('System.Windows.Forms.NotifyIcon')); + t.true(decodedScript.includes('$env:NANOCODER_NOTIFICATION_TITLE')); + t.true(decodedScript.includes('$env:NANOCODER_NOTIFICATION_MESSAGE')); + + // Verify title and message env vars are passed verbatim + t.is(payload.options.env.NANOCODER_NOTIFICATION_TITLE, title); + t.is(payload.options.env.NANOCODER_NOTIFICATION_MESSAGE, message); + }, +); + +test.serial( + 'buildWindowsNotificationPayload safely preserves edge case characters in env vars', + (t) => { + const injectionTitles = [ + '`whoami`', + '$(Get-Process)', + '$env:USERPROFILE', + "'; Remove-Item -Recurse C:\\; '", + 'Title with "double quotes" and \'single quotes\'', + 'Emoji πŸš€ and Unicode δ½ ε₯½δΈ–η•Œ', + ]; + + for (const title of injectionTitles) { + const payload = buildWindowsNotificationPayload(title, 'sample message'); + + // Payload env var must match exact literal text + t.is(payload.options.env.NANOCODER_NOTIFICATION_TITLE, title); + } + }, +); + +test.serial( + 'sendNotification handles win32 platform gracefully and invokes static powershell command', + (t) => { + const originalPlatform = process.platform; + const originalExecFile = childProcess.execFile; + let executedCommand = ''; + let executedArgs: string[] = []; + let executedOptions: {windowsHide?: boolean; env?: NodeJS.ProcessEnv} = {}; + + // Spy on childProcess.execFile + // biome-ignore lint/suspicious/noExplicitAny: test stub + (childProcess.execFile as any) = ( + command: string, + args: string[], + options: any, + callback: any, + ) => { + executedCommand = command; + executedArgs = args; + executedOptions = options; + if (typeof options === 'function') { + options(); + } else if (typeof callback === 'function') { + callback(); + } + }; + + Object.defineProperty(process, 'platform', { + value: 'win32', + configurable: true, + }); + + try { + setNotificationsConfig({ + enabled: true, + events: {generationComplete: true}, + customMessages: { + generationComplete: { + title: '`whoami`', + message: 'Test message $(dir)', + }, + }, + }); + + t.notThrows(() => sendNotification('generationComplete')); + + t.is(executedCommand, 'powershell'); + t.is(executedArgs[0], '-NoProfile'); + t.is(executedArgs[1], '-NonInteractive'); + t.is(executedArgs[2], '-EncodedCommand'); + t.true(executedOptions.windowsHide); + t.is(executedOptions.env?.NANOCODER_NOTIFICATION_TITLE, '`whoami`'); + t.is( + executedOptions.env?.NANOCODER_NOTIFICATION_MESSAGE, + 'Test message $(dir)', + ); + } finally { + childProcess.execFile = originalExecFile; + Object.defineProperty(process, 'platform', { + value: originalPlatform, + configurable: true, + }); + } + }, +); + // ============================================================================ // Terminal Bell Tests // ============================================================================ diff --git a/source/utils/notifications.ts b/source/utils/notifications.ts index e8736b91b..c5b9dbe1c 100644 --- a/source/utils/notifications.ts +++ b/source/utils/notifications.ts @@ -1,4 +1,4 @@ -import {execFile, execSync} from 'child_process'; +import childProcess, {execSync} from 'child_process'; import {existsSync} from 'fs'; import {basename, dirname, join} from 'path'; import {fileURLToPath} from 'url'; @@ -106,7 +106,7 @@ function sendDarwin(title: string, message: string): void { if (_config.sound) { args.push('-sound', 'default'); } - execFile(tnPath, args, () => {}); + childProcess.execFile(tnPath, args, () => {}); return; } @@ -123,7 +123,7 @@ function sendDarwin(title: string, message: string): void { const escapedMessage = escapeAppleScript(message); const sound = _config.sound ? ' sound name "default"' : ''; const script = `display notification "${escapedMessage}" with title "${escapedTitle}"${sound}`; - execFile('osascript', ['-e', script], () => {}); + childProcess.execFile('osascript', ['-e', script], () => {}); } function sendLinux(title: string, message: string): void { @@ -133,22 +133,67 @@ function sendLinux(title: string, message: string): void { args.push('-i', iconPath); } args.push(title, message); - execFile('notify-send', args, () => {}); + childProcess.execFile('notify-send', args, () => {}); } -function sendWindows(title: string, message: string): void { - const script = ` +const WINDOWS_NOTIFICATION_SCRIPT = ` Add-Type -AssemblyName System.Windows.Forms $notify = New-Object System.Windows.Forms.NotifyIcon $notify.Icon = [System.Drawing.SystemIcons]::Information -$notify.BalloonTipTitle = '${title.replace(/'/g, "''")}' -$notify.BalloonTipText = '${message.replace(/'/g, "''")}' +$notify.BalloonTipTitle = $env:NANOCODER_NOTIFICATION_TITLE +$notify.BalloonTipText = $env:NANOCODER_NOTIFICATION_MESSAGE $notify.Visible = $true $notify.ShowBalloonTip(5000) Start-Sleep -Seconds 1 $notify.Dispose() -`; - execFile('powershell', ['-NoProfile', '-Command', script], () => {}); +`.trim(); + +const WINDOWS_NOTIFICATION_ENCODED_COMMAND = Buffer.from( + WINDOWS_NOTIFICATION_SCRIPT, + 'utf16le', +).toString('base64'); + +export function buildWindowsNotificationPayload( + title: string, + message: string, +): { + command: string; + args: string[]; + options: { + windowsHide: boolean; + env: NodeJS.ProcessEnv; + }; +} { + return { + command: 'powershell', + args: [ + '-NoProfile', + '-NonInteractive', + '-EncodedCommand', + WINDOWS_NOTIFICATION_ENCODED_COMMAND, + ], + options: { + windowsHide: true, + // Explicitly spread process.env: passing custom `env` disables implicit + // environment inheritance in child_process, and powershell.exe needs + // standard system vars like SystemRoot, PATH, and TEMP to run. + env: { + ...process.env, + NANOCODER_NOTIFICATION_TITLE: title, + NANOCODER_NOTIFICATION_MESSAGE: message, + }, + }, + }; +} + +function sendWindows(title: string, message: string): void { + const payload = buildWindowsNotificationPayload(title, message); + childProcess.execFile( + payload.command, + payload.args, + payload.options, + () => {}, + ); } // A terminal bell is delivered by the terminal emulator itself, so it still