Skip to content

Commit 5c90a1f

Browse files
committed
fix(notifications): harden Windows native notifications (#1138)
1 parent 34f7d83 commit 5c90a1f

3 files changed

Lines changed: 182 additions & 10 deletions

File tree

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
"@nanocollective/nanocoder": patch
3+
---
4+
5+
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.
6+

source/utils/notifications.spec.ts

Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
1+
import childProcess from 'child_process';
12
import test from 'ava';
23
import {
4+
buildWindowsNotificationPayload,
35
getNotificationsConfig,
46
sendNotification,
57
setNotificationsConfig,
@@ -107,6 +109,125 @@ test.serial('sendNotification handles undefined events gracefully', (t) => {
107109
t.notThrows(() => sendNotification('toolConfirmation'));
108110
});
109111

112+
// ============================================================================
113+
// Windows Notification Tests & Security Verification
114+
// ============================================================================
115+
116+
test.serial(
117+
'buildWindowsNotificationPayload generates static script and out-of-band env vars',
118+
(t) => {
119+
const title = 'Test Title `whoami` & $env:TEMP';
120+
const message = 'Line 1\nLine 2 with "quotes" and \'single quotes\' 🚀';
121+
122+
const payload = buildWindowsNotificationPayload(title, message);
123+
124+
t.is(payload.command, 'powershell');
125+
t.is(payload.args[0], '-NoProfile');
126+
t.is(payload.args[1], '-NonInteractive');
127+
t.is(payload.args[2], '-EncodedCommand');
128+
t.true(payload.options.windowsHide);
129+
130+
// Verify encoded script decodes properly and does not interpolate user strings
131+
const decodedScript = Buffer.from(payload.args[3], 'base64').toString(
132+
'utf16le',
133+
);
134+
t.true(decodedScript.includes('System.Windows.Forms.NotifyIcon'));
135+
t.true(decodedScript.includes('$env:NANOCODER_NOTIFICATION_TITLE'));
136+
t.true(decodedScript.includes('$env:NANOCODER_NOTIFICATION_MESSAGE'));
137+
138+
// Verify title and message env vars are passed verbatim
139+
t.is(payload.options.env.NANOCODER_NOTIFICATION_TITLE, title);
140+
t.is(payload.options.env.NANOCODER_NOTIFICATION_MESSAGE, message);
141+
},
142+
);
143+
144+
test.serial(
145+
'buildWindowsNotificationPayload safely preserves edge case characters in env vars',
146+
(t) => {
147+
const injectionTitles = [
148+
'`whoami`',
149+
'$(Get-Process)',
150+
'$env:USERPROFILE',
151+
"'; Remove-Item -Recurse C:\\; '",
152+
'Title with "double quotes" and \'single quotes\'',
153+
'Emoji 🚀 and Unicode 你好世界',
154+
];
155+
156+
for (const title of injectionTitles) {
157+
const payload = buildWindowsNotificationPayload(title, 'sample message');
158+
159+
// Payload env var must match exact literal text
160+
t.is(payload.options.env.NANOCODER_NOTIFICATION_TITLE, title);
161+
}
162+
},
163+
);
164+
165+
test.serial(
166+
'sendNotification handles win32 platform gracefully and invokes static powershell command',
167+
(t) => {
168+
const originalPlatform = process.platform;
169+
const originalExecFile = childProcess.execFile;
170+
let executedCommand = '';
171+
let executedArgs: string[] = [];
172+
let executedOptions: {windowsHide?: boolean; env?: NodeJS.ProcessEnv} = {};
173+
174+
// Spy on childProcess.execFile
175+
// biome-ignore lint/suspicious/noExplicitAny: test stub
176+
(childProcess.execFile as any) = (
177+
command: string,
178+
args: string[],
179+
options: any,
180+
callback: any,
181+
) => {
182+
executedCommand = command;
183+
executedArgs = args;
184+
executedOptions = options;
185+
if (typeof options === 'function') {
186+
options();
187+
} else if (typeof callback === 'function') {
188+
callback();
189+
}
190+
};
191+
192+
Object.defineProperty(process, 'platform', {
193+
value: 'win32',
194+
configurable: true,
195+
});
196+
197+
try {
198+
setNotificationsConfig({
199+
enabled: true,
200+
events: {generationComplete: true},
201+
customMessages: {
202+
generationComplete: {
203+
title: '`whoami`',
204+
message: 'Test message $(dir)',
205+
},
206+
},
207+
});
208+
209+
t.notThrows(() => sendNotification('generationComplete'));
210+
211+
t.is(executedCommand, 'powershell');
212+
t.is(executedArgs[0], '-NoProfile');
213+
t.is(executedArgs[1], '-NonInteractive');
214+
t.is(executedArgs[2], '-EncodedCommand');
215+
t.true(executedOptions.windowsHide);
216+
t.is(executedOptions.env?.NANOCODER_NOTIFICATION_TITLE, '`whoami`');
217+
t.is(
218+
executedOptions.env?.NANOCODER_NOTIFICATION_MESSAGE,
219+
'Test message $(dir)',
220+
);
221+
} finally {
222+
childProcess.execFile = originalExecFile;
223+
Object.defineProperty(process, 'platform', {
224+
value: originalPlatform,
225+
configurable: true,
226+
});
227+
}
228+
},
229+
);
230+
110231
// ============================================================================
111232
// Terminal Bell Tests
112233
// ============================================================================

source/utils/notifications.ts

Lines changed: 55 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import {execFile, execSync} from 'child_process';
1+
import childProcess, {execSync} from 'child_process';
22
import {existsSync} from 'fs';
33
import {basename, dirname, join} from 'path';
44
import {fileURLToPath} from 'url';
@@ -106,7 +106,7 @@ function sendDarwin(title: string, message: string): void {
106106
if (_config.sound) {
107107
args.push('-sound', 'default');
108108
}
109-
execFile(tnPath, args, () => {});
109+
childProcess.execFile(tnPath, args, () => {});
110110
return;
111111
}
112112

@@ -123,7 +123,7 @@ function sendDarwin(title: string, message: string): void {
123123
const escapedMessage = escapeAppleScript(message);
124124
const sound = _config.sound ? ' sound name "default"' : '';
125125
const script = `display notification "${escapedMessage}" with title "${escapedTitle}"${sound}`;
126-
execFile('osascript', ['-e', script], () => {});
126+
childProcess.execFile('osascript', ['-e', script], () => {});
127127
}
128128

129129
function sendLinux(title: string, message: string): void {
@@ -133,22 +133,67 @@ function sendLinux(title: string, message: string): void {
133133
args.push('-i', iconPath);
134134
}
135135
args.push(title, message);
136-
execFile('notify-send', args, () => {});
136+
childProcess.execFile('notify-send', args, () => {});
137137
}
138138

139-
function sendWindows(title: string, message: string): void {
140-
const script = `
139+
const WINDOWS_NOTIFICATION_SCRIPT = `
141140
Add-Type -AssemblyName System.Windows.Forms
142141
$notify = New-Object System.Windows.Forms.NotifyIcon
143142
$notify.Icon = [System.Drawing.SystemIcons]::Information
144-
$notify.BalloonTipTitle = '${title.replace(/'/g, "''")}'
145-
$notify.BalloonTipText = '${message.replace(/'/g, "''")}'
143+
$notify.BalloonTipTitle = $env:NANOCODER_NOTIFICATION_TITLE
144+
$notify.BalloonTipText = $env:NANOCODER_NOTIFICATION_MESSAGE
146145
$notify.Visible = $true
147146
$notify.ShowBalloonTip(5000)
148147
Start-Sleep -Seconds 1
149148
$notify.Dispose()
150-
`;
151-
execFile('powershell', ['-NoProfile', '-Command', script], () => {});
149+
`.trim();
150+
151+
const WINDOWS_NOTIFICATION_ENCODED_COMMAND = Buffer.from(
152+
WINDOWS_NOTIFICATION_SCRIPT,
153+
'utf16le',
154+
).toString('base64');
155+
156+
export function buildWindowsNotificationPayload(
157+
title: string,
158+
message: string,
159+
): {
160+
command: string;
161+
args: string[];
162+
options: {
163+
windowsHide: boolean;
164+
env: NodeJS.ProcessEnv;
165+
};
166+
} {
167+
return {
168+
command: 'powershell',
169+
args: [
170+
'-NoProfile',
171+
'-NonInteractive',
172+
'-EncodedCommand',
173+
WINDOWS_NOTIFICATION_ENCODED_COMMAND,
174+
],
175+
options: {
176+
windowsHide: true,
177+
// Explicitly spread process.env: passing custom `env` disables implicit
178+
// environment inheritance in child_process, and powershell.exe needs
179+
// standard system vars like SystemRoot, PATH, and TEMP to run.
180+
env: {
181+
...process.env,
182+
NANOCODER_NOTIFICATION_TITLE: title,
183+
NANOCODER_NOTIFICATION_MESSAGE: message,
184+
},
185+
},
186+
};
187+
}
188+
189+
function sendWindows(title: string, message: string): void {
190+
const payload = buildWindowsNotificationPayload(title, message);
191+
childProcess.execFile(
192+
payload.command,
193+
payload.args,
194+
payload.options,
195+
() => {},
196+
);
152197
}
153198

154199
// A terminal bell is delivered by the terminal emulator itself, so it still

0 commit comments

Comments
 (0)