Skip to content
Open
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
"ansis": "^3.16.0",
"change-case": "^5.4.4",
"is-wsl": "^3.1.1",
"open": "^10.2.0",
"open": "^11.0.1",
"terminal-link": "^3.0.0"
},
"devDependencies": {
Expand Down
18 changes: 13 additions & 5 deletions src/shared/orgOpenCommandBase.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ import { apps } from 'open';
import { SfCommand } from '@salesforce/sf-plugins-core';
import { Connection, Messages, Org, SfdcUrl, SfError } from '@salesforce/core';
import { env } from '@salesforce/kit';
import utils, { handleDomainError } from './orgOpenUtils.js';
import utils, { getWindowsPrivateBrowserApp, handleDomainError } from './orgOpenUtils.js';
import { type OrgOpenOutput } from './orgTypes.js';

Messages.importMessagesDirectoryFromMetaUrl(import.meta.url);
Expand Down Expand Up @@ -78,10 +78,18 @@ export abstract class OrgOpenCommandBase<T> extends SfCommand<T> {
handleDomainError(err, url, env);
}

const cp = await utils.openUrl(url, {
...(flags.browser ? { app: { name: apps[flags.browser] } } : {}),
...(flags.private ? { newInstance: platform() === 'darwin', app: { name: apps.browserPrivate } } : {}),
});
let openOptions: import('open').Options = {};
if (flags.browser) {
openOptions = { app: { name: apps[flags.browser] } };
} else if (flags.private) {
if (platform() === 'win32') {
openOptions = { app: await getWindowsPrivateBrowserApp() };
} else {
openOptions = { newInstance: platform() === 'darwin', app: { name: apps.browserPrivate } };
}
}

const cp = await utils.openUrl(url, openOptions);
cp.on('error', (err) => {
throw SfError.wrap(err);
});
Expand Down
77 changes: 75 additions & 2 deletions src/shared/orgOpenUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,11 +14,14 @@
* limitations under the License.
*/

import { ChildProcess } from 'node:child_process';
import open, { Options } from 'open';
import { ChildProcess, execFile } from 'node:child_process';
import { promisify } from 'node:util';
import open, { apps, Options } from 'open';
import { Logger, Messages, SfError } from '@salesforce/core';
import { Duration, Env } from '@salesforce/kit';

const execFileAsync = promisify(execFile);

Messages.importMessagesDirectoryFromMetaUrl(import.meta.url);
const messages = Messages.loadMessages('@salesforce/plugin-org', 'open');

Expand All @@ -43,6 +46,76 @@ export const handleDomainError = (err: unknown, url: string, env: Env): string =
throw err;
};

const windowsBrowserProgIds: Record<string, { name: string; id: string }> = {
MSEdgeHTM: { name: 'Edge', id: 'com.microsoft.edge' },
MSEdgeBHTML: { name: 'Edge Beta', id: 'com.microsoft.edge' },
MSEdgeDHTML: { name: 'Edge Dev', id: 'com.microsoft.edge' },
AppXq0fevzme2pys62n3e0fbqa7peapykr8v: { name: 'Edge', id: 'com.microsoft.edge' },
ChromeHTML: { name: 'Chrome', id: 'com.google.chrome' },
ChromeBHTML: { name: 'Chrome Beta', id: 'com.google.chrome' },
ChromeDHTML: { name: 'Chrome Dev', id: 'com.google.chrome' },
ChromiumHTM: { name: 'Chromium', id: 'com.google.chrome' },
BraveHTML: { name: 'Brave', id: 'com.brave.Browser' },
BraveBHTML: { name: 'Brave Beta', id: 'com.brave.Browser' },
BraveDHTML: { name: 'Brave Dev', id: 'com.brave.Browser' },
BraveSSHTM: { name: 'Brave Nightly', id: 'com.brave.Browser' },
FirefoxURL: { name: 'Firefox', id: 'org.mozilla.firefox' },
};

const browserIdToAppName: Record<string, 'chrome' | 'firefox' | 'edge' | 'brave'> = {
'com.google.chrome': 'chrome',
'com.brave.Browser': 'brave',
'org.mozilla.firefox': 'firefox',
'com.microsoft.edge': 'edge',
};

const privateFlags: Record<string, string> = {
chrome: '--incognito',
brave: '--incognito',
firefox: '--private-window',
edge: '--inPrivate',
};

export type ExecFileFn = (cmd: string, args: string[]) => Promise<{ stdout: string }>;

