feat: release passwordless hierarchical APT v1.2.5
This commit is contained in:
@@ -27,7 +27,7 @@ npm run build
|
||||
- IPC methods are narrow and sender-validated against the main local file frame.
|
||||
- Bridge is exactly `127.0.0.1:18247`, exact committed extension origin, and `X-APT-Pairing` authenticated.
|
||||
- Persist only the pairing hash envelope under Electron `userData`; plaintext is shown once after first run/rotation.
|
||||
- Proxy helper launches directly with `shell: false`; stop only app-owned child processes.
|
||||
- Proxy helper launches directly with `shell: false`; passwordless launch uses the main-owned paired session only through the helper's required `-k` argument. Never expose that cookie to renderer IPC, UI, logs, files, shell commands, or tracked process metadata; stop only app-owned child processes.
|
||||
- Update behavior is check-only against the exact GitPeji API. The only follow-up action opens the fixed GitPeji releases page externally.
|
||||
- Never add download/install/replace logic, arbitrary URLs, redirect following, shell scripts, broad process killing, hardcoded bridge secrets, or credential-bearing renderer state.
|
||||
- Synthetic tests only: never use a real tenant or Alta session.
|
||||
|
||||
@@ -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 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.
|
||||
- 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.
|
||||
- Proxy launch is a direct, detached `spawn` of the fixed helper with `shell: false`. APT passes the main-process-only paired Alta session through the helper's supported `-k` cookie mode so users are not prompted for Alta credentials again. The cookie never crosses renderer IPC, enters a shell, or reaches logs, files, tracked process metadata, or UI state; it is necessarily present in the helper process arguments because the external helper exposes no pipe/stdin credential interface. 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.
|
||||
|
||||
## Requirements
|
||||
@@ -30,8 +30,7 @@ npm start
|
||||
2. Start APT. Under **Bridge Pairing**, copy the one-time secret.
|
||||
3. Open the extension pairing settings, paste the secret, and save it.
|
||||
4. Visit your Alta deployment in Chrome and use **Send to APT**.
|
||||
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.
|
||||
5. Select a local camera and choose **Start Proxy**. APT reuses the paired Chrome session; no Alta username, password, or 2FA prompt is required.
|
||||
|
||||
Use **Generate / Rotate** if a pairing may have been exposed, then update the extension. Use **Revoke** to immediately disable bridge authentication.
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"manifest_version": 3,
|
||||
"name": "Alta Proxy Tool Bridge",
|
||||
"version": "1.2.0",
|
||||
"version": "1.2.5",
|
||||
"description": "Send Alta session cookies to a paired Alta Proxy Tool desktop app.",
|
||||
"key": "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAt4EZdkSgOsyiy5DRe0JkX+BpK94FpMjBU59NVIqDPO8QBDwvqNDWT/UjqHK/0aqSxzed5KibX6MdAvc495+u1sCybFdjDdXyBewEvg+PDqGiJketlZKC9dcR1RXHuPgAoM3NaNbMb3TqYcS9J4iGq0UwadxubkQrEcPiuyR6oriOkop8q9/5DWGb15wOGmiCuVmlXfUjNJIvNBm9P/ZHtgFBYDI2PuSSI5GI4j04VFpEyfNlFCrpi8GQ7bYZzezigZWXRjhhNwkx39bNHlkAWYa8XGZseCpKKvi0EaeCoBPjoYSAt161SM1dqX+/UC61/sLOU/SpDB1SYGTm5DC+7wIDAQAB",
|
||||
"permissions": ["cookies", "activeTab", "clipboardWrite", "storage"],
|
||||
|
||||
@@ -75,9 +75,3 @@ button:disabled { cursor: not-allowed; opacity: 0.45; }
|
||||
padding-top: 14px;
|
||||
border-top: 1px solid #3c3c3c;
|
||||
}
|
||||
|
||||
.privacy-note {
|
||||
margin-top: 24px;
|
||||
color: #aaa;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
<body>
|
||||
<main class="options-card">
|
||||
<h1>Pair with Alta Proxy Tool</h1>
|
||||
<p>In the desktop app, create a one-time pairing secret. Paste it below on this computer. Treat it like a password.</p>
|
||||
<p>Create a one-time pairing secret in APT, then paste it below.</p>
|
||||
<form id="pairingForm">
|
||||
<label for="pairingSecret">One-time pairing secret</label>
|
||||
<input id="pairingSecret" name="pairingSecret" type="password" inputmode="text" autocomplete="off" spellcheck="false" required>
|
||||
@@ -22,7 +22,7 @@
|
||||
<p id="currentState">Checking...</p>
|
||||
<button id="forgetBtn" type="button" class="danger-btn" disabled>Forget pairing</button>
|
||||
</section>
|
||||
<p class="privacy-note">The secret is stored only in Chrome extension local storage. It is never displayed again or copied automatically.</p>
|
||||
|
||||
</main>
|
||||
<script src="options.js"></script>
|
||||
</body>
|
||||
|
||||
@@ -88,14 +88,6 @@ h1 {
|
||||
opacity: 0.45;
|
||||
}
|
||||
|
||||
.copy-warning {
|
||||
margin: 10px 0 0;
|
||||
padding: 8px 10px;
|
||||
border-left: 3px solid #f0a33a;
|
||||
background: rgba(240, 163, 58, 0.08);
|
||||
color: #d7c29f;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.status-msg {
|
||||
display: none;
|
||||
|
||||
@@ -17,7 +17,6 @@
|
||||
<button id="sendBtn" class="primary-btn" type="button" disabled>Send to APT</button>
|
||||
<button id="copyBtn" class="secondary-btn" type="button" disabled>Copy VA Token</button>
|
||||
</div>
|
||||
<p id="copyWarning" class="copy-warning">Copying exposes the full bearer token. Clipboard history or sync may retain it. A confirmation is required.</p>
|
||||
<div id="statusMsg" class="status-msg" role="status" aria-live="polite"></div>
|
||||
</main>
|
||||
<script src="popup.js"></script>
|
||||
|
||||
@@ -45,7 +45,6 @@ function createPopupController({
|
||||
const pairingInfo = documentApi.getElementById('pairingInfo');
|
||||
const sendBtn = documentApi.getElementById('sendBtn');
|
||||
const copyBtn = documentApi.getElementById('copyBtn');
|
||||
const copyWarning = documentApi.getElementById('copyWarning');
|
||||
const statusMsg = documentApi.getElementById('statusMsg');
|
||||
const openOptionsBtn = documentApi.getElementById('openOptionsBtn');
|
||||
|
||||
@@ -159,9 +158,7 @@ function createPopupController({
|
||||
|
||||
async function copyToken() {
|
||||
if (!detectedOrigin || !pairingSecret || busy) return;
|
||||
const confirmed = confirmCopy(
|
||||
'Copy the full VA bearer token? Clipboard history or sync may retain it. Continue only if you will paste it into a trusted destination.'
|
||||
);
|
||||
const confirmed = confirmCopy('Copy VA token?');
|
||||
if (!confirmed) {
|
||||
showStatus('Copy cancelled. The VA token was not read.', 'info');
|
||||
return;
|
||||
@@ -172,7 +169,7 @@ function createPopupController({
|
||||
try {
|
||||
cookieValue = await getVaCookieValue();
|
||||
await navigatorApi.clipboard.writeText(cookieValue);
|
||||
showStatus('VA token copied. Clear your clipboard after use.', 'success');
|
||||
showStatus('VA token copied.', 'success');
|
||||
} catch (error) {
|
||||
showCookieError(error, true);
|
||||
} finally {
|
||||
@@ -182,7 +179,6 @@ function createPopupController({
|
||||
}
|
||||
|
||||
async function init() {
|
||||
copyWarning.textContent = 'Copying exposes the full bearer token. Clipboard history or sync may retain it. A confirmation is required.';
|
||||
sendBtn.addEventListener('click', sendToApt);
|
||||
copyBtn.addEventListener('click', copyToken);
|
||||
openOptionsBtn.addEventListener('click', () => chromeApi.runtime.openOptionsPage());
|
||||
|
||||
@@ -40,7 +40,7 @@ Feature discovery begins only after Phase A passes independent security review a
|
||||
- Use a fresh worktree from GitPeji `master` at `a80074ac57b7a4517837b5d95754f5e6433df3ac` or newer.
|
||||
- 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 write cookies to logs, disk, command files, shell strings, crash reports, analytics, or test artifacts.
|
||||
- Do not write cookies to logs, disk, command files, shell strings, crash reports, analytics, or test artifacts. The sole accepted exception is the fixed helper's required `-k` argv value for Zac's explicitly required paired-extension passwordless proxy flow; keep it main-process-only, direct-spawned with `shell: false`, redacted from errors, and absent from renderer IPC and tracked metadata.
|
||||
- No production release until the exact candidate passes controller verification plus independent security and release reviews.
|
||||
- Mac distribution remains approval-gated under Kanban task `t_42bad4d3`.
|
||||
|
||||
|
||||
@@ -16,3 +16,7 @@
|
||||
- Synthetic sentinel credentials only in tests.
|
||||
- Work remains on the isolated hardening branch until review gates pass.
|
||||
- No feature additions until Critical/Important security closure.
|
||||
|
||||
## Accepted passwordless proxy exception
|
||||
|
||||
Zac explicitly requires the paired-extension **Connect → select camera → Start Proxy** flow without re-entering Alta credentials. The external `aware-cam-proxy.exe` helper supports cookie authentication only through `-k`; it exposes no stdin, named-pipe, environment-variable, or credential-file interface. APT therefore passes the validated main-owned session directly to the fixed helper as one argv value. This exception remains bounded by direct `shell: false` spawn, trusted-device allowlisting, no renderer/IPC credential parameter, no logs/files/shells, redacted launch errors, safe tracked metadata, and synthetic regression tests.
|
||||
|
||||
+4
-6
@@ -51,11 +51,12 @@
|
||||
</div>
|
||||
<div class="connection-controls">
|
||||
<button type="button" id="disconnectBtn" class="btn-outline" disabled>Disconnect</button>
|
||||
<button type="button" id="managePairingBtn" class="btn-outline" hidden>Manage Pairing</button>
|
||||
</div>
|
||||
<div id="connectionStatus" class="status-message"></div>
|
||||
</section>
|
||||
|
||||
<section class="content-section pairing-section">
|
||||
<section id="pairingSection" class="content-section pairing-section" hidden>
|
||||
<h2>Bridge Pairing</h2>
|
||||
<p class="section-help">Only the committed APT Chrome extension can send a session. Pair it with a one-time secret.</p>
|
||||
<div class="status-row">
|
||||
@@ -70,6 +71,7 @@
|
||||
<div class="proxy-buttons">
|
||||
<button type="button" id="rotatePairingBtn" class="btn-primary">Generate / Rotate</button>
|
||||
<button type="button" id="revokePairingBtn" class="btn-outline">Revoke</button>
|
||||
<button type="button" id="hidePairingBtn" class="btn-outline" hidden>Done</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -80,11 +82,7 @@
|
||||
<label for="selectedDeviceId">Device UUID:</label>
|
||||
<input type="text" id="selectedDeviceId" placeholder="Select a device from the list" readonly>
|
||||
</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>
|
||||
<p class="section-help">APT uses the paired Chrome extension session. Select a camera, then start the proxy—no Alta credentials need to be entered again.</p>
|
||||
<div class="proxy-buttons">
|
||||
<button type="button" id="startProxyBtn" class="btn-primary" disabled>Start Proxy</button>
|
||||
<button type="button" id="stopProxyBtn" class="btn-outline" disabled>Stop Proxy</button>
|
||||
|
||||
@@ -81,7 +81,7 @@ function registerIpcHandlers() {
|
||||
registerIpc('get-device-groups', () => runtime.getDeviceGroups());
|
||||
registerIpc('get-device-hierarchy', () => runtime.getDeviceHierarchy());
|
||||
registerIpc('get-auth-info', () => runtime.getAuthInfo());
|
||||
registerIpc('launch-proxy', (deviceId, username) => runtime.launchProxy(deviceId, username));
|
||||
registerIpc('launch-proxy', (deviceId) => runtime.launchProxy(deviceId));
|
||||
registerIpc('stop-proxy', async (key) => {
|
||||
const result = await runtime.stopProxy(key);
|
||||
sendConnectionState();
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "alta-api-client",
|
||||
"version": "1.2.0",
|
||||
"version": "1.2.5",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "alta-api-client",
|
||||
"version": "1.2.0",
|
||||
"version": "1.2.5",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"axios": "1.19.0"
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "alta-api-client",
|
||||
"version": "1.2.0",
|
||||
"version": "1.2.5",
|
||||
"description": "Secure Windows Electron client for the Alta Camera Proxy",
|
||||
"main": "main.js",
|
||||
"scripts": {
|
||||
|
||||
+1
-1
@@ -15,7 +15,7 @@ contextBridge.exposeInMainWorld('electronAPI', Object.freeze({
|
||||
getDeviceGroups: () => ipcRenderer.invoke('get-device-groups'),
|
||||
getDeviceHierarchy: () => ipcRenderer.invoke('get-device-hierarchy'),
|
||||
getAuthInfo: () => ipcRenderer.invoke('get-auth-info'),
|
||||
launchProxy: (deviceId, username) => ipcRenderer.invoke('launch-proxy', deviceId, username),
|
||||
launchProxy: (deviceId) => ipcRenderer.invoke('launch-proxy', deviceId),
|
||||
stopProxy: (key) => ipcRenderer.invoke('stop-proxy', key),
|
||||
disconnect: () => ipcRenderer.invoke('disconnect'),
|
||||
getConnectionState: () => ipcRenderer.invoke('get-connection-state'),
|
||||
|
||||
+10
-1
@@ -1,6 +1,15 @@
|
||||
'use strict';
|
||||
|
||||
(function exposeRendererController(globalScope) {
|
||||
function shouldShowPairingOnboarding({
|
||||
paired = false,
|
||||
secretVisible = false,
|
||||
connected = false,
|
||||
manuallyOpen = false,
|
||||
} = {}) {
|
||||
return manuallyOpen || !paired || (secretVisible && !connected);
|
||||
}
|
||||
|
||||
function createRendererController({
|
||||
disconnect,
|
||||
renderConnectionState,
|
||||
@@ -35,7 +44,7 @@
|
||||
});
|
||||
}
|
||||
|
||||
const api = Object.freeze({ createRendererController });
|
||||
const api = Object.freeze({ createRendererController, shouldShowPairingOnboarding });
|
||||
if (typeof module !== 'undefined' && module.exports) module.exports = api;
|
||||
if (globalScope) globalScope.AptRendererController = api;
|
||||
}(typeof window !== 'undefined' ? window : undefined));
|
||||
|
||||
+42
-9
@@ -10,7 +10,6 @@ const statusIndicator = document.getElementById('statusIndicator');
|
||||
const deviceSearch = document.getElementById('deviceSearch');
|
||||
const disconnectBtn = document.getElementById('disconnectBtn');
|
||||
const selectedDeviceId = document.getElementById('selectedDeviceId');
|
||||
const altaUsername = document.getElementById('altaUsername');
|
||||
const startProxyBtn = document.getElementById('startProxyBtn');
|
||||
const stopProxyBtn = document.getElementById('stopProxyBtn');
|
||||
const checkUpdateBtn = document.getElementById('checkUpdateBtn');
|
||||
@@ -18,11 +17,14 @@ const updateNotice = document.getElementById('updateNotice');
|
||||
const updateMessage = document.getElementById('updateMessage');
|
||||
const openReleasesBtn = document.getElementById('openReleasesBtn');
|
||||
const dismissUpdateBtn = document.getElementById('dismissUpdateBtn');
|
||||
const pairingSection = document.getElementById('pairingSection');
|
||||
const managePairingBtn = document.getElementById('managePairingBtn');
|
||||
const pairingState = document.getElementById('pairingState');
|
||||
const pairingSecret = document.getElementById('pairingSecret');
|
||||
const pairingSecretRow = document.getElementById('pairingSecretRow');
|
||||
const rotatePairingBtn = document.getElementById('rotatePairingBtn');
|
||||
const revokePairingBtn = document.getElementById('revokePairingBtn');
|
||||
const hidePairingBtn = document.getElementById('hidePairingBtn');
|
||||
|
||||
const ROW_HEIGHT = window.AptSidebarView.ROW_HEIGHT;
|
||||
let connection = { connected: false, origin: null, activeProxies: [] };
|
||||
@@ -32,6 +34,17 @@ let sidebarController = null;
|
||||
let loadGeneration = 0;
|
||||
let searchTimer = null;
|
||||
let scrollFrame = null;
|
||||
let pairingView = { paired: false, secretVisible: false, manuallyOpen: false };
|
||||
|
||||
function renderPairingVisibility() {
|
||||
const visible = window.AptRendererController.shouldShowPairingOnboarding({
|
||||
...pairingView,
|
||||
connected: connection.connected,
|
||||
});
|
||||
pairingSection.hidden = !visible;
|
||||
managePairingBtn.hidden = visible || !pairingView.paired;
|
||||
hidePairingBtn.hidden = !pairingView.paired;
|
||||
}
|
||||
|
||||
function showStatus(element, message, type) {
|
||||
element.textContent = message;
|
||||
@@ -51,8 +64,7 @@ function deviceStatusFor(device) {
|
||||
function updateProxyButtons() {
|
||||
const id = selectedDevice && selectedDevice.id;
|
||||
const active = id ? activeDeviceIds().has(id.toLowerCase()) : false;
|
||||
const hasUsername = altaUsername.value.trim().length > 0;
|
||||
startProxyBtn.disabled = !connection.connected || !id || !hasUsername || active;
|
||||
startProxyBtn.disabled = !connection.connected || !id || active;
|
||||
stopProxyBtn.disabled = !id || !active;
|
||||
}
|
||||
|
||||
@@ -67,6 +79,13 @@ function renderConnectionState(state) {
|
||||
dot.className = `status-dot ${connection.connected ? 'online' : 'offline'}`;
|
||||
text.textContent = connection.connected ? 'Connected' : 'Disconnected';
|
||||
disconnectBtn.disabled = !connection.connected;
|
||||
if (connection.connected) {
|
||||
pairingView.secretVisible = false;
|
||||
pairingView.manuallyOpen = false;
|
||||
pairingSecret.value = '';
|
||||
pairingSecretRow.style.display = 'none';
|
||||
}
|
||||
renderPairingVisibility();
|
||||
updateProxyButtons();
|
||||
if (sidebarController) sidebarController.refresh();
|
||||
}
|
||||
@@ -145,10 +164,9 @@ function installHierarchy(hierarchy) {
|
||||
const count = snapshot.state.searchQuery && snapshot.state.searchCameraKeys
|
||||
? snapshot.state.searchCameraKeys.size
|
||||
: hierarchyModel.cameraCount;
|
||||
const hidden = snapshot.rows.hiddenSelected ? ' Selected camera is hidden by the current view.' : '';
|
||||
deviceResults.textContent = snapshot.state.searchQuery
|
||||
? `${count} exact search result${count === 1 ? '' : 's'}.${hidden}`
|
||||
: `${count} camera${count === 1 ? '' : 's'}.${hidden}`;
|
||||
? `${count} exact search result${count === 1 ? '' : 's'}.`
|
||||
: `${count} camera${count === 1 ? '' : 's'}.`;
|
||||
},
|
||||
});
|
||||
sidebarController = window.AptSidebarController.createSidebarController({
|
||||
@@ -201,7 +219,6 @@ const rendererController = window.AptRendererController.createRendererController
|
||||
disconnect: () => window.electronAPI.disconnect(),
|
||||
renderConnectionState,
|
||||
clearDisconnectedState: () => {
|
||||
altaUsername.value = '';
|
||||
clearHierarchy();
|
||||
},
|
||||
showConnectionStatus: (message, type) => showStatus(connectionStatus, message, type),
|
||||
@@ -216,7 +233,7 @@ async function handleStartProxy() {
|
||||
if (!id) return;
|
||||
startProxyBtn.disabled = true;
|
||||
showStatus(connectionStatus, `Starting proxy for ${selectedDevice.name || 'selected device'}...`, 'info');
|
||||
const result = await window.electronAPI.launchProxy(id, altaUsername.value);
|
||||
const result = await window.electronAPI.launchProxy(id);
|
||||
if (result.success) {
|
||||
renderConnectionState(await window.electronAPI.getConnectionState());
|
||||
showStatus(connectionStatus, 'Camera proxy started.', 'success');
|
||||
@@ -259,6 +276,11 @@ async function checkForUpdates(showCurrent = true) {
|
||||
}
|
||||
|
||||
function renderPairingStatus(result) {
|
||||
pairingView = {
|
||||
paired: result.paired === true,
|
||||
secretVisible: Boolean(result.secret),
|
||||
manuallyOpen: Boolean(result.secret) || result.paired !== true,
|
||||
};
|
||||
pairingState.textContent = result.paired ? 'Paired' : 'Revoked / not paired';
|
||||
pairingState.className = result.paired ? 'paired' : 'unpaired';
|
||||
revokePairingBtn.disabled = !result.paired;
|
||||
@@ -270,6 +292,7 @@ function renderPairingStatus(result) {
|
||||
pairingSecret.value = '';
|
||||
pairingSecretRow.style.display = 'none';
|
||||
}
|
||||
renderPairingVisibility();
|
||||
}
|
||||
|
||||
async function runSearch(query) {
|
||||
@@ -340,10 +363,20 @@ deviceSearch.addEventListener('keydown', (event) => {
|
||||
disconnectBtn.addEventListener('click', handleDisconnect);
|
||||
startProxyBtn.addEventListener('click', handleStartProxy);
|
||||
stopProxyBtn.addEventListener('click', handleStopProxy);
|
||||
altaUsername.addEventListener('input', updateProxyButtons);
|
||||
checkUpdateBtn.addEventListener('click', () => checkForUpdates(true));
|
||||
openReleasesBtn.addEventListener('click', () => window.electronAPI.openFixedReleasesPage());
|
||||
dismissUpdateBtn.addEventListener('click', () => { updateNotice.style.display = 'none'; });
|
||||
rotatePairingBtn.addEventListener('click', async () => renderPairingStatus(await window.electronAPI.rotatePairing()));
|
||||
revokePairingBtn.addEventListener('click', async () => renderPairingStatus(await window.electronAPI.revokePairing()));
|
||||
managePairingBtn.addEventListener('click', () => {
|
||||
pairingView.manuallyOpen = true;
|
||||
renderPairingVisibility();
|
||||
});
|
||||
hidePairingBtn.addEventListener('click', () => {
|
||||
pairingView.manuallyOpen = false;
|
||||
pairingView.secretVisible = false;
|
||||
pairingSecret.value = '';
|
||||
pairingSecretRow.style.display = 'none';
|
||||
renderPairingVisibility();
|
||||
});
|
||||
document.addEventListener('DOMContentLoaded', () => { initialize().catch(() => showStatus(connectionStatus, 'Application initialization failed.', 'error')); });
|
||||
|
||||
@@ -37,7 +37,9 @@ function projectDevice(raw) {
|
||||
type: nullableString(raw.type),
|
||||
model: nullableString(raw.model),
|
||||
address: nullableString(raw.address),
|
||||
siteId: nullableId(raw.server_group_id),
|
||||
// server_group_id identifies the Alta server/cloud connector, not a device site.
|
||||
// The tree infers the site through device_group_id -> deviceGroups.parent_id.
|
||||
siteId: null,
|
||||
deviceGroupId: nullableId(raw.device_group_id),
|
||||
displayStatus: nullableString(raw.live && raw.live.display_status),
|
||||
localStorage: nullableBoolean(raw.capabilities && raw.capabilities.localStorage),
|
||||
@@ -89,17 +91,20 @@ function projectDeviceHierarchy({ devices = [], sites = [], groups = [], metadat
|
||||
throw new TypeError('Hierarchy projection requires arrays');
|
||||
}
|
||||
const deviceProjection = projectArray(devices, projectDevice);
|
||||
const eligibleDevices = deviceProjection.projected.filter((device) => device.localStorage !== true);
|
||||
const cloudNativeExcluded = deviceProjection.projected.length - eligibleDevices.length;
|
||||
const siteProjection = projectArray(sites, projectSite);
|
||||
const groupProjection = projectArray(groups, projectGroup);
|
||||
const payload = {
|
||||
devices: deviceProjection.projected,
|
||||
devices: eligibleDevices,
|
||||
sites: siteProjection.projected,
|
||||
groups: groupProjection.projected,
|
||||
diagnostics: {
|
||||
devices: {
|
||||
received: devices.length,
|
||||
eligible: deviceProjection.projected.length,
|
||||
eligible: eligibleDevices.length,
|
||||
invalid: deviceProjection.invalid,
|
||||
cloudNativeExcluded,
|
||||
},
|
||||
sites: {
|
||||
received: sites.length,
|
||||
|
||||
@@ -11,7 +11,7 @@ const {
|
||||
} = require('./bridge-auth');
|
||||
const { RELEASES_PAGE_URL } = require('./update-policy');
|
||||
const { projectDeviceHierarchy, projectGroup, projectSite } = require('./device-projection');
|
||||
const { validateDeviceId, validateUsername } = require('./proxy-launch');
|
||||
const { validateCookie, validateDeviceId } = require('./proxy-launch');
|
||||
|
||||
const DEFAULT_DISCOVERY_TIMEOUT_MS = 60_000;
|
||||
|
||||
@@ -23,7 +23,7 @@ function safeErrorMessage(error, fallback) {
|
||||
'INVALID_ALTA_RESPONSE', 'INVALID_ALTA_RESPONSE_DATA', 'ALTA_RESPONSE_TOO_LARGE',
|
||||
'ALTA_RESPONSE_TOO_MANY_OBJECTS', 'PROJECTED_PAYLOAD_TOO_LARGE',
|
||||
'UNSAFE_ALTA_REDIRECT', 'TOO_MANY_ALTA_REDIRECTS', 'HELPER_NOT_FOUND',
|
||||
'UNSUPPORTED_PLATFORM', 'INVALID_DEVICE_ID', 'INVALID_USERNAME', 'SPAWN_FAILED',
|
||||
'UNSUPPORTED_PLATFORM', 'INVALID_DEVICE_ID', 'INVALID_SESSION_COOKIE', 'SPAWN_FAILED',
|
||||
]);
|
||||
return error && allowed.has(error.code) && typeof error.message === 'string'
|
||||
? error.message
|
||||
@@ -363,10 +363,9 @@ class AppRuntime {
|
||||
}
|
||||
}
|
||||
|
||||
async launchProxy(deviceId, username) {
|
||||
async launchProxy(deviceId) {
|
||||
try {
|
||||
const validatedId = validateDeviceId(deviceId);
|
||||
const validatedUsername = validateUsername(username);
|
||||
this._reconcileProxies();
|
||||
if (!this.allowedDeviceDiscovery || !this._isCurrentDiscovery(this.allowedDeviceDiscovery) ||
|
||||
!this.allowedDeviceIds.has(validatedId)) {
|
||||
@@ -378,7 +377,7 @@ class AppRuntime {
|
||||
const session = this.sessionStore.requireSession();
|
||||
const result = this.proxyManager.launchProxy({
|
||||
deploymentHost: new URL(session.origin).hostname,
|
||||
username: validatedUsername,
|
||||
cookie: validateCookie(session.cookie),
|
||||
deviceId: validatedId,
|
||||
});
|
||||
this.proxyByDevice.set(validatedId, result.processId);
|
||||
|
||||
+16
-15
@@ -5,7 +5,7 @@ const nodePath = require('node:path');
|
||||
const { spawn: nodeSpawn } = require('node:child_process');
|
||||
|
||||
const HELPER_FILENAME = 'aware-cam-proxy.exe';
|
||||
const MAX_USERNAME_LENGTH = 254;
|
||||
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])?$/;
|
||||
@@ -42,19 +42,20 @@ function validateDeviceId(value) {
|
||||
return value.toLowerCase();
|
||||
}
|
||||
|
||||
function validateUsername(value) {
|
||||
if (typeof value !== 'string' || value.length === 0 || value.length > MAX_USERNAME_LENGTH || value.trim().length === 0) {
|
||||
throw new ProxyLaunchError('Alta username is invalid.', 'INVALID_USERNAME');
|
||||
function validateCookie(value) {
|
||||
if (typeof value !== 'string' || value.length === 0 || value.length > MAX_COOKIE_LENGTH) {
|
||||
throw new ProxyLaunchError('Alta session cookie is invalid.', 'INVALID_SESSION_COOKIE');
|
||||
}
|
||||
if (/[\u0000-\u001f\u007f-\u009f]/.test(value)) {
|
||||
throw new ProxyLaunchError('Alta username must not contain control characters.', 'INVALID_USERNAME');
|
||||
if (/[;\u0000-\u001f\u007f-\u009f]/.test(value)) {
|
||||
throw new ProxyLaunchError('Alta session cookie must not contain separators or control characters.', 'INVALID_SESSION_COOKIE');
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function safeProcessError(error) {
|
||||
function safeProcessError(error, secret) {
|
||||
const source = error && typeof error.message === 'string' ? error.message : 'Unknown process error';
|
||||
return source.replace(/[\u0000-\u001f\u007f-\u009f]/g, ' ').slice(0, 512);
|
||||
const redacted = secret ? source.split(secret).join('[REDACTED]') : source;
|
||||
return redacted.replace(/[\u0000-\u001f\u007f-\u009f]/g, ' ').slice(0, 512);
|
||||
}
|
||||
|
||||
function safeMetadata(entry, status = entry.status) {
|
||||
@@ -129,7 +130,7 @@ function createProxyManager({
|
||||
|
||||
const deploymentHost = validateDeploymentHost(request.deploymentHost);
|
||||
const deviceId = validateDeviceId(request.deviceId);
|
||||
const username = validateUsername(request.username);
|
||||
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');
|
||||
@@ -139,17 +140,17 @@ function createProxyManager({
|
||||
try {
|
||||
child = spawn(
|
||||
helperPath,
|
||||
['-a', deploymentHost, '-u', username, '-d', deviceId],
|
||||
['-a', deploymentHost, '-d', deviceId, '-k', cookie],
|
||||
{
|
||||
shell: false,
|
||||
detached: true,
|
||||
stdio: 'inherit',
|
||||
stdio: 'ignore',
|
||||
windowsHide: false
|
||||
}
|
||||
);
|
||||
} catch (error) {
|
||||
throw new ProxyLaunchError(
|
||||
`Failed to launch proxy helper: ${safeProcessError(error)}`,
|
||||
`Failed to launch proxy helper: ${safeProcessError(error, cookie)}`,
|
||||
'SPAWN_FAILED'
|
||||
);
|
||||
}
|
||||
@@ -268,10 +269,10 @@ function createProxyManager({
|
||||
module.exports = {
|
||||
DEFAULT_STOP_TIMEOUT_MS,
|
||||
HELPER_FILENAME,
|
||||
MAX_USERNAME_LENGTH,
|
||||
MAX_COOKIE_LENGTH,
|
||||
ProxyLaunchError,
|
||||
createProxyManager,
|
||||
validateCookie,
|
||||
validateDeploymentHost,
|
||||
validateDeviceId,
|
||||
validateUsername
|
||||
validateDeviceId
|
||||
};
|
||||
|
||||
@@ -11,7 +11,7 @@ const ROOT = path.join(__dirname, '..');
|
||||
const read = (name) => fs.readFileSync(path.join(ROOT, name), 'utf8');
|
||||
const readJson = (name) => JSON.parse(read(name));
|
||||
|
||||
function writeKit(root, { version = '1.2.0', legacy = false } = {}) {
|
||||
function writeKit(root, { version = '1.2.5', legacy = false } = {}) {
|
||||
const extension = path.join(root, 'chrome-extension');
|
||||
fs.mkdirSync(extension, { recursive: true });
|
||||
fs.writeFileSync(path.join(root, 'AltaCameraProxy.exe'), 'synthetic executable');
|
||||
@@ -31,7 +31,7 @@ test('application and extension versions and supported dependencies stay coordin
|
||||
const lock = readJson('package-lock.json');
|
||||
const manifest = readJson('chrome-extension/manifest.json');
|
||||
|
||||
assert.equal(pkg.version, '1.2.0');
|
||||
assert.equal(pkg.version, '1.2.5');
|
||||
assert.equal(manifest.version, pkg.version);
|
||||
assert.equal(lock.version, pkg.version);
|
||||
assert.equal(lock.packages[''].version, pkg.version);
|
||||
|
||||
@@ -6,6 +6,7 @@ const {
|
||||
MAX_PROJECTED_PAYLOAD_BYTES,
|
||||
projectDeviceHierarchy,
|
||||
} = require('../src/device-projection');
|
||||
const { buildDeviceTree } = require('../device-tree');
|
||||
|
||||
function uuid(index) {
|
||||
return `00000000-0000-4000-8000-${index.toString(16).padStart(12, '0')}`;
|
||||
@@ -18,7 +19,7 @@ function rawDevice(index, overrides = {}) {
|
||||
type: 'camera',
|
||||
model: 'Synthetic',
|
||||
address: `10.0.${Math.floor(index / 255)}.${index % 255}`,
|
||||
server_group_id: 'site-1',
|
||||
server_group_id: 'server-group-1',
|
||||
device_group_id: 'group-1',
|
||||
capabilities: { localStorage: index % 2 === 0, secretCapability: 'drop-me' },
|
||||
live: { display_status: 'online', private: 'drop-me' },
|
||||
@@ -29,25 +30,68 @@ function rawDevice(index, overrides = {}) {
|
||||
|
||||
test('projects only allowlisted fields and honestly diagnoses ineligible devices', () => {
|
||||
const payload = projectDeviceHierarchy({
|
||||
devices: [rawDevice(1), rawDevice(2, { guid: 'not-a-uuid' })],
|
||||
devices: [
|
||||
rawDevice(1),
|
||||
rawDevice(2, { guid: 'not-a-uuid' }),
|
||||
rawDevice(4),
|
||||
],
|
||||
sites: [{ id: 'site-1', name: 'HQ', pending_deletion_start: null, secret: 'drop-me' }],
|
||||
groups: [{ id: 'group-1', name: 'Lobby', parent_id: null, pending_deletion_start: null, secret: 'drop-me' }],
|
||||
});
|
||||
|
||||
assert.deepEqual(payload.devices, [{
|
||||
id: uuid(1), name: 'Camera 1', type: 'camera', model: 'Synthetic', address: '10.0.0.1',
|
||||
siteId: 'site-1', deviceGroupId: 'group-1', displayStatus: 'online', localStorage: false,
|
||||
siteId: null, deviceGroupId: 'group-1', displayStatus: 'online', localStorage: false,
|
||||
}]);
|
||||
assert.deepEqual(payload.sites, [{ id: 'site-1', name: 'HQ', pendingDeletionStart: null }]);
|
||||
assert.deepEqual(payload.groups, [{ id: 'group-1', name: 'Lobby', parentId: null, pendingDeletionStart: null }]);
|
||||
assert.deepEqual(payload.diagnostics.devices, { received: 2, eligible: 1, invalid: 1 });
|
||||
assert.deepEqual(payload.diagnostics.devices, {
|
||||
received: 3, eligible: 1, invalid: 1, cloudNativeExcluded: 1,
|
||||
});
|
||||
assert.doesNotMatch(JSON.stringify(payload), /drop-me|cookie|secretCapability|private/);
|
||||
});
|
||||
|
||||
test('infers camera sites from device-group parents instead of server groups', () => {
|
||||
const payload = projectDeviceHierarchy({
|
||||
devices: [rawDevice(3, {
|
||||
name: 'Front Door',
|
||||
server_group_id: 'cloud-connector-server-group',
|
||||
device_group_id: 'entrances',
|
||||
})],
|
||||
sites: [{ id: 'test-site', name: 'Test Site' }],
|
||||
groups: [{ id: 'entrances', name: 'Entrances', parent_id: 'test-site' }],
|
||||
});
|
||||
const model = buildDeviceTree(payload);
|
||||
const camera = [...model.cameraByKey.values()][0];
|
||||
|
||||
assert.equal(payload.devices[0].siteId, null);
|
||||
assert.equal(camera.site.canonicalId, 'test-site');
|
||||
assert.equal(camera.group.canonicalId, 'entrances');
|
||||
assert.equal(camera.hierarchyStatus, 'inferred-site');
|
||||
assert.deepEqual(model.diagnostics, []);
|
||||
});
|
||||
|
||||
test('excludes cloud-native cameras while retaining Cloud Connector and unknown-storage devices', () => {
|
||||
const payload = projectDeviceHierarchy({
|
||||
devices: [
|
||||
rawDevice(10, { name: 'Cloud Native', capabilities: { localStorage: true } }),
|
||||
rawDevice(11, { name: 'Cloud Connector', capabilities: { localStorage: false } }),
|
||||
rawDevice(12, { name: 'Unknown Storage', capabilities: {} }),
|
||||
],
|
||||
});
|
||||
|
||||
assert.deepEqual(payload.devices.map((device) => device.name), ['Cloud Connector', 'Unknown Storage']);
|
||||
assert.deepEqual(payload.diagnostics.devices, {
|
||||
received: 3, eligible: 2, invalid: 0, cloudNativeExcluded: 1,
|
||||
});
|
||||
});
|
||||
|
||||
test('projects synthetic 1,500 and 10,000 camera deployments below the 8 MiB IPC cap', () => {
|
||||
for (const count of [1_500, 10_000]) {
|
||||
const payload = projectDeviceHierarchy({
|
||||
devices: Array.from({ length: count }, (_, index) => rawDevice(index)),
|
||||
devices: Array.from({ length: count }, (_, index) => rawDevice(index, {
|
||||
capabilities: { localStorage: false },
|
||||
})),
|
||||
sites: [],
|
||||
groups: [],
|
||||
});
|
||||
@@ -80,6 +124,7 @@ test('repairs lone UTF-16 surrogates without changing valid Unicode or hiding ca
|
||||
address: 'Hall \ud800\udc00 / \udc00',
|
||||
server_group_id: 'site-\ud800',
|
||||
device_group_id: 'group-\udc00',
|
||||
capabilities: { localStorage: false },
|
||||
})],
|
||||
sites: [{ id: 'site-\ud800', name: 'Valid \ud83d\udcf7 \udc00', pending_deletion_start: '\ud800' }],
|
||||
groups: [{ id: 'group-\udc00', name: 'Group \ud83d\udcf7 \ud800', parent_id: 'site-\ud800' }],
|
||||
@@ -90,7 +135,7 @@ test('repairs lone UTF-16 surrogates without changing valid Unicode or hiding ca
|
||||
assert.equal(payload.devices[0].name, 'Front \ufffd camera');
|
||||
assert.equal(payload.devices[0].model, 'Model \ufffd');
|
||||
assert.equal(payload.devices[0].address, 'Hall \ud800\udc00 / \ufffd');
|
||||
assert.equal(payload.devices[0].siteId, 'site-\ufffd');
|
||||
assert.equal(payload.devices[0].siteId, null);
|
||||
assert.equal(payload.devices[0].deviceGroupId, 'group-\ufffd');
|
||||
assert.deepEqual(payload.sites, [{ id: 'site-\ufffd', name: 'Valid \ud83d\udcf7 \ufffd', pendingDeletionStart: '\ufffd' }]);
|
||||
assert.deepEqual(payload.groups, [{ id: 'group-\ufffd', name: 'Group \ud83d\udcf7 \ufffd', parentId: 'site-\ufffd', pendingDeletionStart: null }]);
|
||||
@@ -101,6 +146,7 @@ test('repairs lone UTF-16 surrogates without changing valid Unicode or hiding ca
|
||||
test('rejects a projected hierarchy payload above 8 MiB', () => {
|
||||
const devices = Array.from({ length: 10_000 }, (_, index) => rawDevice(index, {
|
||||
name: `Camera ${index} ${'x'.repeat(900)}`,
|
||||
capabilities: { localStorage: false },
|
||||
}));
|
||||
assert.throws(
|
||||
() => projectDeviceHierarchy({ devices, sites: [], groups: [] }),
|
||||
|
||||
@@ -39,10 +39,11 @@ function makeElement() {
|
||||
|
||||
function makePopupHarness({ paired = true, confirmCopy = false, clipboardReject = null, validServerProof = true } = {}) {
|
||||
const elements = Object.fromEntries(
|
||||
['tabInfo', 'pairingInfo', 'sendBtn', 'copyBtn', 'statusMsg', 'copyWarning', 'confirmCopy', 'openOptionsBtn']
|
||||
['tabInfo', 'pairingInfo', 'sendBtn', 'copyBtn', 'statusMsg', 'openOptionsBtn']
|
||||
.map((id) => [id, makeElement()])
|
||||
);
|
||||
const clipboardWrites = [];
|
||||
const confirmationMessages = [];
|
||||
const fetchCalls = [];
|
||||
let cookieReads = 0;
|
||||
const chromeApi = {
|
||||
@@ -65,7 +66,7 @@ function makePopupHarness({ paired = true, confirmCopy = false, clipboardReject
|
||||
chromeApi,
|
||||
documentApi,
|
||||
navigatorApi,
|
||||
confirmCopy: () => confirmCopy,
|
||||
confirmCopy: (message) => { confirmationMessages.push(message); return confirmCopy; },
|
||||
cryptoApi: crypto.webcrypto,
|
||||
fetchImpl: async (...args) => {
|
||||
fetchCalls.push(args);
|
||||
@@ -81,7 +82,7 @@ function makePopupHarness({ paired = true, confirmCopy = false, clipboardReject
|
||||
return { ok: true, json: async () => ({ success: true }) };
|
||||
}
|
||||
});
|
||||
return { controller, elements, clipboardWrites, fetchCalls, get cookieReads() { return cookieReads; } };
|
||||
return { controller, elements, clipboardWrites, confirmationMessages, fetchCalls, get cookieReads() { return cookieReads; } };
|
||||
}
|
||||
|
||||
test('manifest commits only a public key, stable ID, local storage, and exact loopback host access', () => {
|
||||
@@ -174,11 +175,11 @@ test('copy cancellation occurs before cookie access and never writes the token',
|
||||
assert.match(harness.elements.statusMsg.textContent, /cancelled/i);
|
||||
});
|
||||
|
||||
test('confirmed copy warns about clipboard history and reports success without exposing token', async () => {
|
||||
test('confirmed copy uses one concise prompt and reports success without exposing token', async () => {
|
||||
const harness = makePopupHarness({ paired: true, confirmCopy: true });
|
||||
await harness.controller.init();
|
||||
assert.match(harness.elements.copyWarning.textContent, /clipboard (?:history|sync)/i);
|
||||
await harness.controller.copyToken();
|
||||
assert.deepEqual(harness.confirmationMessages, ['Copy VA token?']);
|
||||
assert.deepEqual(harness.clipboardWrites, ['sensitive-va-token']);
|
||||
assert.match(harness.elements.statusMsg.textContent, /copied/i);
|
||||
assert.equal(harness.elements.statusMsg.textContent.includes('sensitive-va-token'), false);
|
||||
@@ -201,6 +202,7 @@ test('options UI stores a validated pairing secret locally and can forget pairin
|
||||
const js = read('options.js');
|
||||
assert.match(html, /pairingSecret/);
|
||||
assert.match(html, /type="password"/);
|
||||
assert.doesNotMatch(html, /treat it like a password|privacy-note/i);
|
||||
assert.match(js, /chrome\.storage\.local\.set/);
|
||||
assert.match(js, /chrome\.storage\.local\.remove/);
|
||||
assert.match(js, /aptPairingSecret/);
|
||||
|
||||
+22
-22
@@ -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 VALID_HOST = 'tenant.avasecurity.com';
|
||||
const VALID_DEVICE_ID = '123e4567-e89b-42d3-a456-426614174000';
|
||||
const VALID_USERNAME = 'proxy.operator+apt@example.com';
|
||||
const SYNTHETIC_COOKIE = 'HERMES_SENTINEL_SESSION_COOKIE';
|
||||
|
||||
function loadModule() {
|
||||
return require(MODULE_PATH);
|
||||
@@ -67,7 +67,7 @@ function validRequest(overrides = {}) {
|
||||
return {
|
||||
deploymentHost: VALID_HOST,
|
||||
deviceId: VALID_DEVICE_ID,
|
||||
username: VALID_USERNAME,
|
||||
cookie: SYNTHETIC_COOKIE,
|
||||
...overrides
|
||||
};
|
||||
}
|
||||
@@ -76,18 +76,18 @@ test('exports the proxy manager module', () => {
|
||||
assert.doesNotThrow(() => loadModule());
|
||||
});
|
||||
|
||||
test('launches the approved helper directly in a visible interactive console', () => {
|
||||
test('launches the approved helper directly with the paired Alta session', () => {
|
||||
const { manager, calls } = createHarness();
|
||||
|
||||
const result = manager.launchProxy(validRequest());
|
||||
|
||||
assert.deepEqual(calls, [[
|
||||
APPROVED_HELPER,
|
||||
['-a', VALID_HOST, '-u', VALID_USERNAME, '-d', VALID_DEVICE_ID],
|
||||
['-a', VALID_HOST, '-d', VALID_DEVICE_ID, '-k', SYNTHETIC_COOKIE],
|
||||
{
|
||||
shell: false,
|
||||
detached: true,
|
||||
stdio: 'inherit',
|
||||
stdio: 'ignore',
|
||||
windowsHide: false
|
||||
}
|
||||
]]);
|
||||
@@ -100,16 +100,15 @@ test('launches the approved helper directly in a visible interactive console', (
|
||||
});
|
||||
});
|
||||
|
||||
test('passes username punctuation literally in argv without invoking a shell', () => {
|
||||
const username = 'proxy+apt&literal|name@example.com';
|
||||
test('passes the paired cookie literally without invoking a shell or console prompt', () => {
|
||||
const cookie = 'abc_DEF-123.456==';
|
||||
const { manager, calls } = createHarness();
|
||||
|
||||
manager.launchProxy(validRequest({ username }));
|
||||
manager.launchProxy(validRequest({ cookie }));
|
||||
|
||||
assert.equal(calls[0][1][3], username);
|
||||
assert.equal(calls[0][1][5], cookie);
|
||||
assert.equal(calls[0][2].shell, false);
|
||||
assert.equal(calls[0][2].stdio, 'inherit');
|
||||
assert.notEqual(calls[0][2].stdio, 'ignore');
|
||||
assert.equal(calls[0][2].stdio, 'ignore');
|
||||
});
|
||||
|
||||
test('rejects the CRLF calc.exe reproducer in every structured input', () => {
|
||||
@@ -117,8 +116,8 @@ test('rejects the CRLF calc.exe reproducer in every structured input', () => {
|
||||
const attacks = [
|
||||
{ deploymentHost: `${VALID_HOST}\r\ncalc.exe` },
|
||||
{ deviceId: `${VALID_DEVICE_ID}\r\ncalc.exe` },
|
||||
{ username: `${VALID_USERNAME}\r\ncalc.exe` },
|
||||
{ username: `${VALID_USERNAME}\0calc.exe` }
|
||||
{ cookie: `${SYNTHETIC_COOKIE}\r\ncalc.exe` },
|
||||
{ cookie: `${SYNTHETIC_COOKIE}\0calc.exe` }
|
||||
];
|
||||
|
||||
for (const attack of attacks) {
|
||||
@@ -191,10 +190,10 @@ test('normalizes a valid Alta hostname to lowercase', () => {
|
||||
assert.equal(calls[0][1][1], 'tenant.avigilon.com');
|
||||
});
|
||||
|
||||
test('rejects empty, oversized, non-string, and control-character usernames', () => {
|
||||
for (const username of ['', 'x'.repeat(255), 42, 'user\nname', 'user\rname', 'user\0name', 'user\u007fname']) {
|
||||
test('rejects empty, oversized, non-string, and control-character cookies', () => {
|
||||
for (const cookie of ['', 'x'.repeat(4097), 42, 'abc\nxyz', 'abc\rxyz', 'abc\0xyz', 'abc;xyz']) {
|
||||
const { manager } = createHarness();
|
||||
assert.throws(() => manager.launchProxy(validRequest({ username })), /username/i);
|
||||
assert.throws(() => manager.launchProxy(validRequest({ cookie })), /cookie/i);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -218,25 +217,27 @@ test('requires an absolute approved application directory and Windows platform',
|
||||
);
|
||||
});
|
||||
|
||||
test('reports a bounded spawn failure without credential redaction machinery', () => {
|
||||
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'); }
|
||||
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_SESSION_COOKIE/);
|
||||
return true;
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
test('tracks only safe process metadata and never exposes usernames', () => {
|
||||
test('tracks only safe process metadata and never exposes cookies', () => {
|
||||
const { manager } = createHarness();
|
||||
manager.launchProxy(validRequest());
|
||||
|
||||
@@ -247,7 +248,7 @@ test('tracks only safe process metadata and never exposes usernames', () => {
|
||||
startedAt: 1_777_777_777_777,
|
||||
status: 'running'
|
||||
}]);
|
||||
assert.doesNotMatch(JSON.stringify(tracked), /proxy\.operator/);
|
||||
assert.doesNotMatch(JSON.stringify(tracked), /HERMES_SENTINEL_SESSION_COOKIE/);
|
||||
});
|
||||
|
||||
test('stopping one tracked process waits for confirmed exit and leaves the other alive', async () => {
|
||||
@@ -381,9 +382,8 @@ test('exit events remove only the matching owned child', () => {
|
||||
assert.deepEqual(manager.listTrackedProxies().map((item) => item.processId), [4101]);
|
||||
});
|
||||
|
||||
test('source contains no bearer argv, shell launchers, broad process killers, or persistence APIs', () => {
|
||||
test('source contains no shell launchers, broad process killers, or credential persistence APIs', () => {
|
||||
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, /\.(?:bat|command)\b/i);
|
||||
assert.doesNotMatch(source, /(?:writeFile|appendFile|mkdtemp|tmpdir)/);
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const { createRendererController } = require('../renderer-controller');
|
||||
const { createRendererController, shouldShowPairingOnboarding } = require('../renderer-controller');
|
||||
|
||||
function harness(result) {
|
||||
const calls = [];
|
||||
@@ -39,3 +39,11 @@ test('successful disconnect renders disconnected state and clears device state',
|
||||
['status', 'Disconnected from Alta.', 'info'],
|
||||
]);
|
||||
});
|
||||
|
||||
test('pairing onboarding is temporary but remains recoverable', () => {
|
||||
assert.equal(shouldShowPairingOnboarding({ paired: false }), true);
|
||||
assert.equal(shouldShowPairingOnboarding({ paired: true, secretVisible: true }), true);
|
||||
assert.equal(shouldShowPairingOnboarding({ paired: true }), false);
|
||||
assert.equal(shouldShowPairingOnboarding({ paired: true, secretVisible: true, connected: true }), false);
|
||||
assert.equal(shouldShowPairingOnboarding({ paired: true, connected: true, manuallyOpen: true }), true);
|
||||
});
|
||||
|
||||
@@ -40,6 +40,7 @@ test('renderer uses one hierarchy request and no parallel flat discovery', () =>
|
||||
assert.match(renderer, /electronAPI\.getDeviceHierarchy\(\)/);
|
||||
assert.doesNotMatch(renderer, /electronAPI\.getDevices\(|electronAPI\.getDeviceSites\(|Promise\.all\(\s*\[\s*window\.electronAPI\.get/);
|
||||
assert.doesNotMatch(renderer, /\ballDevices\b|\ballSites\b|\bcollapsedSites\b|groupDevicesBySite/);
|
||||
assert.doesNotMatch(renderer, /Selected camera is hidden|hidden by the current view/i);
|
||||
assert.match(renderer, /loadGeneration/);
|
||||
assert.match(renderer, /generation\s*!==\s*loadGeneration/);
|
||||
});
|
||||
|
||||
@@ -92,14 +92,11 @@ test('runtime keeps Alta credentials in main-owned modules and exposes only non-
|
||||
localStorage: null,
|
||||
}],
|
||||
});
|
||||
const launched = await runtime.launchProxy(
|
||||
'550e8400-e29b-41d4-a716-446655440000',
|
||||
'proxy.operator@example.com'
|
||||
);
|
||||
const launched = await runtime.launchProxy('550e8400-e29b-41d4-a716-446655440000');
|
||||
assert.equal(launched.success, true);
|
||||
assert.deepEqual(calls[0], {
|
||||
deploymentHost: 'customer.avasecurity.com',
|
||||
username: 'proxy.operator@example.com',
|
||||
cookie: 'top-secret-cookie',
|
||||
deviceId: '550e8400-e29b-41d4-a716-446655440000',
|
||||
});
|
||||
assert.deepEqual(runtime.getConnectionState(), {
|
||||
@@ -189,7 +186,7 @@ test('newest hierarchy discovery owns the launch allowlist when completions arri
|
||||
const freshB = runtime.getDeviceHierarchy();
|
||||
pending[1]([{ guid: deviceB }]);
|
||||
assert.equal((await freshB).success, true);
|
||||
assert.equal((await runtime.launchProxy(deviceB, 'operator@example.com')).success, true);
|
||||
assert.equal((await runtime.launchProxy(deviceB)).success, true);
|
||||
pending[0]([{ guid: deviceA }]);
|
||||
assert.deepEqual(await staleA, {
|
||||
success: false,
|
||||
@@ -197,7 +194,7 @@ test('newest hierarchy discovery owns the launch allowlist when completions arri
|
||||
hierarchy: { devices: [], sites: [], groups: [] },
|
||||
message: 'Alta device discovery result is stale',
|
||||
});
|
||||
assert.equal((await runtime.launchProxy(deviceA, 'operator@example.com')).success, false);
|
||||
assert.equal((await runtime.launchProxy(deviceA)).success, false);
|
||||
assert.deepEqual(launches, [deviceB]);
|
||||
});
|
||||
|
||||
@@ -248,7 +245,7 @@ test('launch allowlist remains bound to the session origin that produced it', as
|
||||
});
|
||||
assert.equal((await runtime.getDeviceHierarchy()).success, true);
|
||||
sessionStore.establish('https://second.avasecurity.com', 'replacement-cookie');
|
||||
assert.equal((await runtime.launchProxy(deviceId, 'operator@example.com')).success, false);
|
||||
assert.equal((await runtime.launchProxy(deviceId)).success, false);
|
||||
assert.equal(launches, 0);
|
||||
});
|
||||
|
||||
@@ -256,28 +253,6 @@ test('runtime rejects discovery deadlines above 60 seconds', () => {
|
||||
assert.throws(() => new AppRuntime({ discoveryTimeoutMs: 60_001 }), /discovery timeout/i);
|
||||
});
|
||||
|
||||
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 () => {
|
||||
const protect = (value) => Buffer.from(`protected:${value}`);
|
||||
@@ -403,10 +378,10 @@ test('runtime reconciles exited children and permits relaunch for the same devic
|
||||
},
|
||||
});
|
||||
await runtime.getDevices();
|
||||
assert.equal((await runtime.launchProxy(deviceId, 'operator@example.com')).processId, 4101);
|
||||
assert.equal((await runtime.launchProxy(deviceId)).processId, 4101);
|
||||
tracked.length = 0;
|
||||
assert.deepEqual(runtime.getConnectionState().activeProxies, []);
|
||||
assert.equal((await runtime.launchProxy(deviceId, 'operator@example.com')).processId, 4102);
|
||||
assert.equal((await runtime.launchProxy(deviceId)).processId, 4102);
|
||||
});
|
||||
|
||||
test('disconnect stops every owned proxy before clearing the Alta session', async () => {
|
||||
@@ -474,7 +449,7 @@ test('kill request without exit keeps proxy tracked and session connected until
|
||||
setTimeout(callback) { timeoutCallback = callback; return 1; },
|
||||
clearTimeout() {},
|
||||
});
|
||||
proxyManager.launchProxy({ deploymentHost: 'customer.avasecurity.com', username: 'operator@example.com', deviceId });
|
||||
proxyManager.launchProxy({ deploymentHost: 'customer.avasecurity.com', cookie: 'synthetic-cookie', deviceId });
|
||||
const runtime = new AppRuntime({ sessionStore, altaClient: {}, proxyManager });
|
||||
|
||||
const firstDisconnect = runtime.disconnect();
|
||||
@@ -525,13 +500,16 @@ test('preload and renderer expose only narrow, credential-free contracts', () =>
|
||||
];
|
||||
for (const method of expectedMethods) assert.match(preload, new RegExp(`\\b${method}\\b`));
|
||||
assert.doesNotMatch(preload, /downloadAndInstall|download-and-install|onUpdateDownloadProgress|onExtensionCookie/);
|
||||
assert.match(preload, /launchProxy:\s*\(deviceId, username\)/);
|
||||
assert.match(preload, /launchProxy:\s*\(deviceId\)/);
|
||||
assert.doesNotMatch(preload, /launchProxy:\s*\([^)]*(?:cookie|origin)/i);
|
||||
assert.doesNotMatch(renderer, /cookieValue|sessionData\.cookies|cookies\s*:/);
|
||||
assert.match(renderer, /state\.connected\s*&&\s*\(!wasConnected\s*\|\|\s*state\.origin\s*!==\s*previousOrigin\)/);
|
||||
assert.match(read('main.js'), /onConnectionStateChanged:\s*\(\)\s*=>\s*\{\s*runtime\.onSessionChanged\(\)/);
|
||||
assert.doesNotMatch(html, /id="cookieKey"|updateProgress|Install Update/);
|
||||
assert.match(html, /id="altaUsername"/);
|
||||
assert.doesNotMatch(html, /id="cookieKey"|id="altaUsername"|type="password"|updateProgress|Install Update/);
|
||||
assert.match(html, /paired Chrome extension session/i);
|
||||
assert.match(html, /id="pairingSection"[^>]*hidden/);
|
||||
assert.match(html, /id="managePairingBtn"/);
|
||||
assert.match(renderer, /shouldShowPairingOnboarding/);
|
||||
assert.match(renderer, /openFixedReleasesPage/);
|
||||
assert.match(html, /Bridge Pairing/);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user