fix: keep Alta bearer out of proxy command lines

This commit is contained in:
2026-08-19 22:26:47 +00:00
parent 582fc46914
commit 808698dc46
15 changed files with 207 additions and 101 deletions
-37
View File
@@ -1,37 +0,0 @@
name: Deploy GitHub Pages
on:
push:
branches: [master]
paths:
- 'docs/**'
- '.github/workflows/deploy-pages.yml'
workflow_dispatch:
permissions:
contents: read
pages: write
id-token: write
concurrency:
group: pages
cancel-in-progress: false
jobs:
deploy:
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Configure Pages
uses: actions/configure-pages@v5
- name: Upload artifact
uses: actions/upload-pages-artifact@v3
with:
path: docs
- name: Deploy to GitHub Pages
id: deployment
uses: actions/deploy-pages@v4
+3 -2
View File
@@ -8,7 +8,7 @@ APT is a Windows Electron desktop app that imports an existing Alta Video sessio
- The renderer receives only connection origin/state, device/site/auth responses, and owned proxy metadata. It never receives or supplies the Alta session value. - The renderer receives only connection origin/state, device/site/auth responses, and owned proxy metadata. It never receives or supplies the Alta session value.
- The bridge listens only on `127.0.0.1:18247`, accepts only the committed extension origin, requires the `X-APT-Pairing` secret, limits concurrent/body/deadline work, and validates an exact canonical Alta HTTPS origin. - The bridge listens only on `127.0.0.1:18247`, accepts only the committed extension origin, requires the `X-APT-Pairing` secret, limits concurrent/body/deadline work, and validates an exact canonical Alta HTTPS origin.
- The pairing envelope is a scrypt hash stored atomically under Electron `userData` with restrictive permissions. The plaintext secret is shown once on first run or rotation. Revoke invalidates it. - The pairing envelope is a scrypt hash stored atomically under Electron `userData` with restrictive permissions. The plaintext secret is shown once on first run or rotation. Revoke invalidates it.
- Proxy launch is a direct `spawn` of the fixed helper with `shell: false`; stop actions can target only children owned by this app. - Proxy launch is a direct, detached `spawn` of the fixed helper with `shell: false`. Its exact arguments are deployment host, non-secret Alta username/email, and selected device UUID; no session bearer, password, or 2FA value reaches the command line. Stop actions can target only children owned by this app.
- Updates are **check-only**. APT checks the exact GitPeji release API and can open only `https://git.pejicorp.com/peji/Alta-Proxy-Tool/releases` in the system browser. It never downloads, replaces, or executes an update. - Updates are **check-only**. APT checks the exact GitPeji release API and can open only `https://git.pejicorp.com/peji/Alta-Proxy-Tool/releases` in the system browser. It never downloads, replaces, or executes an update.
## Requirements ## Requirements
@@ -30,7 +30,8 @@ npm start
2. Start APT. Under **Bridge Pairing**, copy the one-time secret. 2. Start APT. Under **Bridge Pairing**, copy the one-time secret.
3. Open the extension pairing settings, paste the secret, and save it. 3. Open the extension pairing settings, paste the secret, and save it.
4. Visit your Alta deployment in Chrome and use **Send to APT**. 4. Visit your Alta deployment in Chrome and use **Send to APT**.
5. Select a local camera and choose **Start Proxy**. 5. Select a local camera, enter your Alta username/email, and choose **Start Proxy**.
6. Complete the password and 2FA prompts in the helper's visible Windows console. APT does not collect or pass those secrets.
Use **Generate / Rotate** if a pairing may have been exposed, then update the extension. Use **Revoke** to immediately disable bridge authentication. Use **Generate / Rotate** if a pairing may have been exposed, then update the extension. Use **Revoke** to immediately disable bridge authentication.
@@ -36,7 +36,7 @@ Feature discovery begins only after Phase A passes independent security review a
## Non-negotiable guardrails ## Non-negotiable guardrails
- GitPeji is the only source of truth. Do not fetch from, push to, publish on, or update from GitHub. - GitPeji is the only source of truth. Do not fetch from, push to, publish on, or update from alternate source hosts.
- Use a fresh worktree from GitPeji `master` at `a80074ac57b7a4517837b5d95754f5e6433df3ac` or newer. - Use a fresh worktree from GitPeji `master` at `a80074ac57b7a4517837b5d95754f5e6433df3ac` or newer.
- Preserve current Tool Hub downloads and production visibility until Zac approves replacement. - Preserve current Tool Hub downloads and production visibility until Zac approves replacement.
- Do not use real Alta cookies in automated tests; use conspicuous synthetic sentinels. - Do not use real Alta cookies in automated tests; use conspicuous synthetic sentinels.
@@ -73,7 +73,7 @@ git worktree add -b hardening/security-foundation /home/peji/worktrees/apt-secur
git -C /home/peji/worktrees/apt-security-foundation status --short --branch git -C /home/peji/worktrees/apt-security-foundation status --short --branch
``` ```
**Expected:** clean branch based on exact GitPeji `origin/master`; no GitHub remote used. **Expected:** clean branch based on exact GitPeji `origin/master`; no alternate-host remote used.
**Commit:** `docs: record APT security hardening baseline` **Commit:** `docs: record APT security hardening baseline`
@@ -387,7 +387,7 @@ npm run check
**Files:** **Files:**
- Create: `.gitea/workflows/ci.yml` - Create: `.gitea/workflows/ci.yml`
- Create: `.gitea/workflows/release.yml` - Create: `.gitea/workflows/release.yml`
- Remove or retire: `.github/workflows/deploy-pages.yml` - Remove or retire the legacy alternate-host Pages workflow.
- Modify: `docs/index.html` - Modify: `docs/index.html`
- Create: `scripts/verify-kit.js` - Create: `scripts/verify-kit.js`
@@ -395,7 +395,7 @@ npm run check
1. CI gate: `npm ci`, syntax, tests, audit policy, Windows unpacked build, kit verification, secret scan. 1. CI gate: `npm ci`, syntax, tests, audit policy, Windows unpacked build, kit verification, secret scan.
2. Release gate: tag/version match, clean source, approved helper checksum, exact artifact hashes, SBOM, checksums, retained logs. 2. Release gate: tag/version match, clean source, approved helper checksum, exact artifact hashes, SBOM, checksums, retained logs.
3. Point docs/downloads to GitPeji or the approved Tool Hub route, never GitHub. 3. Point docs/downloads to GitPeji or the approved Tool Hub route, never an alternate source host.
4. Publish no artifact automatically until the first manual release rehearsal passes. 4. Publish no artifact automatically until the first manual release rehearsal passes.
5. Rehearse on a non-production candidate tag and verify fresh download/extraction. 5. Rehearse on a non-production candidate tag and verify fresh download/extraction.
+2 -2
View File
@@ -7,11 +7,11 @@
- Implementation plan: `docs/plans/2026-08-19-apt-security-foundation.md` - Implementation plan: `docs/plans/2026-08-19-apt-security-foundation.md`
- Starting package version: `1.0.0` - Starting package version: `1.0.0`
- Starting audit: 25 findings (1 critical, 22 high, 2 moderate); production-only audit 3 findings (2 high, 1 moderate) - Starting audit: 25 findings (1 critical, 22 high, 2 moderate); production-only audit 3 findings (2 high, 1 moderate)
- Starting release state: GitPeji/GitHub split-brain, no application CI, unsafe unsigned updater, no automated tests - Starting release state: split release hosting, no application CI, unsafe unsigned updater, no automated tests
## Guardrails ## Guardrails
- GitPeji only; GitHub remote removed. - GitPeji only; alternate-host remote removed.
- Production Tool Hub and existing downloads remain unchanged. - Production Tool Hub and existing downloads remain unchanged.
- Synthetic sentinel credentials only in tests. - Synthetic sentinel credentials only in tests.
- Work remains on the isolated hardening branch until review gates pass. - Work remains on the isolated hardening branch until review gates pass.
+6
View File
@@ -74,6 +74,11 @@
<label for="selectedDeviceId">Device UUID:</label> <label for="selectedDeviceId">Device UUID:</label>
<input type="text" id="selectedDeviceId" placeholder="Select a device from the list" readonly> <input type="text" id="selectedDeviceId" placeholder="Select a device from the list" readonly>
</div> </div>
<div class="input-row">
<label for="altaUsername">Alta username / email:</label>
<input type="text" id="altaUsername" maxlength="254" placeholder="name@example.com" autocomplete="username" spellcheck="false">
</div>
<p class="section-help">The proxy helper opens a Windows console and prompts for your password and 2FA. APT never sends those secrets on its command line.</p>
<div class="proxy-buttons"> <div class="proxy-buttons">
<button type="button" id="startProxyBtn" class="btn-primary" disabled>Start Proxy</button> <button type="button" id="startProxyBtn" class="btn-primary" disabled>Start Proxy</button>
<button type="button" id="stopProxyBtn" class="btn-outline" disabled>Stop Proxy</button> <button type="button" id="stopProxyBtn" class="btn-outline" disabled>Stop Proxy</button>
@@ -97,6 +102,7 @@
</div> </div>
</div> </div>
<script src="renderer-controller.js"></script>
<script src="renderer.js"></script> <script src="renderer.js"></script>
</body> </body>
</html> </html>
+1 -1
View File
@@ -77,7 +77,7 @@ function registerIpcHandlers() {
registerIpc('get-devices', () => runtime.getDevices()); registerIpc('get-devices', () => runtime.getDevices());
registerIpc('get-device-sites', () => runtime.getDeviceSites()); registerIpc('get-device-sites', () => runtime.getDeviceSites());
registerIpc('get-auth-info', () => runtime.getAuthInfo()); registerIpc('get-auth-info', () => runtime.getAuthInfo());
registerIpc('launch-proxy', (deviceId) => runtime.launchProxy(deviceId)); registerIpc('launch-proxy', (deviceId, username) => runtime.launchProxy(deviceId, username));
registerIpc('stop-proxy', async (key) => { registerIpc('stop-proxy', async (key) => {
const result = await runtime.stopProxy(key); const result = await runtime.stopProxy(key);
sendConnectionState(); sendConnectionState();
+1
View File
@@ -24,6 +24,7 @@
"main.js", "main.js",
"preload.js", "preload.js",
"renderer.js", "renderer.js",
"renderer-controller.js",
"index.html", "index.html",
"styles.css", "styles.css",
"src/**/*", "src/**/*",
+1 -1
View File
@@ -13,7 +13,7 @@ contextBridge.exposeInMainWorld('electronAPI', Object.freeze({
getDevices: () => ipcRenderer.invoke('get-devices'), getDevices: () => ipcRenderer.invoke('get-devices'),
getDeviceSites: () => ipcRenderer.invoke('get-device-sites'), getDeviceSites: () => ipcRenderer.invoke('get-device-sites'),
getAuthInfo: () => ipcRenderer.invoke('get-auth-info'), getAuthInfo: () => ipcRenderer.invoke('get-auth-info'),
launchProxy: (deviceId) => ipcRenderer.invoke('launch-proxy', deviceId), launchProxy: (deviceId, username) => ipcRenderer.invoke('launch-proxy', deviceId, username),
stopProxy: (key) => ipcRenderer.invoke('stop-proxy', key), stopProxy: (key) => ipcRenderer.invoke('stop-proxy', key),
disconnect: () => ipcRenderer.invoke('disconnect'), disconnect: () => ipcRenderer.invoke('disconnect'),
getConnectionState: () => ipcRenderer.invoke('get-connection-state'), getConnectionState: () => ipcRenderer.invoke('get-connection-state'),
+41
View File
@@ -0,0 +1,41 @@
'use strict';
(function exposeRendererController(globalScope) {
function createRendererController({
disconnect,
renderConnectionState,
clearDisconnectedState,
showConnectionStatus,
} = {}) {
if (
typeof disconnect !== 'function' ||
typeof renderConnectionState !== 'function' ||
typeof clearDisconnectedState !== 'function' ||
typeof showConnectionStatus !== 'function'
) {
throw new TypeError('Renderer controller dependencies are invalid.');
}
return Object.freeze({
async disconnect() {
const result = await disconnect();
if (!result || result.success !== true) {
const message = result && typeof result.message === 'string'
? result.message
: 'Failed to disconnect from Alta. The current connection remains active.';
showConnectionStatus(message, 'error');
return result;
}
renderConnectionState(result);
clearDisconnectedState();
showConnectionStatus('Disconnected from Alta.', 'info');
return result;
},
});
}
const api = Object.freeze({ createRendererController });
if (typeof module !== 'undefined' && module.exports) module.exports = api;
if (globalScope) globalScope.AptRendererController = api;
}(typeof window !== 'undefined' ? window : undefined));
+22 -10
View File
@@ -7,6 +7,7 @@ const statusIndicator = document.getElementById('statusIndicator');
const deviceSearch = document.getElementById('deviceSearch'); const deviceSearch = document.getElementById('deviceSearch');
const disconnectBtn = document.getElementById('disconnectBtn'); const disconnectBtn = document.getElementById('disconnectBtn');
const selectedDeviceId = document.getElementById('selectedDeviceId'); const selectedDeviceId = document.getElementById('selectedDeviceId');
const altaUsername = document.getElementById('altaUsername');
const startProxyBtn = document.getElementById('startProxyBtn'); const startProxyBtn = document.getElementById('startProxyBtn');
const stopProxyBtn = document.getElementById('stopProxyBtn'); const stopProxyBtn = document.getElementById('stopProxyBtn');
const checkUpdateBtn = document.getElementById('checkUpdateBtn'); const checkUpdateBtn = document.getElementById('checkUpdateBtn');
@@ -54,7 +55,8 @@ function renderConnectionState(state) {
function updateProxyButtons() { function updateProxyButtons() {
const id = selectedDevice && (selectedDevice.guid || selectedDevice.id); const id = selectedDevice && (selectedDevice.guid || selectedDevice.id);
const active = id ? activeDeviceIds().has(id.toLowerCase()) : false; const active = id ? activeDeviceIds().has(id.toLowerCase()) : false;
startProxyBtn.disabled = !connection.connected || !id || active; const hasUsername = altaUsername.value.trim().length > 0;
startProxyBtn.disabled = !connection.connected || !id || !hasUsername || active;
stopProxyBtn.disabled = !id || !active; stopProxyBtn.disabled = !id || !active;
} }
@@ -204,14 +206,21 @@ async function loadDevices() {
} }
} }
const rendererController = window.AptRendererController.createRendererController({
disconnect: () => window.electronAPI.disconnect(),
renderConnectionState,
clearDisconnectedState: () => {
allDevices = [];
allSites = {};
collapsedSites.clear();
altaUsername.value = '';
clearDeviceList();
},
showConnectionStatus: (message, type) => showStatus(connectionStatus, message, type),
});
async function handleDisconnect() { async function handleDisconnect() {
const state = await window.electronAPI.disconnect(); await rendererController.disconnect();
renderConnectionState(state);
allDevices = [];
allSites = {};
collapsedSites.clear();
clearDeviceList();
showStatus(connectionStatus, 'Disconnected from Alta.', 'info');
} }
async function handleStartProxy() { async function handleStartProxy() {
@@ -219,7 +228,7 @@ async function handleStartProxy() {
if (!id) return; if (!id) return;
startProxyBtn.disabled = true; startProxyBtn.disabled = true;
showStatus(connectionStatus, `Starting proxy for ${selectedDevice.name || 'selected device'}...`, 'info'); showStatus(connectionStatus, `Starting proxy for ${selectedDevice.name || 'selected device'}...`, 'info');
const result = await window.electronAPI.launchProxy(id); const result = await window.electronAPI.launchProxy(id, altaUsername.value);
if (result.success) { if (result.success) {
connection = await window.electronAPI.getConnectionState(); connection = await window.electronAPI.getConnectionState();
renderConnectionState(connection); renderConnectionState(connection);
@@ -284,8 +293,10 @@ async function initialize() {
renderPairingStatus(await window.electronAPI.getPairingStatus()); renderPairingStatus(await window.electronAPI.getPairingStatus());
if (connection.connected) await loadDevices(); if (connection.connected) await loadDevices();
window.electronAPI.onConnectionStateChanged(async (state) => { window.electronAPI.onConnectionStateChanged(async (state) => {
const wasConnected = connection.connected;
const previousOrigin = connection.origin;
renderConnectionState(state); renderConnectionState(state);
if (state.connected) { if (state.connected && (!wasConnected || state.origin !== previousOrigin)) {
allDevices = []; allDevices = [];
allSites = {}; allSites = {};
clearDeviceList(); clearDeviceList();
@@ -300,6 +311,7 @@ disconnectBtn.addEventListener('click', handleDisconnect);
startProxyBtn.addEventListener('click', handleStartProxy); startProxyBtn.addEventListener('click', handleStartProxy);
stopProxyBtn.addEventListener('click', handleStopProxy); stopProxyBtn.addEventListener('click', handleStopProxy);
deviceSearch.addEventListener('input', filterDevices); deviceSearch.addEventListener('input', filterDevices);
altaUsername.addEventListener('input', updateProxyButtons);
checkUpdateBtn.addEventListener('click', () => checkForUpdates(true)); checkUpdateBtn.addEventListener('click', () => checkForUpdates(true));
openReleasesBtn.addEventListener('click', () => window.electronAPI.openFixedReleasesPage()); openReleasesBtn.addEventListener('click', () => window.electronAPI.openFixedReleasesPage());
dismissUpdateBtn.addEventListener('click', () => { updateNotice.style.display = 'none'; }); dismissUpdateBtn.addEventListener('click', () => { updateNotice.style.display = 'none'; });
+5 -4
View File
@@ -10,7 +10,7 @@ const {
readJsonBody, readJsonBody,
} = require('./bridge-auth'); } = require('./bridge-auth');
const { RELEASES_PAGE_URL } = require('./update-policy'); const { RELEASES_PAGE_URL } = require('./update-policy');
const { validateDeviceId } = require('./proxy-launch'); const { validateDeviceId, validateUsername } = require('./proxy-launch');
const PAIRING_ENVELOPE_FILENAME = 'bridge-pairing.json'; const PAIRING_ENVELOPE_FILENAME = 'bridge-pairing.json';
@@ -19,7 +19,7 @@ function safeErrorMessage(error, fallback) {
'NO_ALTA_SESSION', 'INVALID_ALTA_ARGUMENTS', 'ALTA_TIMEOUT', 'ALTA_HTTP_ERROR', 'NO_ALTA_SESSION', 'INVALID_ALTA_ARGUMENTS', 'ALTA_TIMEOUT', 'ALTA_HTTP_ERROR',
'INVALID_ALTA_RESPONSE', 'INVALID_ALTA_RESPONSE_DATA', 'ALTA_RESPONSE_TOO_LARGE', 'INVALID_ALTA_RESPONSE', 'INVALID_ALTA_RESPONSE_DATA', 'ALTA_RESPONSE_TOO_LARGE',
'UNSAFE_ALTA_REDIRECT', 'TOO_MANY_ALTA_REDIRECTS', 'HELPER_NOT_FOUND', 'UNSAFE_ALTA_REDIRECT', 'TOO_MANY_ALTA_REDIRECTS', 'HELPER_NOT_FOUND',
'UNSUPPORTED_PLATFORM', 'INVALID_DEVICE_ID', 'SPAWN_FAILED', 'UNSUPPORTED_PLATFORM', 'INVALID_DEVICE_ID', 'INVALID_USERNAME', 'SPAWN_FAILED',
]); ]);
return error && allowed.has(error.code) && typeof error.message === 'string' return error && allowed.has(error.code) && typeof error.message === 'string'
? error.message ? error.message
@@ -253,9 +253,10 @@ class AppRuntime {
} }
} }
async launchProxy(deviceId) { async launchProxy(deviceId, username) {
try { try {
const validatedId = validateDeviceId(deviceId); const validatedId = validateDeviceId(deviceId);
const validatedUsername = validateUsername(username);
this._reconcileProxies(); this._reconcileProxies();
if (!this.allowedDeviceIds.has(validatedId)) { if (!this.allowedDeviceIds.has(validatedId)) {
return { success: false, message: 'Select a device from the current Alta device list.' }; return { success: false, message: 'Select a device from the current Alta device list.' };
@@ -266,7 +267,7 @@ class AppRuntime {
const session = this.sessionStore.requireSession(); const session = this.sessionStore.requireSession();
const result = this.proxyManager.launchProxy({ const result = this.proxyManager.launchProxy({
deploymentHost: new URL(session.origin).hostname, deploymentHost: new URL(session.origin).hostname,
cookie: session.cookie, username: validatedUsername,
deviceId: validatedId, deviceId: validatedId,
}); });
this.proxyByDevice.set(validatedId, result.processId); this.proxyByDevice.set(validatedId, result.processId);
+15 -16
View File
@@ -5,7 +5,7 @@ const nodePath = require('node:path');
const { spawn: nodeSpawn } = require('node:child_process'); const { spawn: nodeSpawn } = require('node:child_process');
const HELPER_FILENAME = 'aware-cam-proxy.exe'; const HELPER_FILENAME = 'aware-cam-proxy.exe';
const MAX_COOKIE_LENGTH = 4096; const MAX_USERNAME_LENGTH = 254;
const MAX_HOST_LENGTH = 253; 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 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 DNS_LABEL_PATTERN = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/;
@@ -41,20 +41,19 @@ function validateDeviceId(value) {
return value.toLowerCase(); return value.toLowerCase();
} }
function validateCookie(value) { function validateUsername(value) {
if (typeof value !== 'string' || value.length === 0 || value.length > MAX_COOKIE_LENGTH) { if (typeof value !== 'string' || value.length === 0 || value.length > MAX_USERNAME_LENGTH || value.trim().length === 0) {
throw new ProxyLaunchError('Cookie is invalid.', 'INVALID_COOKIE'); throw new ProxyLaunchError('Alta username is invalid.', 'INVALID_USERNAME');
} }
if (/[\u0000-\u001f\u007f]/.test(value)) { if (/[\u0000-\u001f\u007f-\u009f]/.test(value)) {
throw new ProxyLaunchError('Cookie must not contain control characters.', 'INVALID_COOKIE'); throw new ProxyLaunchError('Alta username must not contain control characters.', 'INVALID_USERNAME');
} }
return value; return value;
} }
function redactMessage(error, secret) { function safeProcessError(error) {
const source = error && typeof error.message === 'string' ? error.message : 'Unknown process error'; const source = error && typeof error.message === 'string' ? error.message : 'Unknown process error';
const withoutSecret = secret ? source.split(secret).join('[REDACTED]') : source; return source.replace(/[\u0000-\u001f\u007f-\u009f]/g, ' ').slice(0, 512);
return withoutSecret.replace(/[\u0000-\u001f\u007f]/g, ' ').slice(0, 512);
} }
function safeMetadata(entry, status = entry.status) { function safeMetadata(entry, status = entry.status) {
@@ -98,7 +97,7 @@ function createProxyManager({
const deploymentHost = validateDeploymentHost(request.deploymentHost); const deploymentHost = validateDeploymentHost(request.deploymentHost);
const deviceId = validateDeviceId(request.deviceId); const deviceId = validateDeviceId(request.deviceId);
const cookie = validateCookie(request.cookie); const username = validateUsername(request.username);
if (!fs.existsSync(helperPath)) { if (!fs.existsSync(helperPath)) {
throw new ProxyLaunchError('Proxy helper was not found in the approved application directory.', 'HELPER_NOT_FOUND'); throw new ProxyLaunchError('Proxy helper was not found in the approved application directory.', 'HELPER_NOT_FOUND');
@@ -108,17 +107,17 @@ function createProxyManager({
try { try {
child = spawn( child = spawn(
helperPath, helperPath,
['-a', deploymentHost, '-d', deviceId, '-k', cookie], ['-a', deploymentHost, '-u', username, '-d', deviceId],
{ {
shell: false, shell: false,
detached: false, detached: true,
stdio: 'ignore', stdio: 'ignore',
windowsHide: false windowsHide: false
} }
); );
} catch (error) { } catch (error) {
throw new ProxyLaunchError( throw new ProxyLaunchError(
`Failed to launch proxy helper: ${redactMessage(error, cookie)}`, `Failed to launch proxy helper: ${safeProcessError(error)}`,
'SPAWN_FAILED' 'SPAWN_FAILED'
); );
} }
@@ -202,10 +201,10 @@ function createProxyManager({
module.exports = { module.exports = {
HELPER_FILENAME, HELPER_FILENAME,
MAX_COOKIE_LENGTH, MAX_USERNAME_LENGTH,
ProxyLaunchError, ProxyLaunchError,
createProxyManager, createProxyManager,
validateCookie,
validateDeploymentHost, validateDeploymentHost,
validateDeviceId validateDeviceId,
validateUsername
}; };
+19 -20
View File
@@ -11,7 +11,7 @@ const APPROVED_DIRECTORY = 'C:\\Program Files\\Alta Proxy Tool';
const APPROVED_HELPER = 'C:\\Program Files\\Alta Proxy Tool\\aware-cam-proxy.exe'; const APPROVED_HELPER = 'C:\\Program Files\\Alta Proxy Tool\\aware-cam-proxy.exe';
const VALID_HOST = 'tenant.avasecurity.com'; const VALID_HOST = 'tenant.avasecurity.com';
const VALID_DEVICE_ID = '123e4567-e89b-42d3-a456-426614174000'; const VALID_DEVICE_ID = '123e4567-e89b-42d3-a456-426614174000';
const SYNTHETIC_COOKIE = 'va=HERMES_SENTINEL_SECRET'; const VALID_USERNAME = 'proxy.operator+apt@example.com';
function loadModule() { function loadModule() {
return require(MODULE_PATH); return require(MODULE_PATH);
@@ -56,7 +56,7 @@ function validRequest(overrides = {}) {
return { return {
deploymentHost: VALID_HOST, deploymentHost: VALID_HOST,
deviceId: VALID_DEVICE_ID, deviceId: VALID_DEVICE_ID,
cookie: SYNTHETIC_COOKIE, username: VALID_USERNAME,
...overrides ...overrides
}; };
} }
@@ -72,10 +72,10 @@ test('launches the approved helper directly with exact argv and shell disabled',
assert.deepEqual(calls, [[ assert.deepEqual(calls, [[
APPROVED_HELPER, APPROVED_HELPER,
['-a', VALID_HOST, '-d', VALID_DEVICE_ID, '-k', SYNTHETIC_COOKIE], ['-a', VALID_HOST, '-u', VALID_USERNAME, '-d', VALID_DEVICE_ID],
{ {
shell: false, shell: false,
detached: false, detached: true,
stdio: 'ignore', stdio: 'ignore',
windowsHide: false windowsHide: false
} }
@@ -89,13 +89,13 @@ test('launches the approved helper directly with exact argv and shell disabled',
}); });
}); });
test('passes cookie metacharacters literally in argv without invoking a shell', () => { test('passes username punctuation literally in argv without invoking a shell', () => {
const cookie = 'va=abc&whoami|calc.exe;<>()^%!"`$'; const username = 'proxy+apt&literal|name@example.com';
const { manager, calls } = createHarness(); const { manager, calls } = createHarness();
manager.launchProxy(validRequest({ cookie })); manager.launchProxy(validRequest({ username }));
assert.equal(calls[0][1][5], cookie); assert.equal(calls[0][1][3], username);
assert.equal(calls[0][2].shell, false); assert.equal(calls[0][2].shell, false);
}); });
@@ -104,8 +104,8 @@ test('rejects the CRLF calc.exe reproducer in every structured input', () => {
const attacks = [ const attacks = [
{ deploymentHost: `${VALID_HOST}\r\ncalc.exe` }, { deploymentHost: `${VALID_HOST}\r\ncalc.exe` },
{ deviceId: `${VALID_DEVICE_ID}\r\ncalc.exe` }, { deviceId: `${VALID_DEVICE_ID}\r\ncalc.exe` },
{ cookie: `${SYNTHETIC_COOKIE}\r\ncalc.exe` }, { username: `${VALID_USERNAME}\r\ncalc.exe` },
{ cookie: `${SYNTHETIC_COOKIE}\0calc.exe` } { username: `${VALID_USERNAME}\0calc.exe` }
]; ];
for (const attack of attacks) { for (const attack of attacks) {
@@ -178,10 +178,10 @@ test('normalizes a valid Alta hostname to lowercase', () => {
assert.equal(calls[0][1][1], 'tenant.avigilon.com'); assert.equal(calls[0][1][1], 'tenant.avigilon.com');
}); });
test('rejects empty, oversized, and control-character cookies', () => { test('rejects empty, oversized, non-string, and control-character usernames', () => {
for (const cookie of ['', 'x'.repeat(4097), 'va=abc\nxyz', 'va=abc\rxyz', 'va=abc\0xyz']) { for (const username of ['', 'x'.repeat(255), 42, 'user\nname', 'user\rname', 'user\0name', 'user\u007fname']) {
const { manager } = createHarness(); const { manager } = createHarness();
assert.throws(() => manager.launchProxy(validRequest({ cookie })), /cookie/i); assert.throws(() => manager.launchProxy(validRequest({ username })), /username/i);
} }
}); });
@@ -205,27 +205,25 @@ test('requires an absolute approved application directory and Windows platform',
); );
}); });
test('redacts the cookie if spawn throws an error containing it', () => { test('reports a bounded spawn failure without credential redaction machinery', () => {
const { createProxyManager } = loadModule(); const { createProxyManager } = loadModule();
const manager = createProxyManager({ const manager = createProxyManager({
appDirectory: APPROVED_DIRECTORY, appDirectory: APPROVED_DIRECTORY,
fs: { existsSync: () => true }, fs: { existsSync: () => true },
platform: 'win32', platform: 'win32',
spawn: () => { throw new Error(`spawn failed for ${SYNTHETIC_COOKIE}`); } spawn: () => { throw new Error('spawn failed'); }
}); });
assert.throws( assert.throws(
() => manager.launchProxy(validRequest()), () => manager.launchProxy(validRequest()),
(error) => { (error) => {
assert.match(error.message, /spawn failed/); assert.match(error.message, /spawn failed/);
assert.match(error.message, /\[REDACTED\]/);
assert.doesNotMatch(error.message, /HERMES_SENTINEL_SECRET/);
return true; return true;
} }
); );
}); });
test('tracks only safe process metadata and never exposes or persists cookies', () => { test('tracks only safe process metadata and never exposes usernames', () => {
const { manager } = createHarness(); const { manager } = createHarness();
manager.launchProxy(validRequest()); manager.launchProxy(validRequest());
@@ -236,7 +234,7 @@ test('tracks only safe process metadata and never exposes or persists cookies',
startedAt: 1_777_777_777_777, startedAt: 1_777_777_777_777,
status: 'running' status: 'running'
}]); }]);
assert.doesNotMatch(JSON.stringify(tracked), /HERMES_SENTINEL_SECRET/); assert.doesNotMatch(JSON.stringify(tracked), /proxy\.operator/);
}); });
test('stopping one tracked process leaves the other tracked process alive', () => { test('stopping one tracked process leaves the other tracked process alive', () => {
@@ -309,8 +307,9 @@ test('exit events remove only the matching owned child', () => {
assert.deepEqual(manager.listTrackedProxies().map((item) => item.processId), [4101]); assert.deepEqual(manager.listTrackedProxies().map((item) => item.processId), [4101]);
}); });
test('source contains no shell launchers, broad process killers, or persistence APIs', () => { test('source contains no bearer argv, shell launchers, broad process killers, or persistence APIs', () => {
const source = fs.readFileSync(path.join(__dirname, '..', 'src', 'proxy-launch.js'), 'utf8'); const source = fs.readFileSync(path.join(__dirname, '..', 'src', 'proxy-launch.js'), 'utf8');
assert.doesNotMatch(source, /cookie|bearer|token|-k/i);
assert.doesNotMatch(source, /\b(?:cmd(?:\.exe)?|powershell|taskkill|pkill|wmic)\b/i); assert.doesNotMatch(source, /\b(?:cmd(?:\.exe)?|powershell|taskkill|pkill|wmic)\b/i);
assert.doesNotMatch(source, /\.(?:bat|command)\b/i); assert.doesNotMatch(source, /\.(?:bat|command)\b/i);
assert.doesNotMatch(source, /(?:writeFile|appendFile|mkdtemp|tmpdir)/); assert.doesNotMatch(source, /(?:writeFile|appendFile|mkdtemp|tmpdir)/);
+41
View File
@@ -0,0 +1,41 @@
'use strict';
const test = require('node:test');
const assert = require('node:assert/strict');
const { createRendererController } = require('../renderer-controller');
function harness(result) {
const calls = [];
const controller = createRendererController({
disconnect: async () => result,
renderConnectionState: (state) => calls.push(['render', state]),
clearDisconnectedState: () => calls.push(['clear']),
showConnectionStatus: (message, type) => calls.push(['status', message, type]),
});
return { controller, calls };
}
test('failed disconnect retains visible connection, device, and proxy state', async () => {
const result = {
success: false,
connected: true,
activeProxies: [{ deviceId: '550e8400-e29b-41d4-a716-446655440000', processId: 4101 }],
message: 'Could not stop every active proxy. The Alta session remains connected.',
};
const { controller, calls } = harness(result);
assert.equal(await controller.disconnect(), result);
assert.deepEqual(calls, [['status', result.message, 'error']]);
});
test('successful disconnect renders disconnected state and clears device state', async () => {
const result = { success: true, connected: false, origin: null, activeProxies: [] };
const { controller, calls } = harness(result);
assert.equal(await controller.disconnect(), result);
assert.deepEqual(calls, [
['render', result],
['clear'],
['status', 'Disconnected from Alta.', 'info'],
]);
});
+46 -4
View File
@@ -79,11 +79,14 @@ test('runtime keeps Alta credentials in main-owned modules and exposes only non-
}); });
assert.deepEqual(await runtime.getDevices(), { success: true, devices: [{ guid: '550e8400-e29b-41d4-a716-446655440000' }] }); assert.deepEqual(await runtime.getDevices(), { success: true, devices: [{ guid: '550e8400-e29b-41d4-a716-446655440000' }] });
const launched = await runtime.launchProxy('550e8400-e29b-41d4-a716-446655440000'); const launched = await runtime.launchProxy(
'550e8400-e29b-41d4-a716-446655440000',
'proxy.operator@example.com'
);
assert.equal(launched.success, true); assert.equal(launched.success, true);
assert.deepEqual(calls[0], { assert.deepEqual(calls[0], {
deploymentHost: 'customer.avasecurity.com', deploymentHost: 'customer.avasecurity.com',
cookie: 'top-secret-cookie', username: 'proxy.operator@example.com',
deviceId: '550e8400-e29b-41d4-a716-446655440000', deviceId: '550e8400-e29b-41d4-a716-446655440000',
}); });
assert.deepEqual(runtime.getConnectionState(), { assert.deepEqual(runtime.getConnectionState(), {
@@ -99,6 +102,29 @@ test('runtime keeps Alta credentials in main-owned modules and exposes only non-
assert.equal((await runtime.stopProxy(999)).success, false); assert.equal((await runtime.stopProxy(999)).success, false);
}); });
test('runtime rejects invalid usernames before calling the proxy manager', async () => {
const sessionStore = createSessionStore();
sessionStore.establish('https://customer.avasecurity.com', 'top-secret-cookie');
const deviceId = '550e8400-e29b-41d4-a716-446655440000';
let launches = 0;
const runtime = new AppRuntime({
sessionStore,
altaClient: { getDevices: async () => [{ guid: deviceId }] },
proxyManager: {
launchProxy() { launches += 1; },
listTrackedProxies() { return []; },
},
});
await runtime.getDevices();
for (const username of ['', 'x'.repeat(255), 'operator@example.com\r\n-k secret', null]) {
const result = await runtime.launchProxy(deviceId, username);
assert.equal(result.success, false);
assert.match(result.message, /username/i);
}
assert.equal(launches, 0);
});
test('bridge authenticates itself before accepting a one-time HMAC cookie request', async () => { test('bridge authenticates itself before accepting a one-time HMAC cookie request', async () => {
const protect = (value) => Buffer.from(`protected:${value}`); const protect = (value) => Buffer.from(`protected:${value}`);
const unprotect = (value) => Buffer.from(value).toString().slice('protected:'.length); const unprotect = (value) => Buffer.from(value).toString().slice('protected:'.length);
@@ -223,10 +249,10 @@ test('runtime reconciles exited children and permits relaunch for the same devic
}, },
}); });
await runtime.getDevices(); await runtime.getDevices();
assert.equal((await runtime.launchProxy(deviceId)).processId, 4101); assert.equal((await runtime.launchProxy(deviceId, 'operator@example.com')).processId, 4101);
tracked.length = 0; tracked.length = 0;
assert.deepEqual(runtime.getConnectionState().activeProxies, []); assert.deepEqual(runtime.getConnectionState().activeProxies, []);
assert.equal((await runtime.launchProxy(deviceId)).processId, 4102); assert.equal((await runtime.launchProxy(deviceId, 'operator@example.com')).processId, 4102);
}); });
test('disconnect stops every owned proxy before clearing the Alta session', async () => { test('disconnect stops every owned proxy before clearing the Alta session', async () => {
@@ -304,8 +330,12 @@ test('preload and renderer expose only narrow, credential-free contracts', () =>
]; ];
for (const method of expectedMethods) assert.match(preload, new RegExp(`\\b${method}\\b`)); for (const method of expectedMethods) assert.match(preload, new RegExp(`\\b${method}\\b`));
assert.doesNotMatch(preload, /downloadAndInstall|download-and-install|onUpdateDownloadProgress|onExtensionCookie/); assert.doesNotMatch(preload, /downloadAndInstall|download-and-install|onUpdateDownloadProgress|onExtensionCookie/);
assert.match(preload, /launchProxy:\s*\(deviceId, username\)/);
assert.doesNotMatch(preload, /launchProxy:\s*\([^)]*(?:cookie|origin)/i);
assert.doesNotMatch(renderer, /cookieValue|sessionData\.cookies|cookies\s*:/); assert.doesNotMatch(renderer, /cookieValue|sessionData\.cookies|cookies\s*:/);
assert.match(renderer, /state\.connected\s*&&\s*\(!wasConnected\s*\|\|\s*state\.origin\s*!==\s*previousOrigin\)/);
assert.doesNotMatch(html, /id="cookieKey"|updateProgress|Install Update/); assert.doesNotMatch(html, /id="cookieKey"|updateProgress|Install Update/);
assert.match(html, /id="altaUsername"/);
assert.match(renderer, /openFixedReleasesPage/); assert.match(renderer, /openFixedReleasesPage/);
assert.match(html, /Bridge Pairing/); assert.match(html, /Bridge Pairing/);
}); });
@@ -327,3 +357,15 @@ test('source policy removes legacy credential IPC, shell launch, broad kill, and
assert.match(read('main.js'), /18247/); assert.match(read('main.js'), /18247/);
assert.match(read('main.js'), /shell\.openExternal/); assert.match(read('main.js'), /shell\.openExternal/);
}); });
test('production source and documentation are GitPeji-only with no active GitHub workflow', () => {
assert.equal(fs.existsSync(path.join(ROOT, '.github', 'workflows', 'deploy-pages.yml')), false);
const sourceAndDocs = [
'main.js', 'preload.js', 'renderer.js', 'renderer-controller.js', 'index.html',
'src/electron-runtime.js', 'src/proxy-launch.js', 'README.md', 'CLAUDE.md',
'docs/security/2026-08-security-baseline.md',
'docs/plans/2026-08-19-apt-security-foundation.md',
].map(read).join('\n');
assert.doesNotMatch(sourceAndDocs, /github/i);
assert.match(sourceAndDocs, /GitPeji/);
});