feat: add bounded large-deployment discovery

This commit is contained in:
2026-08-20 01:05:46 +00:00
parent 4a10e6051e
commit 6a8a4cf95a
8 changed files with 449 additions and 24 deletions
+2
View File
@@ -78,6 +78,8 @@ function registerIpc(channel, handler) {
function registerIpcHandlers() { 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-device-groups', () => runtime.getDeviceGroups());
registerIpc('get-device-hierarchy', () => runtime.getDeviceHierarchy());
registerIpc('get-auth-info', () => runtime.getAuthInfo()); registerIpc('get-auth-info', () => runtime.getAuthInfo());
registerIpc('launch-proxy', (deviceId, username) => runtime.launchProxy(deviceId, username)); registerIpc('launch-proxy', (deviceId, username) => runtime.launchProxy(deviceId, username));
registerIpc('stop-proxy', async (key) => { registerIpc('stop-proxy', async (key) => {
+2
View File
@@ -12,6 +12,8 @@ function onConnectionStateChanged(callback) {
contextBridge.exposeInMainWorld('electronAPI', Object.freeze({ 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'),
getDeviceGroups: () => ipcRenderer.invoke('get-device-groups'),
getDeviceHierarchy: () => ipcRenderer.invoke('get-device-hierarchy'),
getAuthInfo: () => ipcRenderer.invoke('get-auth-info'), getAuthInfo: () => ipcRenderer.invoke('get-auth-info'),
launchProxy: (deviceId, username) => ipcRenderer.invoke('launch-proxy', deviceId, username), launchProxy: (deviceId, username) => ipcRenderer.invoke('launch-proxy', deviceId, username),
stopProxy: (key) => ipcRenderer.invoke('stop-proxy', key), stopProxy: (key) => ipcRenderer.invoke('stop-proxy', key),
+39 -12
View File
@@ -3,13 +3,24 @@
const { canonicalizeAltaOrigin, assertSameAltaOrigin } = require('./url-policy'); const { canonicalizeAltaOrigin, assertSameAltaOrigin } = require('./url-policy');
const { formatAltaError } = require('./error-redaction'); const { formatAltaError } = require('./error-redaction');
const DEFAULT_TIMEOUT_MS = 10_000; const DEFAULT_TIMEOUT_MS = 30_000;
const DEFAULT_MAX_RESPONSE_BYTES = 2 * 1024 * 1024; const DEVICE_MAX_RESPONSE_BYTES = 32 * 1024 * 1024;
const HIERARCHY_MAX_RESPONSE_BYTES = 4 * 1024 * 1024;
const AUTH_MAX_RESPONSE_BYTES = 2 * 1024 * 1024;
const DEFAULT_MAX_RESPONSE_BYTES = DEVICE_MAX_RESPONSE_BYTES;
const MAX_ARRAY_OBJECTS = 10_000;
const DEFAULT_MAX_REDIRECTS = 3; const DEFAULT_MAX_REDIRECTS = 3;
const ENDPOINTS = Object.freeze({ const ENDPOINTS = Object.freeze({
getDevices: Object.freeze({ path: '/api/v1/devices', shape: 'array' }), getDevices: Object.freeze({
getDeviceSites: Object.freeze({ path: '/api/v1/deviceSites', shape: 'array' }), path: '/api/v1/devices', shape: 'array', maxResponseBytes: DEVICE_MAX_RESPONSE_BYTES, maxObjects: MAX_ARRAY_OBJECTS,
getAuthInfo: Object.freeze({ path: '/api/v1/auth', shape: 'object' }), }),
getDeviceSites: Object.freeze({
path: '/api/v1/deviceSites', shape: 'array', maxResponseBytes: HIERARCHY_MAX_RESPONSE_BYTES, maxObjects: MAX_ARRAY_OBJECTS,
}),
getDeviceGroups: Object.freeze({
path: '/api/v1/deviceGroups', shape: 'array', maxResponseBytes: HIERARCHY_MAX_RESPONSE_BYTES, maxObjects: MAX_ARRAY_OBJECTS,
}),
getAuthInfo: Object.freeze({ path: '/api/v1/auth', shape: 'object', maxResponseBytes: AUTH_MAX_RESPONSE_BYTES }),
}); });
const REDIRECT_STATUSES = new Set([301, 302, 303, 307, 308]); const REDIRECT_STATUSES = new Set([301, 302, 303, 307, 308]);
@@ -118,7 +129,7 @@ class AltaClient {
} }
if (typeof transport !== 'function') throw new TypeError('AltaClient transport must be a function'); if (typeof transport !== 'function') throw new TypeError('AltaClient transport must be a function');
if (!Number.isInteger(timeoutMs) || timeoutMs < 1 || timeoutMs > DEFAULT_TIMEOUT_MS) { if (!Number.isInteger(timeoutMs) || timeoutMs < 1 || timeoutMs > DEFAULT_TIMEOUT_MS) {
throw new RangeError('AltaClient timeout must be between 1 and 10000ms'); throw new RangeError('AltaClient timeout must be between 1 and 30000ms');
} }
if (!Number.isInteger(maxResponseBytes) || maxResponseBytes < 1 || maxResponseBytes > DEFAULT_MAX_RESPONSE_BYTES) { if (!Number.isInteger(maxResponseBytes) || maxResponseBytes < 1 || maxResponseBytes > DEFAULT_MAX_RESPONSE_BYTES) {
throw new RangeError('Invalid Alta response size limit'); throw new RangeError('Invalid Alta response size limit');
@@ -141,6 +152,10 @@ class AltaClient {
return this.#invoke('getDeviceSites', args); return this.#invoke('getDeviceSites', args);
} }
getDeviceGroups(...args) {
return this.#invoke('getDeviceGroups', args);
}
getAuthInfo(...args) { getAuthInfo(...args) {
return this.#invoke('getAuthInfo', args); return this.#invoke('getAuthInfo', args);
} }
@@ -150,6 +165,7 @@ class AltaClient {
throw altaError('INVALID_ALTA_ARGUMENTS', 'Alta API methods do not accept renderer parameters'); throw altaError('INVALID_ALTA_ARGUMENTS', 'Alta API methods do not accept renderer parameters');
} }
const endpoint = ENDPOINTS[operation]; const endpoint = ENDPOINTS[operation];
const maxResponseBytes = Math.min(this.maxResponseBytes, endpoint.maxResponseBytes);
const session = this.sessionStore.requireSession(); const session = this.sessionStore.requireSession();
const origin = canonicalizeAltaOrigin(session.origin); const origin = canonicalizeAltaOrigin(session.origin);
const cookie = session.cookie; const cookie = session.cookie;
@@ -157,10 +173,15 @@ class AltaClient {
const controller = new AbortController(); const controller = new AbortController();
try { try {
const data = await this.#request(operation, `${origin}${endpoint.path}`, origin, cookie, deadline, controller, 0); const data = await this.#request(
operation, `${origin}${endpoint.path}`, origin, cookie, deadline, controller, 0, maxResponseBytes,
);
if (endpoint.shape === 'array' && !Array.isArray(data)) { if (endpoint.shape === 'array' && !Array.isArray(data)) {
throw altaError('INVALID_ALTA_RESPONSE_DATA', 'Alta response did not contain an array'); throw altaError('INVALID_ALTA_RESPONSE_DATA', 'Alta response did not contain an array');
} }
if (endpoint.shape === 'array' && data.length > endpoint.maxObjects) {
throw altaError('ALTA_RESPONSE_TOO_MANY_OBJECTS', 'Alta response exceeded the object limit');
}
if (endpoint.shape === 'object' && (!data || typeof data !== 'object' || Array.isArray(data))) { if (endpoint.shape === 'object' && (!data || typeof data !== 'object' || Array.isArray(data))) {
throw altaError('INVALID_ALTA_RESPONSE_DATA', 'Alta response did not contain an object'); throw altaError('INVALID_ALTA_RESPONSE_DATA', 'Alta response did not contain an object');
} }
@@ -172,7 +193,7 @@ class AltaClient {
} }
} }
async #request(operation, url, origin, cookie, deadline, controller, redirectCount) { async #request(operation, url, origin, cookie, deadline, controller, redirectCount, maxResponseBytes) {
assertSameAltaOrigin(url, origin); assertSameAltaOrigin(url, origin);
const remaining = deadline - Date.now(); const remaining = deadline - Date.now();
if (remaining <= 0) throw altaError('ALTA_TIMEOUT', 'Alta request timed out', { timeout: true }); if (remaining <= 0) throw altaError('ALTA_TIMEOUT', 'Alta request timed out', { timeout: true });
@@ -196,7 +217,7 @@ class AltaClient {
signal: controller.signal, signal: controller.signal,
proxy: false, proxy: false,
maxRedirects: 0, maxRedirects: 0,
maxResponseBytes: this.maxResponseBytes, maxResponseBytes,
}))), }))),
timeoutPromise, timeoutPromise,
]); ]);
@@ -209,7 +230,7 @@ class AltaClient {
} }
const headers = normalizeHeaders(response.headers); const headers = normalizeHeaders(response.headers);
// Bound every response, including redirects and errors, before acting on it. // Bound every response, including redirects and errors, before acting on it.
enforceResponseSize(response.data, headers, this.maxResponseBytes); enforceResponseSize(response.data, headers, maxResponseBytes);
if (REDIRECT_STATUSES.has(response.status)) { if (REDIRECT_STATUSES.has(response.status)) {
if (typeof headers.location !== 'string' || headers.location.length === 0) { if (typeof headers.location !== 'string' || headers.location.length === 0) {
@@ -225,14 +246,16 @@ class AltaClient {
} catch { } catch {
throw altaError('UNSAFE_ALTA_REDIRECT', 'Alta redirect changed the validated origin'); throw altaError('UNSAFE_ALTA_REDIRECT', 'Alta redirect changed the validated origin');
} }
return this.#request(operation, target.href, origin, cookie, deadline, controller, redirectCount + 1); return this.#request(
operation, target.href, origin, cookie, deadline, controller, redirectCount + 1, maxResponseBytes,
);
} }
if (response.status < 200 || response.status > 299) { if (response.status < 200 || response.status > 299) {
throw altaError('ALTA_HTTP_ERROR', 'Alta request returned an error status', { status: response.status }); throw altaError('ALTA_HTTP_ERROR', 'Alta request returned an error status', { status: response.status });
} }
return parseData(response.data, this.maxResponseBytes); return parseData(response.data, maxResponseBytes);
} }
} }
@@ -242,10 +265,14 @@ function createAltaClient(options) {
module.exports = { module.exports = {
AltaClient, AltaClient,
AUTH_MAX_RESPONSE_BYTES,
DEFAULT_MAX_REDIRECTS, DEFAULT_MAX_REDIRECTS,
DEFAULT_MAX_RESPONSE_BYTES, DEFAULT_MAX_RESPONSE_BYTES,
DEFAULT_TIMEOUT_MS, DEFAULT_TIMEOUT_MS,
DEVICE_MAX_RESPONSE_BYTES,
ENDPOINTS, ENDPOINTS,
HIERARCHY_MAX_RESPONSE_BYTES,
MAX_ARRAY_OBJECTS,
axiosTransport, axiosTransport,
createAltaClient, createAltaClient,
}; };
+127
View File
@@ -0,0 +1,127 @@
'use strict';
const { validateDeviceId } = require('./proxy-launch');
const MAX_PROJECTED_PAYLOAD_BYTES = 8 * 1024 * 1024;
function projectionError(code, message) {
const error = new Error(message);
error.code = code;
return error;
}
function nullableString(value) {
return typeof value === 'string' ? value : null;
}
function nullableId(value) {
return typeof value === 'string' && value.length > 0 ? value : null;
}
function nullableBoolean(value) {
return typeof value === 'boolean' ? value : null;
}
function projectDevice(raw) {
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return null;
let id;
try {
id = validateDeviceId(raw.guid);
} catch {
return null;
}
return {
id,
name: nullableString(raw.name),
type: nullableString(raw.type),
model: nullableString(raw.model),
address: nullableString(raw.address),
siteId: nullableId(raw.server_group_id),
deviceGroupId: nullableId(raw.device_group_id),
displayStatus: nullableString(raw.live && raw.live.display_status),
localStorage: nullableBoolean(raw.capabilities && raw.capabilities.localStorage),
};
}
function projectSite(raw) {
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return null;
const id = nullableId(raw.id);
if (!id) return null;
return {
id,
name: nullableString(raw.name),
pendingDeletionStart: nullableString(raw.pending_deletion_start),
};
}
function projectGroup(raw) {
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return null;
const id = nullableId(raw.id);
if (!id) return null;
return {
id,
name: nullableString(raw.name),
parentId: nullableId(raw.parent_id),
pendingDeletionStart: nullableString(raw.pending_deletion_start),
};
}
function projectArray(values, projector) {
const projected = [];
let invalid = 0;
for (const value of Array.isArray(values) ? values : []) {
const item = projector(value);
if (item) projected.push(item);
else invalid += 1;
}
return { projected, invalid };
}
function safeMetadataError(value) {
return typeof value === 'string' && value.length > 0 ? value.slice(0, 512) : null;
}
function projectDeviceHierarchy({ devices = [], sites = [], groups = [], metadataErrors = {} } = {}) {
if (!Array.isArray(devices) || !Array.isArray(sites) || !Array.isArray(groups)) {
throw new TypeError('Hierarchy projection requires arrays');
}
const deviceProjection = projectArray(devices, projectDevice);
const siteProjection = projectArray(sites, projectSite);
const groupProjection = projectArray(groups, projectGroup);
const payload = {
devices: deviceProjection.projected,
sites: siteProjection.projected,
groups: groupProjection.projected,
diagnostics: {
devices: {
received: devices.length,
eligible: deviceProjection.projected.length,
invalid: deviceProjection.invalid,
},
sites: {
received: sites.length,
projected: siteProjection.projected.length,
invalid: siteProjection.invalid,
error: safeMetadataError(metadataErrors.sites),
},
groups: {
received: groups.length,
projected: groupProjection.projected.length,
invalid: groupProjection.invalid,
error: safeMetadataError(metadataErrors.groups),
},
},
};
if (Buffer.byteLength(JSON.stringify(payload)) > MAX_PROJECTED_PAYLOAD_BYTES) {
throw projectionError('PROJECTED_PAYLOAD_TOO_LARGE', 'Projected Alta hierarchy exceeded the IPC size limit');
}
return payload;
}
module.exports = {
MAX_PROJECTED_PAYLOAD_BYTES,
projectDevice,
projectDeviceHierarchy,
projectGroup,
projectSite,
};
+74 -8
View File
@@ -10,14 +10,18 @@ const {
readJsonBody, readJsonBody,
} = require('./bridge-auth'); } = require('./bridge-auth');
const { RELEASES_PAGE_URL } = require('./update-policy'); const { RELEASES_PAGE_URL } = require('./update-policy');
const { projectDeviceHierarchy, projectGroup, projectSite } = require('./device-projection');
const { validateDeviceId, validateUsername } = require('./proxy-launch'); const { validateDeviceId, validateUsername } = require('./proxy-launch');
const DEFAULT_DISCOVERY_TIMEOUT_MS = 60_000;
const PAIRING_ENVELOPE_FILENAME = 'bridge-pairing.json'; const PAIRING_ENVELOPE_FILENAME = 'bridge-pairing.json';
function safeErrorMessage(error, fallback) { function safeErrorMessage(error, fallback) {
const allowed = new Set([ const allowed = new Set([
'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',
'ALTA_RESPONSE_TOO_MANY_OBJECTS', 'PROJECTED_PAYLOAD_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', 'INVALID_USERNAME', 'SPAWN_FAILED', 'UNSUPPORTED_PLATFORM', 'INVALID_DEVICE_ID', 'INVALID_USERNAME', 'SPAWN_FAILED',
]); ]);
@@ -196,13 +200,19 @@ class AppRuntime {
checkForUpdate, checkForUpdate,
currentVersion, currentVersion,
openExternal, openExternal,
discoveryTimeoutMs = DEFAULT_DISCOVERY_TIMEOUT_MS,
} = {}) { } = {}) {
if (!Number.isInteger(discoveryTimeoutMs) || discoveryTimeoutMs < 1 ||
discoveryTimeoutMs > DEFAULT_DISCOVERY_TIMEOUT_MS) {
throw new RangeError('AppRuntime discovery timeout must be between 1 and 60000ms');
}
this.sessionStore = sessionStore; this.sessionStore = sessionStore;
this.altaClient = altaClient; this.altaClient = altaClient;
this.proxyManager = proxyManager; this.proxyManager = proxyManager;
this.checkForUpdatePolicy = checkForUpdate; this.checkForUpdatePolicy = checkForUpdate;
this.currentVersion = currentVersion; this.currentVersion = currentVersion;
this.openExternal = openExternal; this.openExternal = openExternal;
this.discoveryTimeoutMs = discoveryTimeoutMs;
this.allowedDeviceIds = new Set(); this.allowedDeviceIds = new Set();
this.proxyByDevice = new Map(); this.proxyByDevice = new Map();
} }
@@ -223,15 +233,17 @@ class AppRuntime {
return tracked.filter((proxy) => this.proxyByDevice.get(proxy.deviceId) === proxy.processId); return tracked.filter((proxy) => this.proxyByDevice.get(proxy.deviceId) === proxy.processId);
} }
_setAllowedDevices(devices) {
this.allowedDeviceIds = new Set(devices.map((device) => device.id));
}
async getDevices() { async getDevices() {
try { try {
const devices = await this.altaClient.getDevices(); const hierarchy = projectDeviceHierarchy({
this.allowedDeviceIds = new Set(); devices: await this.altaClient.getDevices(), sites: [], groups: [],
for (const device of devices) { });
const candidate = device && (device.guid || device.id); this._setAllowedDevices(hierarchy.devices);
try { this.allowedDeviceIds.add(validateDeviceId(candidate)); } catch {} return { success: true, devices: hierarchy.devices };
}
return { success: true, devices };
} catch (error) { } catch (error) {
return { success: false, devices: [], message: safeErrorMessage(error, 'Failed to get devices') }; return { success: false, devices: [], message: safeErrorMessage(error, 'Failed to get devices') };
} }
@@ -239,12 +251,66 @@ class AppRuntime {
async getDeviceSites() { async getDeviceSites() {
try { try {
return { success: true, sites: await this.altaClient.getDeviceSites() }; const sites = (await this.altaClient.getDeviceSites()).map(projectSite).filter(Boolean);
return { success: true, sites };
} catch (error) { } catch (error) {
return { success: false, sites: [], message: safeErrorMessage(error, 'Failed to get device sites') }; return { success: false, sites: [], message: safeErrorMessage(error, 'Failed to get device sites') };
} }
} }
async getDeviceGroups() {
try {
const groups = (await this.altaClient.getDeviceGroups()).map(projectGroup).filter(Boolean);
return { success: true, groups };
} catch (error) {
return { success: false, groups: [], message: safeErrorMessage(error, 'Failed to get device groups') };
}
}
async getDeviceHierarchy() {
let timer;
try {
const discovery = Promise.allSettled([
this.altaClient.getDevices(),
this.altaClient.getDeviceSites(),
this.altaClient.getDeviceGroups(),
]);
const deadline = new Promise((_, reject) => {
timer = setTimeout(() => {
const error = new Error('Alta device discovery timed out');
error.code = 'DISCOVERY_TIMEOUT';
reject(error);
}, this.discoveryTimeoutMs);
});
const [deviceResult, siteResult, groupResult] = await Promise.race([discovery, deadline]);
if (deviceResult.status === 'rejected') throw deviceResult.reason;
const metadataErrors = {};
if (siteResult.status === 'rejected') {
metadataErrors.sites = 'Device sites unavailable';
}
if (groupResult.status === 'rejected') {
metadataErrors.groups = 'Device groups unavailable';
}
const hierarchy = projectDeviceHierarchy({
devices: deviceResult.value,
sites: siteResult.status === 'fulfilled' ? siteResult.value : [],
groups: groupResult.status === 'fulfilled' ? groupResult.value : [],
metadataErrors,
});
this._setAllowedDevices(hierarchy.devices);
return { success: true, hierarchy };
} catch (error) {
this.allowedDeviceIds.clear();
const message = error && error.code === 'DISCOVERY_TIMEOUT'
? 'Alta device discovery timed out'
: safeErrorMessage(error, 'Failed to discover Alta devices');
return { success: false, hierarchy: { devices: [], sites: [], groups: [] }, message };
} finally {
clearTimeout(timer);
}
}
async getAuthInfo() { async getAuthInfo() {
try { try {
return { success: true, authInfo: await this.altaClient.getAuthInfo() }; return { success: true, authInfo: await this.altaClient.getAuthInfo() };
+55 -2
View File
@@ -3,7 +3,12 @@
const test = require('node:test'); const test = require('node:test');
const assert = require('node:assert/strict'); const assert = require('node:assert/strict');
const { SessionStore } = require('../src/session-store'); const { SessionStore } = require('../src/session-store');
const { AltaClient } = require('../src/alta-client'); const {
AltaClient,
DEVICE_MAX_RESPONSE_BYTES,
HIERARCHY_MAX_RESPONSE_BYTES,
MAX_ARRAY_OBJECTS,
} = require('../src/alta-client');
const ORIGIN = 'https://tenant.avasecurity.com'; const ORIGIN = 'https://tenant.avasecurity.com';
const SENTINEL = 'HERMES_SENTINEL_SECRET'; const SENTINEL = 'HERMES_SENTINEL_SECRET';
@@ -24,17 +29,20 @@ test('uses only stored authority, fixed endpoint paths and hardened transport op
calls.push(options); calls.push(options);
if (options.url.endsWith('/devices')) return response([]); if (options.url.endsWith('/devices')) return response([]);
if (options.url.endsWith('/deviceSites')) return response([{ id: 'site-1' }]); if (options.url.endsWith('/deviceSites')) return response([{ id: 'site-1' }]);
if (options.url.endsWith('/deviceGroups')) return response([{ id: 'group-1' }]);
return response({ user: 'engineer' }); return response({ user: 'engineer' });
}; };
const client = new AltaClient({ sessionStore: readyStore(), transport }); const client = new AltaClient({ sessionStore: readyStore(), transport });
assert.deepEqual(await client.getDevices(), []); assert.deepEqual(await client.getDevices(), []);
assert.deepEqual(await client.getDeviceSites(), [{ id: 'site-1' }]); assert.deepEqual(await client.getDeviceSites(), [{ id: 'site-1' }]);
assert.deepEqual(await client.getDeviceGroups(), [{ id: 'group-1' }]);
assert.deepEqual(await client.getAuthInfo(), { user: 'engineer' }); assert.deepEqual(await client.getAuthInfo(), { user: 'engineer' });
assert.deepEqual(calls.map((call) => call.url), [ assert.deepEqual(calls.map((call) => call.url), [
`${ORIGIN}/api/v1/devices`, `${ORIGIN}/api/v1/devices`,
`${ORIGIN}/api/v1/deviceSites`, `${ORIGIN}/api/v1/deviceSites`,
`${ORIGIN}/api/v1/deviceGroups`,
`${ORIGIN}/api/v1/auth`, `${ORIGIN}/api/v1/auth`,
]); ]);
for (const call of calls) { for (const call of calls) {
@@ -42,11 +50,56 @@ test('uses only stored authority, fixed endpoint paths and hardened transport op
assert.equal(call.proxy, false); assert.equal(call.proxy, false);
assert.equal(call.maxRedirects, 0); assert.equal(call.maxRedirects, 0);
assert.match(call.headers.Cookie, /^va=HERMES_SENTINEL_SECRET$/); assert.match(call.headers.Cookie, /^va=HERMES_SENTINEL_SECRET$/);
assert.ok(call.timeout > 0 && call.timeout <= 10_000); assert.ok(call.timeout > 0 && call.timeout <= 30_000);
assert.ok(call.maxResponseBytes > 0); assert.ok(call.maxResponseBytes > 0);
} }
}); });
test('uses endpoint-specific 32 MiB/4 MiB limits and a 30 second request deadline', async () => {
const calls = [];
const client = new AltaClient({
sessionStore: readyStore(),
transport: async (options) => { calls.push(options); return response([]); },
});
await client.getDevices();
await client.getDeviceSites();
await client.getDeviceGroups();
assert.deepEqual(calls.map(({ maxResponseBytes }) => maxResponseBytes), [
DEVICE_MAX_RESPONSE_BYTES,
HIERARCHY_MAX_RESPONSE_BYTES,
HIERARCHY_MAX_RESPONSE_BYTES,
]);
assert.ok(calls.every(({ timeout }) => timeout > 0 && timeout <= 30_000));
});
test('accepts device bodies above 2 MiB and rejects bodies above 32 MiB before parsing', async () => {
const acceptedBody = JSON.stringify([{
guid: '550e8400-e29b-41d4-a716-446655440000',
padding: 'x'.repeat((2 * 1024 * 1024) + 1),
}]);
const accepted = new AltaClient({
sessionStore: readyStore(),
transport: async () => response(Buffer.from(acceptedBody)),
});
assert.equal((await accepted.getDevices()).length, 1);
const rejected = new AltaClient({
sessionStore: readyStore(),
transport: async () => response(Buffer.alloc(DEVICE_MAX_RESPONSE_BYTES + 1, 0x20)),
});
await assert.rejects(rejected.getDevices(), { code: 'ALTA_RESPONSE_TOO_LARGE' });
});
test('rejects plain array responses containing more than 10,000 objects', async () => {
const client = new AltaClient({
sessionStore: readyStore(),
transport: async () => response(Array.from({ length: MAX_ARRAY_OBJECTS + 1 }, () => ({}))),
});
await assert.rejects(client.getDevices(), { code: 'ALTA_RESPONSE_TOO_MANY_OBJECTS' });
});
test('rejects renderer-supplied URL/cookie parameters before transport', async () => { test('rejects renderer-supplied URL/cookie parameters before transport', async () => {
let calls = 0; let calls = 0;
const client = new AltaClient({ const client = new AltaClient({
+83
View File
@@ -0,0 +1,83 @@
'use strict';
const test = require('node:test');
const assert = require('node:assert/strict');
const {
MAX_PROJECTED_PAYLOAD_BYTES,
projectDeviceHierarchy,
} = require('../src/device-projection');
function uuid(index) {
return `00000000-0000-4000-8000-${index.toString(16).padStart(12, '0')}`;
}
function rawDevice(index, overrides = {}) {
return {
guid: uuid(index),
name: `Camera ${index}`,
type: 'camera',
model: 'Synthetic',
address: `10.0.${Math.floor(index / 255)}.${index % 255}`,
server_group_id: 'site-1',
device_group_id: 'group-1',
capabilities: { localStorage: index % 2 === 0, secretCapability: 'drop-me' },
live: { display_status: 'online', private: 'drop-me' },
cookie: 'must-not-cross-ipc',
...overrides,
};
}
test('projects only allowlisted fields and honestly diagnoses ineligible devices', () => {
const payload = projectDeviceHierarchy({
devices: [rawDevice(1), rawDevice(2, { guid: 'not-a-uuid' })],
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,
}]);
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.doesNotMatch(JSON.stringify(payload), /drop-me|cookie|secretCapability|private/);
});
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)),
sites: [],
groups: [],
});
assert.equal(payload.devices.length, count);
assert.equal(payload.diagnostics.devices.received, count);
assert.ok(Buffer.byteLength(JSON.stringify(payload)) <= MAX_PROJECTED_PAYLOAD_BYTES);
}
});
test('malformed hierarchy metadata does not hide valid cameras', () => {
const payload = projectDeviceHierarchy({
devices: [rawDevice(7)],
sites: [null, { id: {}, name: 'bad' }],
groups: ['bad'],
metadataErrors: { sites: 'Unavailable', groups: 'Unavailable' },
});
assert.equal(payload.devices.length, 1);
assert.deepEqual(payload.sites, []);
assert.deepEqual(payload.groups, []);
assert.equal(payload.diagnostics.sites.invalid, 2);
assert.equal(payload.diagnostics.groups.invalid, 1);
assert.equal(payload.diagnostics.sites.error, 'Unavailable');
});
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)}`,
}));
assert.throws(
() => projectDeviceHierarchy({ devices, sites: [], groups: [] }),
{ code: 'PROJECTED_PAYLOAD_TOO_LARGE' },
);
});
+67 -2
View File
@@ -78,7 +78,20 @@ test('runtime keeps Alta credentials in main-owned modules and exposes only non-
openExternal: async () => {}, openExternal: async () => {},
}); });
assert.deepEqual(await runtime.getDevices(), { success: true, devices: [{ guid: '550e8400-e29b-41d4-a716-446655440000' }] }); assert.deepEqual(await runtime.getDevices(), {
success: true,
devices: [{
id: '550e8400-e29b-41d4-a716-446655440000',
name: null,
type: null,
model: null,
address: null,
siteId: null,
deviceGroupId: null,
displayStatus: null,
localStorage: null,
}],
});
const launched = await runtime.launchProxy( const launched = await runtime.launchProxy(
'550e8400-e29b-41d4-a716-446655440000', '550e8400-e29b-41d4-a716-446655440000',
'proxy.operator@example.com' 'proxy.operator@example.com'
@@ -102,6 +115,57 @@ 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 discovers and projects hierarchy while metadata failures preserve cameras', async () => {
const deviceId = '550e8400-e29b-41d4-a716-446655440000';
const runtime = new AppRuntime({
sessionStore: createSessionStore(),
altaClient: {
getDevices: async () => [{
guid: deviceId,
name: 'Camera',
server_group_id: 'site-1',
secret: 'drop-me',
}],
getDeviceSites: async () => { throw Object.assign(new Error('tenant secret'), { code: 'ALTA_HTTP_ERROR' }); },
getDeviceGroups: async () => [{ id: 'group-1', name: 'Lobby', parent_id: null }],
},
proxyManager: { listTrackedProxies: () => [] },
});
const result = await runtime.getDeviceHierarchy();
assert.equal(result.success, true);
assert.deepEqual(result.hierarchy.devices.map(({ id }) => id), [deviceId]);
assert.deepEqual(result.hierarchy.sites, []);
assert.deepEqual(result.hierarchy.groups, [{
id: 'group-1', name: 'Lobby', parentId: null, pendingDeletionStart: null,
}]);
assert.equal(result.hierarchy.diagnostics.sites.error, 'Device sites unavailable');
assert.doesNotMatch(JSON.stringify(result), /drop-me|tenant secret/);
});
test('runtime enforces a bounded whole-discovery deadline', async () => {
const runtime = new AppRuntime({
sessionStore: createSessionStore(),
altaClient: {
getDevices: async () => new Promise(() => {}),
getDeviceSites: async () => [],
getDeviceGroups: async () => [],
},
proxyManager: { listTrackedProxies: () => [] },
discoveryTimeoutMs: 25,
});
const result = await runtime.getDeviceHierarchy();
assert.deepEqual(result, {
success: false,
hierarchy: { devices: [], sites: [], groups: [] },
message: 'Alta device discovery timed out',
});
});
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 () => { test('runtime rejects invalid usernames before calling the proxy manager', async () => {
const sessionStore = createSessionStore(); const sessionStore = createSessionStore();
sessionStore.establish('https://customer.avasecurity.com', 'top-secret-cookie'); sessionStore.establish('https://customer.avasecurity.com', 'top-secret-cookie');
@@ -364,7 +428,8 @@ test('preload and renderer expose only narrow, credential-free contracts', () =>
const renderer = read('renderer.js'); const renderer = read('renderer.js');
const html = read('index.html'); const html = read('index.html');
const expectedMethods = [ const expectedMethods = [
'getDevices', 'getDeviceSites', 'getAuthInfo', 'launchProxy', 'stopProxy', 'disconnect', 'getDevices', 'getDeviceSites', 'getDeviceGroups', 'getDeviceHierarchy', 'getAuthInfo',
'launchProxy', 'stopProxy', 'disconnect',
'getConnectionState', 'checkForUpdates', 'openFixedReleasesPage', 'rotatePairing', 'getConnectionState', 'checkForUpdates', 'openFixedReleasesPage', 'rotatePairing',
'revokePairing', 'getPairingStatus', 'onConnectionStateChanged', 'revokePairing', 'getPairingStatus', 'onConnectionStateChanged',
]; ];