fix: add main-process Alta session boundary

This commit is contained in:
2026-08-19 21:22:17 +00:00
parent f5cfdd8560
commit b503b5777a
8 changed files with 913 additions and 0 deletions
+255
View File
@@ -0,0 +1,255 @@
'use strict';
const { canonicalizeAltaOrigin, assertSameAltaOrigin } = require('./url-policy');
const { formatAltaError } = require('./error-redaction');
const DEFAULT_TIMEOUT_MS = 10_000;
const DEFAULT_MAX_RESPONSE_BYTES = 2 * 1024 * 1024;
const DEFAULT_MAX_REDIRECTS = 3;
const ENDPOINTS = Object.freeze({
getDevices: Object.freeze({ path: '/api/v1/devices', shape: 'array' }),
getDeviceSites: Object.freeze({ path: '/api/v1/deviceSites', shape: 'array' }),
getAuthInfo: Object.freeze({ path: '/api/v1/auth', shape: 'object' }),
});
const REDIRECT_STATUSES = new Set([301, 302, 303, 307, 308]);
function altaError(code, message, extra = {}) {
const error = new Error(message);
error.code = code;
Object.assign(error, extra);
return error;
}
async function axiosTransport(options) {
// Lazy loading keeps pure/injected transport tests independent of installed
// runtime dependencies while production still uses the pinned Axios package.
const axios = require('axios');
return axios.request({
method: options.method,
url: options.url,
headers: options.headers,
timeout: options.timeout,
signal: options.signal,
proxy: false,
maxRedirects: 0,
maxContentLength: options.maxResponseBytes,
maxBodyLength: 0,
responseType: 'arraybuffer',
validateStatus: () => true,
transitional: { clarifyTimeoutError: true },
});
}
function normalizeHeaders(headers) {
if (!headers || typeof headers !== 'object') return Object.freeze({});
const normalized = {};
for (const [key, value] of Object.entries(headers)) {
normalized[String(key).toLowerCase()] = Array.isArray(value) ? value[0] : value;
}
return Object.freeze(normalized);
}
function measuredData(data) {
if (Buffer.isBuffer(data) || data instanceof Uint8Array) {
return { bytes: data.byteLength, raw: Buffer.from(data).toString('utf8') };
}
if (typeof data === 'string') {
return { bytes: Buffer.byteLength(data), raw: data };
}
if (data === undefined) {
throw altaError('INVALID_ALTA_RESPONSE', 'Alta transport returned no response data');
}
try {
const raw = JSON.stringify(data);
if (raw === undefined) throw new Error('not serializable');
return { bytes: Buffer.byteLength(raw), value: data };
} catch {
throw altaError('INVALID_ALTA_RESPONSE_DATA', 'Alta response data is malformed');
}
}
function parseData(data, maxBytes) {
const measured = measuredData(data);
if (measured.bytes > maxBytes) {
throw altaError('ALTA_RESPONSE_TOO_LARGE', 'Alta response exceeded the size limit');
}
if (Object.prototype.hasOwnProperty.call(measured, 'value')) return measured.value;
try {
return JSON.parse(measured.raw);
} catch {
throw altaError('INVALID_ALTA_RESPONSE_DATA', 'Alta response was not valid JSON');
}
}
function enforceResponseSize(data, headers, maxBytes) {
const contentLength = headers['content-length'];
if (contentLength !== undefined) {
const parsedLength = Number(contentLength);
if (!Number.isSafeInteger(parsedLength) || parsedLength < 0) {
throw altaError('INVALID_ALTA_RESPONSE', 'Alta response had an invalid content length');
}
if (parsedLength > maxBytes) {
throw altaError('ALTA_RESPONSE_TOO_LARGE', 'Alta response exceeded the size limit');
}
}
if (data !== undefined && measuredData(data).bytes > maxBytes) {
throw altaError('ALTA_RESPONSE_TOO_LARGE', 'Alta response exceeded the size limit');
}
}
function safeTransportError(operation, error, cookie) {
if (error && typeof error === 'object' && typeof error.code === 'string' && error.code.startsWith('ALTA_')) {
return error;
}
const formatted = formatAltaError(operation, error, { secrets: [cookie, `va=${cookie}`] });
const safe = altaError(formatted.code, formatted.message, {
status: formatted.status,
timeout: formatted.timeout,
});
return safe;
}
class AltaClient {
constructor({
sessionStore,
transport = axiosTransport,
timeoutMs = DEFAULT_TIMEOUT_MS,
maxResponseBytes = DEFAULT_MAX_RESPONSE_BYTES,
maxRedirects = DEFAULT_MAX_REDIRECTS,
} = {}) {
if (!sessionStore || typeof sessionStore.requireSession !== 'function') {
throw new TypeError('AltaClient requires a session store');
}
if (typeof transport !== 'function') throw new TypeError('AltaClient transport must be a function');
if (!Number.isInteger(timeoutMs) || timeoutMs < 1 || timeoutMs > DEFAULT_TIMEOUT_MS) {
throw new RangeError('AltaClient timeout must be between 1 and 10000ms');
}
if (!Number.isInteger(maxResponseBytes) || maxResponseBytes < 1 || maxResponseBytes > DEFAULT_MAX_RESPONSE_BYTES) {
throw new RangeError('Invalid Alta response size limit');
}
if (!Number.isInteger(maxRedirects) || maxRedirects < 0 || maxRedirects > 5) {
throw new RangeError('Invalid Alta redirect limit');
}
this.sessionStore = sessionStore;
this.transport = transport;
this.timeoutMs = timeoutMs;
this.maxResponseBytes = maxResponseBytes;
this.maxRedirects = maxRedirects;
}
getDevices(...args) {
return this.#invoke('getDevices', args);
}
getDeviceSites(...args) {
return this.#invoke('getDeviceSites', args);
}
getAuthInfo(...args) {
return this.#invoke('getAuthInfo', args);
}
async #invoke(operation, args) {
if (args.length !== 0) {
throw altaError('INVALID_ALTA_ARGUMENTS', 'Alta API methods do not accept renderer parameters');
}
const endpoint = ENDPOINTS[operation];
const session = this.sessionStore.requireSession();
const origin = canonicalizeAltaOrigin(session.origin);
const cookie = session.cookie;
const deadline = Date.now() + this.timeoutMs;
const controller = new AbortController();
try {
const data = await this.#request(operation, `${origin}${endpoint.path}`, origin, cookie, deadline, controller, 0);
if (endpoint.shape === 'array' && !Array.isArray(data)) {
throw altaError('INVALID_ALTA_RESPONSE_DATA', 'Alta response did not contain an array');
}
if (endpoint.shape === 'object' && (!data || typeof data !== 'object' || Array.isArray(data))) {
throw altaError('INVALID_ALTA_RESPONSE_DATA', 'Alta response did not contain an object');
}
return data;
} catch (error) {
throw safeTransportError(operation, error, cookie);
} finally {
controller.abort();
}
}
async #request(operation, url, origin, cookie, deadline, controller, redirectCount) {
assertSameAltaOrigin(url, origin);
const remaining = deadline - Date.now();
if (remaining <= 0) throw altaError('ALTA_TIMEOUT', 'Alta request timed out', { timeout: true });
let timer;
const timeoutPromise = new Promise((_, reject) => {
timer = setTimeout(() => {
controller.abort();
reject(altaError('ALTA_TIMEOUT', 'Alta request timed out', { timeout: true }));
}, remaining);
});
let response;
try {
response = await Promise.race([
Promise.resolve(this.transport(Object.freeze({
method: 'GET',
url,
headers: Object.freeze({ Cookie: `va=${cookie}`, Accept: 'application/json' }),
timeout: remaining,
signal: controller.signal,
proxy: false,
maxRedirects: 0,
maxResponseBytes: this.maxResponseBytes,
}))),
timeoutPromise,
]);
} finally {
clearTimeout(timer);
}
if (!response || typeof response !== 'object' || !Number.isInteger(response.status)) {
throw altaError('INVALID_ALTA_RESPONSE', 'Alta transport returned an invalid response');
}
const headers = normalizeHeaders(response.headers);
// Bound every response, including redirects and errors, before acting on it.
enforceResponseSize(response.data, headers, this.maxResponseBytes);
if (REDIRECT_STATUSES.has(response.status)) {
if (typeof headers.location !== 'string' || headers.location.length === 0) {
throw altaError('UNSAFE_ALTA_REDIRECT', 'Alta redirect did not contain a safe location');
}
if (redirectCount >= this.maxRedirects) {
throw altaError('TOO_MANY_ALTA_REDIRECTS', 'Alta response exceeded the redirect limit');
}
let target;
try {
target = new URL(headers.location, url);
assertSameAltaOrigin(target.href, origin);
} catch {
throw altaError('UNSAFE_ALTA_REDIRECT', 'Alta redirect changed the validated origin');
}
return this.#request(operation, target.href, origin, cookie, deadline, controller, redirectCount + 1);
}
if (response.status < 200 || response.status > 299) {
throw altaError('ALTA_HTTP_ERROR', 'Alta request returned an error status', { status: response.status });
}
return parseData(response.data, this.maxResponseBytes);
}
}
function createAltaClient(options) {
return new AltaClient(options);
}
module.exports = {
AltaClient,
DEFAULT_MAX_REDIRECTS,
DEFAULT_MAX_RESPONSE_BYTES,
DEFAULT_TIMEOUT_MS,
ENDPOINTS,
axiosTransport,
createAltaClient,
};
+85
View File
@@ -0,0 +1,85 @@
'use strict';
const MAX_OPERATION_LENGTH = 64;
const MAX_CODE_LENGTH = 64;
const MAX_MESSAGE_LENGTH = 240;
const REDACTED = '[REDACTED]';
function safeRead(object, key) {
try {
return object && typeof object === 'object' ? object[key] : undefined;
} catch {
return undefined;
}
}
function redactKnownPatterns(message) {
return message
.replace(/\bauthorization\s*:\s*bearer\s+[^\s,;]+/gi, REDACTED)
.replace(/\bbearer\s+[^\s,;]+/gi, `Bearer ${REDACTED}`)
.replace(/\bcookie\s*:\s*[^\r\n]*/gi, REDACTED)
.replace(/\b(va\s*=\s*)[^\s;,]*/gi, `$1${REDACTED}`);
}
function sanitizeErrorMessage(value, options = {}) {
let message = typeof value === 'string' ? value : 'Alta request failed';
const secrets = Array.isArray(options.secrets) ? options.secrets : [];
for (const secret of secrets) {
if (typeof secret === 'string' && secret.length > 0) {
message = message.split(secret).join(REDACTED);
}
}
message = redactKnownPatterns(message)
.replace(/[\r\n\t]+/g, ' ')
.replace(/[\u0000-\u001f\u007f]/g, '')
.replace(/\s{2,}/g, ' ')
.trim();
if (!message) message = 'Alta request failed';
return message.slice(0, MAX_MESSAGE_LENGTH);
}
function includesSecret(value, secrets) {
return secrets.some((secret) => typeof secret === 'string' && secret.length > 0 && value.includes(secret));
}
function safeIdentifier(value, fallback, maxLength, secrets) {
if (typeof value !== 'string' || !/^[A-Za-z0-9_.:-]+$/.test(value)) return fallback;
if (includesSecret(value, secrets)) return fallback;
return value.slice(0, maxLength);
}
function safeStatus(error) {
const response = safeRead(error, 'response');
const candidate = safeRead(response, 'status') ?? safeRead(error, 'status');
return Number.isInteger(candidate) && candidate >= 100 && candidate <= 599 ? candidate : null;
}
function formatAltaError(operation, error, options = {}) {
const secrets = Array.isArray(options.secrets) ? options.secrets : [];
const rawMessage = safeRead(error, 'message');
const rawCode = safeRead(error, 'code');
const timeoutValue = safeRead(error, 'timeout');
const timeout = timeoutValue === true || rawCode === 'ECONNABORTED' || rawCode === 'ETIMEDOUT' || rawCode === 'ALTA_TIMEOUT';
// Deliberately construct a fresh allowlisted object. Never copy or spread the
// source error: Axios config/request/headers/data can carry the bearer cookie.
return Object.freeze({
operation: safeIdentifier(operation, 'altaRequest', MAX_OPERATION_LENGTH, secrets),
code: safeIdentifier(rawCode, 'ALTA_ERROR', MAX_CODE_LENGTH, secrets),
status: safeStatus(error),
timeout,
message: sanitizeErrorMessage(rawMessage, options),
});
}
module.exports = {
MAX_CODE_LENGTH,
MAX_MESSAGE_LENGTH,
MAX_OPERATION_LENGTH,
formatAltaError,
formatError: formatAltaError,
sanitizeErrorMessage,
};
+81
View File
@@ -0,0 +1,81 @@
'use strict';
const { canonicalizeAltaOrigin } = require('./url-policy');
const MAX_COOKIE_LENGTH = 4096;
function sessionError(code, message) {
const error = new Error(message);
error.code = code;
return error;
}
function validateCookie(cookie) {
if (
typeof cookie !== 'string' ||
cookie.length === 0 ||
cookie.length > MAX_COOKIE_LENGTH ||
/[;\r\n\0]/.test(cookie)
) {
throw sessionError('INVALID_SESSION_COOKIE', 'Invalid Alta session cookie');
}
return cookie;
}
class SessionStore {
#origin = null;
#cookie = null;
#disposed = false;
establish(deploymentOrigin, cookie) {
if (this.#disposed) {
throw sessionError('SESSION_STORE_DISPOSED', 'Alta session store is disposed');
}
// Validate the complete replacement before dropping the current session.
const nextOrigin = canonicalizeAltaOrigin(deploymentOrigin);
const nextCookie = validateCookie(cookie);
this.#origin = nextOrigin;
this.#cookie = nextCookie;
return this.describe();
}
setSession(deploymentOrigin, cookie) {
return this.establish(deploymentOrigin, cookie);
}
requireSession() {
if (this.#disposed || this.#origin === null || this.#cookie === null) {
throw sessionError('NO_ALTA_SESSION', 'No active Alta session');
}
return Object.freeze({ origin: this.#origin, cookie: this.#cookie });
}
describe() {
return Object.freeze({
connected: !this.#disposed && this.#origin !== null && this.#cookie !== null,
origin: !this.#disposed ? this.#origin : null,
});
}
clear() {
this.#cookie = null;
this.#origin = null;
}
dispose() {
this.clear();
this.#disposed = true;
}
}
function createSessionStore() {
return new SessionStore();
}
module.exports = {
MAX_COOKIE_LENGTH,
SessionStore,
createSessionStore,
validateCookie,
};
+92
View File
@@ -0,0 +1,92 @@
'use strict';
const MAX_ORIGIN_LENGTH = 512;
const ALTA_SUFFIXES = Object.freeze(['avasecurity.com', 'avigilon.com']);
function policyError(message = 'Invalid Alta deployment origin') {
const error = new TypeError(message);
error.code = 'INVALID_ALTA_ORIGIN';
return error;
}
function canonicalizeAltaOrigin(value) {
if (typeof value !== 'string' || value.length === 0 || value.length > MAX_ORIGIN_LENGTH) {
throw policyError();
}
if (value !== value.trim() || /[\u0000-\u0020\u007f]/.test(value)) {
throw policyError();
}
let parsed;
try {
parsed = new URL(value);
} catch {
throw policyError();
}
if (parsed.protocol !== 'https:' || parsed.username || parsed.password || parsed.hash || parsed.search) {
throw policyError();
}
if (parsed.port && parsed.port !== '443') {
throw policyError();
}
if (parsed.pathname !== '/' && parsed.pathname !== '') {
throw policyError();
}
const hostname = parsed.hostname.toLowerCase();
const suffix = ALTA_SUFFIXES.find((candidate) => hostname.endsWith(`.${candidate}`));
if (!suffix) {
throw policyError();
}
const subdomain = hostname.slice(0, -(suffix.length + 1));
const labels = subdomain.split('.');
if (labels.some((label) => !/^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/.test(label))) {
throw policyError();
}
return `https://${hostname}`;
}
function isAltaOrigin(value) {
try {
canonicalizeAltaOrigin(value);
return true;
} catch {
return false;
}
}
function assertSameAltaOrigin(candidate, expectedOrigin) {
if (typeof candidate !== 'string' || /[\u0000-\u0020\u007f]/.test(candidate)) {
throw policyError('Invalid Alta request URL');
}
let target;
try {
target = new URL(candidate);
} catch {
throw policyError('Invalid Alta request URL');
}
const expected = canonicalizeAltaOrigin(expectedOrigin);
const actual = canonicalizeAltaOrigin(target.origin);
if (
actual !== expected ||
target.origin !== expected ||
target.username ||
target.password ||
target.hash
) {
throw policyError('Alta request URL changed origin');
}
return target;
}
module.exports = {
ALTA_SUFFIXES,
MAX_ORIGIN_LENGTH,
assertSameAltaOrigin,
canonicalizeAltaOrigin,
isAltaOrigin,
validateAltaOrigin: canonicalizeAltaOrigin,
};