From f5cfdd8560a1926c1acfd1ea0eac7df87bef69ea Mon Sep 17 00:00:00 2001 From: PageZ948 Date: Wed, 19 Aug 2026 21:19:20 +0000 Subject: [PATCH] fix: add shell-free proxy process manager --- src/proxy-launch.js | 211 +++++++++++++++++++++++++ test/proxy-launch.test.js | 318 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 529 insertions(+) create mode 100644 src/proxy-launch.js create mode 100644 test/proxy-launch.test.js diff --git a/src/proxy-launch.js b/src/proxy-launch.js new file mode 100644 index 0000000..aef53c2 --- /dev/null +++ b/src/proxy-launch.js @@ -0,0 +1,211 @@ +'use strict'; + +const nodeFs = require('node:fs'); +const nodePath = require('node:path'); +const { spawn: nodeSpawn } = require('node:child_process'); + +const HELPER_FILENAME = 'aware-cam-proxy.exe'; +const MAX_COOKIE_LENGTH = 4096; +const MAX_HOST_LENGTH = 253; +const DEVICE_ID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; +const DNS_LABEL_PATTERN = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/; +const ALTA_SUFFIXES = ['.avasecurity.com', '.avigilon.com']; + +class ProxyLaunchError extends Error { + constructor(message, code) { + super(message); + this.name = 'ProxyLaunchError'; + this.code = code; + } +} + +function validateDeploymentHost(value) { + if (typeof value !== 'string' || value.length === 0 || value.length > MAX_HOST_LENGTH) { + throw new ProxyLaunchError('Deployment host is invalid.', 'INVALID_DEPLOYMENT_HOST'); + } + + const host = value.toLowerCase(); + const labels = host.split('.'); + const hasAltaSuffix = ALTA_SUFFIXES.some((suffix) => host.endsWith(suffix)); + if (!hasAltaSuffix || labels.some((label) => !DNS_LABEL_PATTERN.test(label))) { + throw new ProxyLaunchError('Deployment host is invalid.', 'INVALID_DEPLOYMENT_HOST'); + } + + return host; +} + +function validateDeviceId(value) { + if (typeof value !== 'string' || value.length !== 36 || !DEVICE_ID_PATTERN.test(value)) { + throw new ProxyLaunchError('Device identifier is invalid.', 'INVALID_DEVICE_ID'); + } + return value.toLowerCase(); +} + +function validateCookie(value) { + if (typeof value !== 'string' || value.length === 0 || value.length > MAX_COOKIE_LENGTH) { + throw new ProxyLaunchError('Cookie is invalid.', 'INVALID_COOKIE'); + } + if (/[\u0000-\u001f\u007f]/.test(value)) { + throw new ProxyLaunchError('Cookie must not contain control characters.', 'INVALID_COOKIE'); + } + return value; +} + +function redactMessage(error, secret) { + const source = error && typeof error.message === 'string' ? error.message : 'Unknown process error'; + const withoutSecret = secret ? source.split(secret).join('[REDACTED]') : source; + return withoutSecret.replace(/[\u0000-\u001f\u007f]/g, ' ').slice(0, 512); +} + +function safeMetadata(entry, status = entry.status) { + return { + processId: entry.processId, + deviceId: entry.deviceId, + startedAt: entry.startedAt, + status + }; +} + +function createProxyManager({ + appDirectory, + fs = nodeFs, + spawn = nodeSpawn, + platform = process.platform, + now = Date.now +} = {}) { + if (platform !== 'win32') { + throw new ProxyLaunchError('Proxy helper is supported only on the Windows platform.', 'UNSUPPORTED_PLATFORM'); + } + if (typeof appDirectory !== 'string' || !nodePath.win32.isAbsolute(appDirectory)) { + throw new ProxyLaunchError('Approved application directory must be an absolute path.', 'INVALID_APP_DIRECTORY'); + } + if (typeof fs.existsSync !== 'function' || typeof spawn !== 'function' || typeof now !== 'function') { + throw new TypeError('Invalid proxy manager dependency.'); + } + + const helperPath = nodePath.win32.join(appDirectory, HELPER_FILENAME); + const trackedChildren = new Map(); + + function removeIfOwned(processId, child) { + const current = trackedChildren.get(processId); + if (current && current.child === child) trackedChildren.delete(processId); + } + + function launchProxy(request) { + if (!request || typeof request !== 'object' || Array.isArray(request)) { + throw new ProxyLaunchError('Proxy launch request is invalid.', 'INVALID_REQUEST'); + } + + const deploymentHost = validateDeploymentHost(request.deploymentHost); + const deviceId = validateDeviceId(request.deviceId); + const cookie = validateCookie(request.cookie); + + if (!fs.existsSync(helperPath)) { + throw new ProxyLaunchError('Proxy helper was not found in the approved application directory.', 'HELPER_NOT_FOUND'); + } + + let child; + try { + child = spawn( + helperPath, + ['-a', deploymentHost, '-d', deviceId, '-k', cookie], + { + shell: false, + detached: false, + stdio: 'ignore', + windowsHide: false + } + ); + } catch (error) { + throw new ProxyLaunchError( + `Failed to launch proxy helper: ${redactMessage(error, cookie)}`, + 'SPAWN_FAILED' + ); + } + + if (!child || !Number.isSafeInteger(child.pid) || child.pid <= 0 || typeof child.kill !== 'function') { + throw new ProxyLaunchError('Proxy helper did not return a valid child process.', 'INVALID_CHILD_PROCESS'); + } + if (trackedChildren.has(child.pid)) { + try { + child.kill('SIGTERM'); + } catch { + // The new child is deliberately not tracked when its PID collides. + } + throw new ProxyLaunchError('Proxy helper returned a process identifier already in use.', 'DUPLICATE_PROCESS_ID'); + } + + const entry = { + child, + processId: child.pid, + deviceId, + startedAt: now(), + status: 'running' + }; + trackedChildren.set(entry.processId, entry); + + if (typeof child.once === 'function') { + child.once('exit', () => removeIfOwned(entry.processId, child)); + child.once('error', () => removeIfOwned(entry.processId, child)); + } + + return { success: true, ...safeMetadata(entry) }; + } + + function stopProxy(processId) { + if (!Number.isSafeInteger(processId) || processId <= 0) { + throw new ProxyLaunchError('Process identifier is invalid.', 'INVALID_PROCESS_ID'); + } + + const entry = trackedChildren.get(processId); + if (!entry) return { success: false, processId, status: 'not-tracked' }; + + try { + const requested = entry.child.kill('SIGTERM'); + if (!requested) { + removeIfOwned(processId, entry.child); + return { + success: true, + processId, + deviceId: entry.deviceId, + status: 'already-exited' + }; + } + + removeIfOwned(processId, entry.child); + return { + success: true, + processId, + deviceId: entry.deviceId, + status: 'stop-requested' + }; + } catch (error) { + const permissionDenied = error && (error.code === 'EPERM' || error.code === 'EACCES'); + return { + success: false, + processId, + deviceId: entry.deviceId, + status: permissionDenied ? 'permission-denied' : 'stop-failed', + message: permissionDenied + ? 'Unable to stop the tracked proxy process: permission denied.' + : 'Unable to stop the tracked proxy process.' + }; + } + } + + function listTrackedProxies() { + return Array.from(trackedChildren.values(), (entry) => safeMetadata(entry)); + } + + return Object.freeze({ launchProxy, stopProxy, listTrackedProxies }); +} + +module.exports = { + HELPER_FILENAME, + MAX_COOKIE_LENGTH, + ProxyLaunchError, + createProxyManager, + validateCookie, + validateDeploymentHost, + validateDeviceId +}; diff --git a/test/proxy-launch.test.js b/test/proxy-launch.test.js new file mode 100644 index 0000000..373e7ff --- /dev/null +++ b/test/proxy-launch.test.js @@ -0,0 +1,318 @@ +'use strict'; + +const test = require('node:test'); +const assert = require('node:assert/strict'); +const { EventEmitter } = require('node:events'); +const fs = require('node:fs'); +const path = require('node:path'); + +const MODULE_PATH = '../src/proxy-launch'; +const APPROVED_DIRECTORY = 'C:\\Program Files\\Alta Proxy Tool'; +const APPROVED_HELPER = 'C:\\Program Files\\Alta Proxy Tool\\aware-cam-proxy.exe'; +const VALID_HOST = 'tenant.avasecurity.com'; +const VALID_DEVICE_ID = '123e4567-e89b-42d3-a456-426614174000'; +const SYNTHETIC_COOKIE = 'va=HERMES_SENTINEL_SECRET'; + +function loadModule() { + return require(MODULE_PATH); +} + +class FakeChild extends EventEmitter { + constructor(pid, killResult = true) { + super(); + this.pid = pid; + this.killResult = killResult; + this.killCalls = []; + this.killError = null; + } + + kill(signal) { + this.killCalls.push(signal); + if (this.killError) throw this.killError; + return this.killResult; + } +} + +function createHarness({ helperExists = true, children = [new FakeChild(4101)] } = {}) { + const calls = []; + let childIndex = 0; + const spawn = (...args) => { + calls.push(args); + return children[childIndex++]; + }; + const fsStub = { existsSync: (candidate) => helperExists && candidate === APPROVED_HELPER }; + const { createProxyManager } = loadModule(); + const manager = createProxyManager({ + appDirectory: APPROVED_DIRECTORY, + fs: fsStub, + spawn, + platform: 'win32', + now: () => 1_777_777_777_777 + }); + return { manager, calls, children }; +} + +function validRequest(overrides = {}) { + return { + deploymentHost: VALID_HOST, + deviceId: VALID_DEVICE_ID, + cookie: SYNTHETIC_COOKIE, + ...overrides + }; +} + +test('exports the proxy manager module', () => { + assert.doesNotThrow(() => loadModule()); +}); + +test('launches the approved helper directly with exact argv and shell disabled', () => { + const { manager, calls } = createHarness(); + + const result = manager.launchProxy(validRequest()); + + assert.deepEqual(calls, [[ + APPROVED_HELPER, + ['-a', VALID_HOST, '-d', VALID_DEVICE_ID, '-k', SYNTHETIC_COOKIE], + { + shell: false, + detached: false, + stdio: 'ignore', + windowsHide: false + } + ]]); + assert.deepEqual(result, { + success: true, + processId: 4101, + deviceId: VALID_DEVICE_ID, + startedAt: 1_777_777_777_777, + status: 'running' + }); +}); + +test('passes cookie metacharacters literally in argv without invoking a shell', () => { + const cookie = 'va=abc&whoami|calc.exe;<>()^%!"`$'; + const { manager, calls } = createHarness(); + + manager.launchProxy(validRequest({ cookie })); + + assert.equal(calls[0][1][5], cookie); + assert.equal(calls[0][2].shell, false); +}); + +test('rejects the CRLF calc.exe reproducer in every structured input', () => { + const { manager, calls } = createHarness(); + const attacks = [ + { deploymentHost: `${VALID_HOST}\r\ncalc.exe` }, + { deviceId: `${VALID_DEVICE_ID}\r\ncalc.exe` }, + { cookie: `${SYNTHETIC_COOKIE}\r\ncalc.exe` }, + { cookie: `${SYNTHETIC_COOKIE}\0calc.exe` } + ]; + + for (const attack of attacks) { + assert.throws(() => manager.launchProxy(validRequest(attack)), /invalid|must not contain/i); + } + assert.equal(calls.length, 0); +}); + +test('rejects shell metacharacters in deployment hosts and device identifiers', () => { + const { manager, calls } = createHarness(); + for (const deploymentHost of [ + 'tenant.avasecurity.com&calc.exe', + 'tenant.avasecurity.com|calc.exe', + 'https://tenant.avasecurity.com', + 'tenant.avasecurity.com/path', + 'tenant.avasecurity.com:443' + ]) { + assert.throws(() => manager.launchProxy(validRequest({ deploymentHost })), /deployment host/i); + } + for (const deviceId of [ + `${VALID_DEVICE_ID}&calc.exe`, + `${VALID_DEVICE_ID}|calc.exe`, + '$(calc.exe)' + ]) { + assert.throws(() => manager.launchProxy(validRequest({ deviceId })), /device identifier/i); + } + assert.equal(calls.length, 0); +}); + +test('accepts only strict canonical UUID device identifiers', () => { + const invalidIds = [ + '', + '123e4567-e89b-12d3-a456-42661417400', + '123e4567e89b42d3a456426614174000', + '123e4567-e89b-02d3-a456-426614174000', + '123e4567-e89b-42d3-c456-426614174000', + 'g23e4567-e89b-42d3-a456-426614174000', + 'a'.repeat(37) + ]; + + for (const deviceId of invalidIds) { + const { manager } = createHarness(); + assert.throws(() => manager.launchProxy(validRequest({ deviceId })), /device identifier/i); + } +}); + +test('accepts only bounded Alta subdomain hostnames from trusted session state', () => { + const invalidHosts = [ + '', + 'avasecurity.com', + 'avigilon.com', + 'evil.example', + 'tenant.avasecurity.com.evil.example', + '.avasecurity.com', + '-tenant.avasecurity.com', + 'tenant..avasecurity.com', + 'tenant_avasecurity.com', + `tenant.${'a'.repeat(240)}.avasecurity.com` + ]; + + for (const deploymentHost of invalidHosts) { + const { manager } = createHarness(); + assert.throws(() => manager.launchProxy(validRequest({ deploymentHost })), /deployment host/i); + } +}); + +test('normalizes a valid Alta hostname to lowercase', () => { + const { manager, calls } = createHarness(); + manager.launchProxy(validRequest({ deploymentHost: 'Tenant.AVIGILON.com' })); + assert.equal(calls[0][1][1], 'tenant.avigilon.com'); +}); + +test('rejects empty, oversized, and control-character cookies', () => { + for (const cookie of ['', 'x'.repeat(4097), 'va=abc\nxyz', 'va=abc\rxyz', 'va=abc\0xyz']) { + const { manager } = createHarness(); + assert.throws(() => manager.launchProxy(validRequest({ cookie })), /cookie/i); + } +}); + +test('fails closed when the fixed helper is missing and never spawns', () => { + const { manager, calls } = createHarness({ helperExists: false }); + assert.throws(() => manager.launchProxy(validRequest()), /helper.*not found/i); + assert.equal(calls.length, 0); +}); + +test('requires an absolute approved application directory and Windows platform', () => { + const { createProxyManager } = loadModule(); + const dependencies = { fs: { existsSync: () => true }, spawn: () => new FakeChild(1) }; + + assert.throws( + () => createProxyManager({ appDirectory: '..\\untrusted', platform: 'win32', ...dependencies }), + /absolute/i + ); + assert.throws( + () => createProxyManager({ appDirectory: '/opt/apt', platform: 'linux', ...dependencies }), + /platform/i + ); +}); + +test('redacts the cookie if spawn throws an error containing it', () => { + const { createProxyManager } = loadModule(); + const manager = createProxyManager({ + appDirectory: APPROVED_DIRECTORY, + fs: { existsSync: () => true }, + platform: 'win32', + spawn: () => { throw new Error(`spawn failed for ${SYNTHETIC_COOKIE}`); } + }); + + assert.throws( + () => manager.launchProxy(validRequest()), + (error) => { + assert.match(error.message, /spawn failed/); + assert.match(error.message, /\[REDACTED\]/); + assert.doesNotMatch(error.message, /HERMES_SENTINEL_SECRET/); + return true; + } + ); +}); + +test('tracks only safe process metadata and never exposes or persists cookies', () => { + const { manager } = createHarness(); + manager.launchProxy(validRequest()); + + const tracked = manager.listTrackedProxies(); + assert.deepEqual(tracked, [{ + processId: 4101, + deviceId: VALID_DEVICE_ID, + startedAt: 1_777_777_777_777, + status: 'running' + }]); + assert.doesNotMatch(JSON.stringify(tracked), /HERMES_SENTINEL_SECRET/); +}); + +test('stopping one tracked process leaves the other tracked process alive', () => { + const first = new FakeChild(4101); + const second = new FakeChild(4102); + const { manager } = createHarness({ children: [first, second] }); + manager.launchProxy(validRequest()); + manager.launchProxy(validRequest({ deviceId: '123e4567-e89b-42d3-a456-426614174001' })); + + const result = manager.stopProxy(4101); + + assert.deepEqual(result, { + success: true, + processId: 4101, + deviceId: VALID_DEVICE_ID, + status: 'stop-requested' + }); + assert.deepEqual(first.killCalls, ['SIGTERM']); + assert.deepEqual(second.killCalls, []); + assert.deepEqual(manager.listTrackedProxies().map((item) => item.processId), [4102]); +}); + +test('never stops an untracked PID', () => { + const child = new FakeChild(4101); + const { manager } = createHarness({ children: [child] }); + manager.launchProxy(validRequest()); + + assert.deepEqual(manager.stopProxy(9999), { + success: false, + processId: 9999, + status: 'not-tracked' + }); + assert.deepEqual(child.killCalls, []); +}); + +test('reports already-exited and permission-denied states honestly', () => { + const exited = new FakeChild(4101, false); + const denied = new FakeChild(4102); + denied.killError = Object.assign(new Error('operation not permitted'), { code: 'EPERM' }); + const { manager } = createHarness({ children: [exited, denied] }); + manager.launchProxy(validRequest()); + manager.launchProxy(validRequest({ deviceId: '123e4567-e89b-42d3-a456-426614174001' })); + + assert.deepEqual(manager.stopProxy(4101), { + success: true, + processId: 4101, + deviceId: VALID_DEVICE_ID, + status: 'already-exited' + }); + assert.deepEqual(manager.stopProxy(4102), { + success: false, + processId: 4102, + deviceId: '123e4567-e89b-42d3-a456-426614174001', + status: 'permission-denied', + message: 'Unable to stop the tracked proxy process: permission denied.' + }); + assert.deepEqual(manager.listTrackedProxies().map((item) => item.processId), [4102]); +}); + +test('exit events remove only the matching owned child', () => { + const original = new FakeChild(4101); + const replacement = new FakeChild(4101); + const { manager } = createHarness({ children: [original, replacement] }); + manager.launchProxy(validRequest()); + original.emit('exit', 0); + manager.launchProxy(validRequest({ deviceId: '123e4567-e89b-42d3-a456-426614174001' })); + + original.emit('exit', 0); + + assert.deepEqual(manager.listTrackedProxies().map((item) => item.processId), [4101]); +}); + +test('source contains no shell launchers, broad process killers, or persistence APIs', () => { + const source = fs.readFileSync(path.join(__dirname, '..', 'src', 'proxy-launch.js'), 'utf8'); + assert.doesNotMatch(source, /\b(?:cmd(?:\.exe)?|powershell|taskkill|pkill|wmic)\b/i); + assert.doesNotMatch(source, /\.(?:bat|command)\b/i); + assert.doesNotMatch(source, /(?:writeFile|appendFile|mkdtemp|tmpdir)/); + assert.match(source, /shell:\s*false/); +});