export async function getWindowsPrivateBrowserApp(
_execFile: ExecFileFn = execFileAsync
): Promise<{ name: string | readonly string[]; arguments: string[] }> {
const regPath = `${process.env.SYSTEMROOT ?? process.env.windir ?? 'C:\\Windows'}\\System32\\reg.exe`;
const { stdout } = await _execFile(regPath, [
'QUERY',
'HKEY_CURRENT_USER\\Software\\Microsoft\\Windows\\Shell\\Associations\\UrlAssociations\\http\\UserChoice',
'/v',
'ProgId',
]);

const match = /ProgId\s*REG_SZ\s*(?<id>\S+)/.exec(stdout);
if (!match?.groups?.id) {
throw new SfError('Unable to detect default browser from Windows registry');
}

const { id } = match.groups;
const dotIndex = id.lastIndexOf('.');
const hyphenIndex = id.lastIndexOf('-');
const baseIdByDot = dotIndex === -1 ? undefined : id.slice(0, dotIndex);
const baseIdByHyphen = hyphenIndex === -1 ? undefined : id.slice(0, hyphenIndex);

const browser =
windowsBrowserProgIds[id] ??
(baseIdByDot ? windowsBrowserProgIds[baseIdByDot] : undefined) ??
(baseIdByHyphen ? windowsBrowserProgIds[baseIdByHyphen] : undefined);
if (!browser) {
throw new SfError(`Unsupported default browser: ${id}`);
}

const appName = browserIdToAppName[browser.id];
if (!appName) {
throw new SfError(`Unsupported default browser: ${browser.name}`);
}

return { name: apps[appName], arguments: [privateFlags[appName]] };
}

