Commit 85df70eecc2 for nodejs
commit 85df70eecc2b7a4c611608f3ffc371e3882ad1a4
Author: Trivikram Kamat <trivikr.dev@gmail.com>
Date: Sun Oct 4 18:08:04 2026 -0700
watch: escape quotes and backslashes in NODE_OPTIONS
When stripping watch flags, NODE_OPTIONS is tokenized and rejoined
for the child process. Values were only re-quoted if they contained
a space, and nothing inside the quotes was escaped. A value with a
double quote made the child fail with "unterminated string", and a
backslash inside a quoted value was silently dropped.
Quote values that contain a space or a double quote, and escape
backslashes and double quotes inside the quotes, so the child
tokenizes the string back to the same values.
Signed-off-by: Trivikram Kamat <16024985+trivikr@users.noreply.github.com>
Assisted-by: claude:opus-5.5
PR-URL: https://github.com/nodejs/node/pull/66363
Fixes: https://github.com/nodejs/node/issues/66362
Reviewed-By: Moshe Atlow <moshe@atlow.co.il>
diff --git a/lib/internal/main/watch_mode.js b/lib/internal/main/watch_mode.js
index f0f1e26bb26..21889b0304f 100644
--- a/lib/internal/main/watch_mode.js
+++ b/lib/internal/main/watch_mode.js
@@ -7,6 +7,7 @@ const {
ArrayPrototypePushApply,
ArrayPrototypeSlice,
StringPrototypeIncludes,
+ StringPrototypeReplaceAll,
StringPrototypeStartsWith,
} = primordials;
@@ -101,11 +102,20 @@ if (kNodeOptions != null) {
i++;
continue;
}
- // The C++ tokenizer strips quotes during parsing, so values that
- // originally contained spaces (e.g. --require "./path with spaces/f.js")
+ // The C++ tokenizer strips quotes and escapes during parsing, so values
+ // that contain spaces or double quotes (e.g. --require "./a b/f.js")
// need to be re-quoted before rejoining into a single string, otherwise
- // the child's C++ parser would split them into separate tokens.
- ArrayPrototypePush(keep, StringPrototypeIncludes(part, ' ') ? `"${part}"` : part);
+ // the child's C++ parser would split or misparse them. Inside quotes,
+ // backslashes are escape characters, so `"` and `\` must be escaped.
+ // Backslashes are escaped first so the ones added for `"` aren't doubled.
+ // Outside quotes, backslashes are literal, so other values are kept as-is.
+ if (StringPrototypeIncludes(part, ' ') || StringPrototypeIncludes(part, '"')) {
+ const escaped = StringPrototypeReplaceAll(
+ StringPrototypeReplaceAll(part, '\\', '\\\\'), '"', '\\"');
+ ArrayPrototypePush(keep, `"${escaped}"`);
+ } else {
+ ArrayPrototypePush(keep, part);
+ }
}
cleanNodeOptions = ArrayPrototypeJoin(keep, ' ');
}
diff --git a/test/sequential/test-watch-mode.mjs b/test/sequential/test-watch-mode.mjs
index 0c28adec017..8607331a94a 100644
--- a/test/sequential/test-watch-mode.mjs
+++ b/test/sequential/test-watch-mode.mjs
@@ -1064,6 +1064,35 @@ process.on('message', (message) => {
}
});
+ for (const { name, nodeOptions, expected } of [
+ { name: 'a double quote', nodeOptions: '--title="a\\"b"', expected: 'a"b' },
+ { name: 'a backslash', nodeOptions: '--title="a \\\\b"', expected: 'a \\b' },
+ ]) {
+ it(`should preserve NODE_OPTIONS values containing ${name} in child process`, {
+ // Honoring --title from NODE_OPTIONS is required for this test.
+ // process.title is always an empty string on SunOS, so --title
+ // cannot be observed there.
+ skip: !!process.config.variables.node_without_node_options || common.isSunOS,
+ }, async () => {
+ const file = createTmpFile('console.log(JSON.stringify(process.title));');
+ const { done, restart } = runInBackground({
+ args: ['--watch', file],
+ options: {
+ env: { ...process.env, NODE_OPTIONS: `--watch ${nodeOptions}` },
+ },
+ });
+
+ try {
+ const { stdout, stderr } = await restart();
+
+ assert.strictEqual(stderr, '');
+ assert.ok(stdout.includes(JSON.stringify(expected)), stdout.join('\n'));
+ } finally {
+ await done();
+ }
+ });
+ }
+
it('should handle NODE_OPTIONS containing only watch flags', async () => {
const file = createTmpFile('console.log(JSON.stringify(process.env.NODE_OPTIONS));');
const { done, restart } = runInBackground({