Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/fix-windows-notification-powershell-injection.md
Original file line number Diff line number Diff line change
@@ -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.

121 changes: 121 additions & 0 deletions source/utils/notifications.spec.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import childProcess from 'child_process';
import test from 'ava';
import {
buildWindowsNotificationPayload,
getNotificationsConfig,
sendNotification,
setNotificationsConfig,
Expand Down Expand Up @@ -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
// ============================================================================
Expand Down
65 changes: 55 additions & 10 deletions source/utils/notifications.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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;
}

Expand All @@ -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 {
Expand All @@ -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
Expand Down
Loading