export default {
openUrl,
handleDomainError,
Expand Down
75 changes: 75 additions & 0 deletions test/nut/openPrivatePathHijack.nut.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
/*
* Copyright 2026, Salesforce, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

import { platform, tmpdir } from 'node:os';
import fs from 'node:fs';
import path from 'node:path';
import { expect } from 'chai';
import { type ExecFileFn, getWindowsPrivateBrowserApp } from '../../src/shared/orgOpenUtils.js';

describe('W-23807283: reg.exe PATH hijack prevention (Windows only)', () => {
if (platform() !== 'win32') {
it.skip('skipped on non-Windows', () => {});
return;
}

const evidenceFile = path.join(tmpdir(), `path-hijack-evidence-${process.pid}.txt`);

afterEach(() => {
try {
fs.unlinkSync(evidenceFile);
} catch {
// file may not exist
}
});

it('uses a fully-qualified reg.exe path, not bare "reg"', async () => {
let capturedCommand = '';

const interceptExec: ExecFileFn = async (cmd) => {
capturedCommand = cmd;
return { stdout: ' ProgId REG_SZ ChromeHTML\r\n' };
};

await getWindowsPrivateBrowserApp(interceptExec);

expect(capturedCommand).to.match(/[A-Z]:\\.*\\System32\\reg\.exe$/i);
expect(capturedCommand).to.not.equal('reg');
expect(capturedCommand).to.not.equal('reg.exe');
});

it('a project-local reg.exe in PATH does not execute during browser detection', async () => {
const poisonDir = path.join(tmpdir(), `hijack-test-${process.pid}`);
const poisonBin = path.join(poisonDir, 'node_modules', '.bin');
fs.mkdirSync(poisonBin, { recursive: true });

fs.writeFileSync(
path.join(poisonBin, 'reg.exe'),
`@echo off\r\necho HIJACKED > "${evidenceFile}"\r\necho ProgId REG_SZ ChromeHTML\r\n`
);

const originalPath = process.env.PATH;
process.env.PATH = `${poisonBin};${originalPath}`;

try {
await getWindowsPrivateBrowserApp();
expect(fs.existsSync(evidenceFile), 'Malicious reg.exe should NOT have been executed').to.be.false;
} finally {
process.env.PATH = originalPath;
fs.rmSync(poisonDir, { recursive: true, force: true });
}
});
});
93 changes: 92 additions & 1 deletion test/unit/org/open.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ import { MockTestOrgData, shouldThrow, TestContext } from '@salesforce/core/test
import { stubSfCommandUx, stubSpinner, stubUx } from '@salesforce/sf-plugins-core';
import { OrgOpenCommand } from '../../../src/commands/org/open.js';
import { OrgOpenOutput } from '../../../src/shared/orgTypes.js';
import utils from '../../../src/shared/orgOpenUtils.js';
import utils, { getWindowsPrivateBrowserApp } from '../../../src/shared/orgOpenUtils.js';

Messages.importMessagesDirectoryFromMetaUrl(import.meta.url);
const messages = Messages.loadMessages('@salesforce/plugin-org', 'open');
Expand Down Expand Up @@ -347,3 +347,94 @@ describe('org:open', () => {
});
});
});

describe('getWindowsPrivateBrowserApp', () => {
const makeExecStub =
(stdout: string) =>
async (_cmd: string, _args: string[]): Promise<{ stdout: string }> => ({ stdout });

it('detects Chrome as default browser', async () => {
const result = await getWindowsPrivateBrowserApp(
makeExecStub('HKEY_CURRENT_USER\\Software\\...\\UserChoice\r\n ProgId REG_SZ ChromeHTML\r\n')
);
expect(result.arguments).to.deep.equal(['--incognito']);
});

it('detects Firefox as default browser', async () => {
const result = await getWindowsPrivateBrowserApp(
makeExecStub('HKEY_CURRENT_USER\\Software\\...\\UserChoice\r\n ProgId REG_SZ FirefoxURL\r\n')
);
expect(result.arguments).to.deep.equal(['--private-window']);
});

it('detects Edge as default browser', async () => {
const result = await getWindowsPrivateBrowserApp(
makeExecStub('HKEY_CURRENT_USER\\Software\\...\\UserChoice\r\n ProgId REG_SZ MSEdgeHTM\r\n')
);
expect(result.arguments).to.deep.equal(['--inPrivate']);
});

it('detects Brave as default browser', async () => {
const result = await getWindowsPrivateBrowserApp(
makeExecStub('HKEY_CURRENT_USER\\Software\\...\\UserChoice\r\n ProgId REG_SZ BraveHTML\r\n')
);
expect(result.arguments).to.deep.equal(['--incognito']);
});

it('handles hyphen-suffixed ProgIds (e.g. FirefoxURL-6F193CCC56814779)', async () => {
const result = await getWindowsPrivateBrowserApp(
makeExecStub(
'HKEY_CURRENT_USER\\Software\\...\\UserChoice\r\n ProgId REG_SZ FirefoxURL-6F193CCC56814779\r\n'
)
);
expect(result.arguments).to.deep.equal(['--private-window']);
});

it('handles dot-suffixed ProgIds (e.g. ChromeHTML.ABC123)', async () => {
const result = await getWindowsPrivateBrowserApp(
makeExecStub('HKEY_CURRENT_USER\\Software\\...\\UserChoice\r\n ProgId REG_SZ ChromeHTML.ABC123\r\n')
);
expect(result.arguments).to.deep.equal(['--incognito']);
});

it('throws for unsupported browser ProgId', async () => {
try {
await getWindowsPrivateBrowserApp(
makeExecStub('HKEY_CURRENT_USER\\Software\\...\\UserChoice\r\n ProgId REG_SZ UnknownBrowser\r\n')
);
assert.fail('should have thrown');
} catch (e) {
assert(e instanceof SfError);
expect(e.message).to.include('Unsupported default browser');
}
});

it('throws when registry output cannot be parsed', async () => {
try {
await getWindowsPrivateBrowserApp(makeExecStub('unexpected output'));
assert.fail('should have thrown');
} catch (e) {
assert(e instanceof SfError);
expect(e.message).to.include('Unable to detect default browser');
}
});

it('uses fully-qualified reg.exe path from SYSTEMROOT', async () => {
const originalSystemRoot = process.env.SYSTEMROOT;
process.env.SYSTEMROOT = 'D:\\Windows';
let capturedCmd = '';
const execStub = async (cmd: string, _args: string[]): Promise<{ stdout: string }> => {
capturedCmd = cmd;
return { stdout: ' ProgId REG_SZ ChromeHTML\r\n' };
};

await getWindowsPrivateBrowserApp(execStub);
expect(capturedCmd).to.equal('D:\\Windows\\System32\\reg.exe');

if (originalSystemRoot === undefined) {
delete process.env.SYSTEMROOT;
} else {
process.env.SYSTEMROOT = originalSystemRoot;
}
});
});
8 changes: 4 additions & 4 deletions test/unit/org/open/agent.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -161,7 +161,7 @@ describe('org:open:agent', () => {
it('builds URL with api-name and version using BotVersion query', async () => {
const version = '2';
// Override the singleRecordQuery stub to handle determineOrg, BotDefinition, and BotVersion

const singleRecordQueryStub = spies.get('singleRecordQuery');
singleRecordQueryStub.callsFake((query: string) => {
if (query.includes('FROM BotVersion')) return Promise.resolve({ Id: mockVersionId });
Expand Down Expand Up @@ -295,7 +295,7 @@ describe('org:open:agent', () => {
await OrgOpenAgent.run(['--json', '--target-org', testOrg.username, '--api-name', mockBotName, '--private']);

expect(spies.get('open').callCount).to.equal(1);
expect(spies.get('open').args[0][1]).to.have.property('newInstance');
expect(spies.get('open').args[0][1]).to.have.property('app');
});

it('opens in private mode with authoring-bundle', async () => {
Expand All @@ -309,7 +309,7 @@ describe('org:open:agent', () => {
]);

expect(spies.get('open').callCount).to.equal(1);
expect(spies.get('open').args[0][1]).to.have.property('newInstance');
expect(spies.get('open').args[0][1]).to.have.property('app');
});
});

Expand Down Expand Up @@ -375,7 +375,7 @@ describe('org:open:agent', () => {

it('throws helpful error when agent version does not exist', async () => {
const nonExistentVersion = '999';

const singleRecordQueryStub = spies.get('singleRecordQuery');
singleRecordQueryStub.callsFake((query: string) => {
if (query.includes('FROM BotVersion')) return Promise.reject(new Error('No records found'));
Expand Down
Loading
Loading