fix: add main-process Alta session boundary
This commit is contained in:
@@ -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,
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
@@ -0,0 +1,176 @@
|
||||
'use strict';
|
||||
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const { SessionStore } = require('../src/session-store');
|
||||
const { AltaClient } = require('../src/alta-client');
|
||||
|
||||
const ORIGIN = 'https://tenant.avasecurity.com';
|
||||
const SENTINEL = 'HERMES_SENTINEL_SECRET';
|
||||
|
||||
function readyStore() {
|
||||
const store = new SessionStore();
|
||||
store.establish(ORIGIN, SENTINEL);
|
||||
return store;
|
||||
}
|
||||
|
||||
function response(data, overrides = {}) {
|
||||
return { status: 200, headers: {}, data, ...overrides };
|
||||
}
|
||||
|
||||
test('uses only stored authority, fixed endpoint paths and hardened transport options', async () => {
|
||||
const calls = [];
|
||||
const transport = async (options) => {
|
||||
calls.push(options);
|
||||
if (options.url.endsWith('/devices')) return response([]);
|
||||
if (options.url.endsWith('/deviceSites')) return response([{ id: 'site-1' }]);
|
||||
return response({ user: 'engineer' });
|
||||
};
|
||||
const client = new AltaClient({ sessionStore: readyStore(), transport });
|
||||
|
||||
assert.deepEqual(await client.getDevices(), []);
|
||||
assert.deepEqual(await client.getDeviceSites(), [{ id: 'site-1' }]);
|
||||
assert.deepEqual(await client.getAuthInfo(), { user: 'engineer' });
|
||||
|
||||
assert.deepEqual(calls.map((call) => call.url), [
|
||||
`${ORIGIN}/api/v1/devices`,
|
||||
`${ORIGIN}/api/v1/deviceSites`,
|
||||
`${ORIGIN}/api/v1/auth`,
|
||||
]);
|
||||
for (const call of calls) {
|
||||
assert.equal(call.method, 'GET');
|
||||
assert.equal(call.proxy, false);
|
||||
assert.equal(call.maxRedirects, 0);
|
||||
assert.match(call.headers.Cookie, /^va=HERMES_SENTINEL_SECRET$/);
|
||||
assert.ok(call.timeout > 0 && call.timeout <= 10_000);
|
||||
assert.ok(call.maxResponseBytes > 0);
|
||||
}
|
||||
});
|
||||
|
||||
test('rejects renderer-supplied URL/cookie parameters before transport', async () => {
|
||||
let calls = 0;
|
||||
const client = new AltaClient({
|
||||
sessionStore: readyStore(),
|
||||
transport: async () => { calls += 1; return response([]); },
|
||||
});
|
||||
|
||||
await assert.rejects(
|
||||
client.getDevices({ deploymentUrl: 'https://evil.example', cookies: [SENTINEL] }),
|
||||
{ code: 'INVALID_ALTA_ARGUMENTS' },
|
||||
);
|
||||
await assert.rejects(client.getAuthInfo(SENTINEL), { code: 'INVALID_ALTA_ARGUMENTS' });
|
||||
assert.equal(calls, 0);
|
||||
});
|
||||
|
||||
test('allows only bounded exact-same-origin redirects', async () => {
|
||||
const seen = [];
|
||||
const transport = async (options) => {
|
||||
seen.push(options.url);
|
||||
if (seen.length === 1) {
|
||||
return response('', { status: 307, headers: { location: '/api/v1/devices?cursor=next' } });
|
||||
}
|
||||
return response([]);
|
||||
};
|
||||
const client = new AltaClient({ sessionStore: readyStore(), transport });
|
||||
assert.deepEqual(await client.getDevices(), []);
|
||||
assert.deepEqual(seen, [
|
||||
`${ORIGIN}/api/v1/devices`,
|
||||
`${ORIGIN}/api/v1/devices?cursor=next`,
|
||||
]);
|
||||
|
||||
for (const location of [
|
||||
'https://evil.example/steal',
|
||||
'https://other.avasecurity.com/api/v1/devices',
|
||||
'http://tenant.avasecurity.com/api/v1/devices',
|
||||
'//evil.example/steal',
|
||||
`https://user:pass@tenant.avasecurity.com/api/v1/devices`,
|
||||
`${ORIGIN}/api/v1/devices#not-sent-to-server`,
|
||||
]) {
|
||||
let count = 0;
|
||||
const blocked = new AltaClient({
|
||||
sessionStore: readyStore(),
|
||||
transport: async () => {
|
||||
count += 1;
|
||||
return response('', { status: 302, headers: { location } });
|
||||
},
|
||||
});
|
||||
await assert.rejects(blocked.getDevices(), { code: 'UNSAFE_ALTA_REDIRECT' });
|
||||
assert.equal(count, 1, location);
|
||||
}
|
||||
});
|
||||
|
||||
test('enforces redirect limit and rejects redirects without locations', async () => {
|
||||
const looping = new AltaClient({
|
||||
sessionStore: readyStore(),
|
||||
maxRedirects: 2,
|
||||
transport: async () => response('', { status: 302, headers: { location: '/api/v1/devices' } }),
|
||||
});
|
||||
await assert.rejects(looping.getDevices(), { code: 'TOO_MANY_ALTA_REDIRECTS' });
|
||||
|
||||
const missing = new AltaClient({
|
||||
sessionStore: readyStore(),
|
||||
transport: async () => response('', { status: 302, headers: {} }),
|
||||
});
|
||||
await assert.rejects(missing.getDevices(), { code: 'UNSAFE_ALTA_REDIRECT' });
|
||||
});
|
||||
|
||||
test('enforces a total timeout even when injected transport does not cooperate', async () => {
|
||||
const client = new AltaClient({
|
||||
sessionStore: readyStore(),
|
||||
timeoutMs: 25,
|
||||
transport: async () => new Promise(() => {}),
|
||||
});
|
||||
await assert.rejects(client.getDevices(), { code: 'ALTA_TIMEOUT', timeout: true });
|
||||
});
|
||||
|
||||
test('rejects oversized responses using content-length and actual data size', async () => {
|
||||
const byHeader = new AltaClient({
|
||||
sessionStore: readyStore(),
|
||||
maxResponseBytes: 32,
|
||||
transport: async () => response([], { headers: { 'content-length': '33' } }),
|
||||
});
|
||||
await assert.rejects(byHeader.getDevices(), { code: 'ALTA_RESPONSE_TOO_LARGE' });
|
||||
|
||||
const byBody = new AltaClient({
|
||||
sessionStore: readyStore(),
|
||||
maxResponseBytes: 32,
|
||||
transport: async () => response(JSON.stringify([{ value: 'x'.repeat(40) }])),
|
||||
});
|
||||
await assert.rejects(byBody.getDevices(), { code: 'ALTA_RESPONSE_TOO_LARGE' });
|
||||
|
||||
for (const status of [302, 500]) {
|
||||
const oversizedFailure = new AltaClient({
|
||||
sessionStore: readyStore(),
|
||||
maxResponseBytes: 32,
|
||||
transport: async () => response('x'.repeat(33), {
|
||||
status,
|
||||
headers: status === 302 ? { location: '/api/v1/devices' } : {},
|
||||
}),
|
||||
});
|
||||
await assert.rejects(oversizedFailure.getDevices(), { code: 'ALTA_RESPONSE_TOO_LARGE' });
|
||||
}
|
||||
});
|
||||
|
||||
test('rejects malformed response envelopes, JSON, status and endpoint data shapes', async () => {
|
||||
const cases = [
|
||||
[undefined, 'INVALID_ALTA_RESPONSE'],
|
||||
[response('{broken'), 'INVALID_ALTA_RESPONSE_DATA'],
|
||||
[response([], { status: 500 }), 'ALTA_HTTP_ERROR'],
|
||||
[response({ not: 'an array' }), 'INVALID_ALTA_RESPONSE_DATA'],
|
||||
];
|
||||
for (const [value, code] of cases) {
|
||||
const client = new AltaClient({ sessionStore: readyStore(), transport: async () => value });
|
||||
await assert.rejects(client.getDevices(), { code });
|
||||
}
|
||||
|
||||
const authClient = new AltaClient({ sessionStore: readyStore(), transport: async () => response([]) });
|
||||
await assert.rejects(authClient.getAuthInfo(), { code: 'INVALID_ALTA_RESPONSE_DATA' });
|
||||
});
|
||||
|
||||
test('revalidates the stored origin before each request', async () => {
|
||||
const store = { requireSession: () => Object.freeze({ origin: 'https://evil.example', cookie: SENTINEL }) };
|
||||
let called = false;
|
||||
const client = new AltaClient({ sessionStore: store, transport: async () => { called = true; } });
|
||||
await assert.rejects(client.getDevices(), { code: 'INVALID_ALTA_ORIGIN' });
|
||||
assert.equal(called, false);
|
||||
});
|
||||
@@ -0,0 +1,109 @@
|
||||
'use strict';
|
||||
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const {
|
||||
formatAltaError,
|
||||
sanitizeErrorMessage,
|
||||
} = require('../src/error-redaction');
|
||||
|
||||
const SENTINEL = 'HERMES_SENTINEL_SECRET';
|
||||
|
||||
function assertNoSecret(value) {
|
||||
const serialized = JSON.stringify(value);
|
||||
assert.equal(serialized.includes(SENTINEL), false, serialized);
|
||||
assert.equal(serialized.includes('Cookie'), false, serialized);
|
||||
assert.equal(serialized.includes('Authorization'), false, serialized);
|
||||
}
|
||||
|
||||
test('formats only an allowlist of bounded safe fields', () => {
|
||||
const error = new Error('request failed safely');
|
||||
error.code = 'ECONNRESET';
|
||||
error.timeout = false;
|
||||
error.response = { status: 502 };
|
||||
|
||||
const formatted = formatAltaError('getDevices', error, { secrets: [SENTINEL] });
|
||||
assert.deepEqual(formatted, {
|
||||
operation: 'getDevices',
|
||||
code: 'ECONNRESET',
|
||||
status: 502,
|
||||
timeout: false,
|
||||
message: 'request failed safely',
|
||||
});
|
||||
assert.deepEqual(Object.keys(formatted), ['operation', 'code', 'status', 'timeout', 'message']);
|
||||
});
|
||||
|
||||
test('never serializes Axios config, request, headers, URL, body, cause or response data', () => {
|
||||
const error = new Error(`failed with va=${SENTINEL}`);
|
||||
error.code = 'ERR_BAD_RESPONSE';
|
||||
error.config = {
|
||||
url: `https://evil.example/?token=${SENTINEL}`,
|
||||
headers: { Cookie: `va=${SENTINEL}`, Authorization: `Bearer ${SENTINEL}` },
|
||||
data: { token: SENTINEL },
|
||||
};
|
||||
error.request = { rawHeaders: `Cookie: va=${SENTINEL}` };
|
||||
error.response = {
|
||||
status: 401,
|
||||
headers: { 'set-cookie': `va=${SENTINEL}` },
|
||||
data: { message: SENTINEL, nested: { secret: SENTINEL } },
|
||||
};
|
||||
error.cause = { message: SENTINEL, headers: { Cookie: SENTINEL } };
|
||||
|
||||
const formatted = formatAltaError('getDevices', error, { secrets: [SENTINEL] });
|
||||
assertNoSecret(formatted);
|
||||
assert.deepEqual(Object.keys(formatted), ['operation', 'code', 'status', 'timeout', 'message']);
|
||||
assert.equal(formatted.message, 'failed with va=[REDACTED]');
|
||||
});
|
||||
|
||||
test('redacts cookie and bearer patterns without needing the exact secret', () => {
|
||||
for (const message of [
|
||||
`Cookie: va=${SENTINEL}; other=value`,
|
||||
`va=${SENTINEL}`,
|
||||
`Authorization: Bearer ${SENTINEL}`,
|
||||
`Bearer ${SENTINEL}`,
|
||||
]) {
|
||||
const clean = sanitizeErrorMessage(message);
|
||||
assertNoSecret({ message: clean });
|
||||
}
|
||||
});
|
||||
|
||||
test('bounds and sanitizes operation, code, status and message', () => {
|
||||
const error = {
|
||||
message: `line one\r\nCookie: va=${SENTINEL} ${'x'.repeat(1000)}`,
|
||||
code: 'BAD CODE WITH SPACES AND SECRET_' + SENTINEL,
|
||||
response: { status: 9999 },
|
||||
};
|
||||
const formatted = formatAltaError(`unsafe operation ${SENTINEL}`, error, { secrets: [SENTINEL] });
|
||||
|
||||
assertNoSecret(formatted);
|
||||
assert.ok(formatted.operation.length <= 64);
|
||||
assert.equal(formatted.code, 'ALTA_ERROR');
|
||||
assert.equal(formatted.status, null);
|
||||
assert.ok(formatted.message.length <= 240);
|
||||
assert.equal(/[\r\n]/.test(formatted.message), false);
|
||||
});
|
||||
|
||||
test('handles hostile accessors, cycles and non-error values without leaking or throwing', () => {
|
||||
const hostile = {};
|
||||
Object.defineProperty(hostile, 'message', { get() { throw new Error(SENTINEL); } });
|
||||
Object.defineProperty(hostile, 'code', { get() { throw new Error(SENTINEL); } });
|
||||
hostile.self = hostile;
|
||||
|
||||
const formatted = formatAltaError('request', hostile, { secrets: [SENTINEL] });
|
||||
assertNoSecret(formatted);
|
||||
assert.equal(formatted.message, 'Alta request failed');
|
||||
assert.equal(formatted.code, 'ALTA_ERROR');
|
||||
|
||||
assertNoSecret(formatAltaError('request', SENTINEL, { secrets: [SENTINEL] }));
|
||||
assertNoSecret(formatAltaError('request', null, { secrets: [SENTINEL] }));
|
||||
});
|
||||
|
||||
test('does not leak a supplied secret through valid-looking operation or code fields', () => {
|
||||
const formatted = formatAltaError(SENTINEL, {
|
||||
code: SENTINEL,
|
||||
message: 'failed',
|
||||
}, { secrets: [SENTINEL] });
|
||||
assertNoSecret(formatted);
|
||||
assert.equal(formatted.operation, 'altaRequest');
|
||||
assert.equal(formatted.code, 'ALTA_ERROR');
|
||||
});
|
||||
@@ -0,0 +1,58 @@
|
||||
'use strict';
|
||||
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const { SessionStore } = require('../src/session-store');
|
||||
|
||||
const SENTINEL = 'HERMES_SENTINEL_SECRET';
|
||||
|
||||
test('stores a canonical origin and cookie only in main-process memory', () => {
|
||||
const store = new SessionStore();
|
||||
store.establish('https://Tenant.AVASECURITY.com/', SENTINEL);
|
||||
|
||||
assert.deepEqual(store.describe(), {
|
||||
connected: true,
|
||||
origin: 'https://tenant.avasecurity.com',
|
||||
});
|
||||
assert.equal(JSON.stringify(store.describe()).includes(SENTINEL), false);
|
||||
|
||||
const session = store.requireSession();
|
||||
assert.equal(session.origin, 'https://tenant.avasecurity.com');
|
||||
assert.equal(session.cookie, SENTINEL);
|
||||
assert.equal(Object.isFrozen(session), true);
|
||||
});
|
||||
|
||||
test('rejects malformed and header-injecting cookies', () => {
|
||||
const store = new SessionStore();
|
||||
for (const cookie of ['', null, 12, 'secret\r\nX-Evil: yes', `x${'a'.repeat(4096)}`, 'abc\0def', 'token; injected=yes']) {
|
||||
assert.throws(() => store.establish('https://tenant.avasecurity.com', cookie), {
|
||||
code: 'INVALID_SESSION_COOKIE',
|
||||
});
|
||||
}
|
||||
assert.equal(store.describe().connected, false);
|
||||
});
|
||||
|
||||
test('does not replace a valid session when a new session is invalid', () => {
|
||||
const store = new SessionStore();
|
||||
store.establish('https://one.avasecurity.com', SENTINEL);
|
||||
assert.throws(() => store.establish('https://evil.example', 'replacement'));
|
||||
assert.equal(store.requireSession().origin, 'https://one.avasecurity.com');
|
||||
assert.equal(store.requireSession().cookie, SENTINEL);
|
||||
});
|
||||
|
||||
test('clear removes session references and dispose permanently closes the store', () => {
|
||||
const store = new SessionStore();
|
||||
store.establish('https://tenant.avigilon.com', SENTINEL);
|
||||
store.clear();
|
||||
|
||||
assert.deepEqual(store.describe(), { connected: false, origin: null });
|
||||
assert.throws(() => store.requireSession(), { code: 'NO_ALTA_SESSION' });
|
||||
|
||||
store.establish('https://tenant.avigilon.com', 'new-token');
|
||||
store.dispose();
|
||||
assert.deepEqual(store.describe(), { connected: false, origin: null });
|
||||
assert.throws(() => store.establish('https://tenant.avigilon.com', 'again'), {
|
||||
code: 'SESSION_STORE_DISPOSED',
|
||||
});
|
||||
assert.doesNotThrow(() => store.dispose());
|
||||
});
|
||||
@@ -0,0 +1,57 @@
|
||||
'use strict';
|
||||
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const {
|
||||
canonicalizeAltaOrigin,
|
||||
isAltaOrigin,
|
||||
} = require('../src/url-policy');
|
||||
|
||||
test('accepts and canonicalizes HTTPS Alta deployment subdomains', () => {
|
||||
assert.equal(canonicalizeAltaOrigin('https://Example.AVASECURITY.com/'), 'https://example.avasecurity.com');
|
||||
assert.equal(canonicalizeAltaOrigin('https://edge.eu.avigilon.com:443'), 'https://edge.eu.avigilon.com');
|
||||
assert.equal(isAltaOrigin('https://tenant.avasecurity.com'), true);
|
||||
});
|
||||
|
||||
test('rejects roots, lookalikes, HTTP, and arbitrary exfiltration destinations', () => {
|
||||
for (const value of [
|
||||
'https://avasecurity.com',
|
||||
'https://avigilon.com',
|
||||
'https://avasecurity.com.evil.example',
|
||||
'https://tenant.avasecurity.com.evil.example',
|
||||
'https://evilavasecurity.com',
|
||||
'http://tenant.avasecurity.com',
|
||||
'https://example.com',
|
||||
'file:///etc/passwd',
|
||||
]) {
|
||||
assert.throws(() => canonicalizeAltaOrigin(value), { code: 'INVALID_ALTA_ORIGIN' }, value);
|
||||
}
|
||||
});
|
||||
|
||||
test('rejects authority tricks, fragments, paths, queries and non-default ports', () => {
|
||||
for (const value of [
|
||||
'https://user:pass@tenant.avasecurity.com',
|
||||
'https://tenant.avasecurity.com/#fragment',
|
||||
'https://tenant.avasecurity.com/api/v1/devices',
|
||||
'https://tenant.avasecurity.com?next=https://evil.example',
|
||||
'https://tenant.avasecurity.com:8443',
|
||||
'https://tenant.avasecurity.com\\@evil.example',
|
||||
]) {
|
||||
assert.throws(() => canonicalizeAltaOrigin(value), { code: 'INVALID_ALTA_ORIGIN' }, value);
|
||||
}
|
||||
});
|
||||
|
||||
test('rejects CRLF, whitespace, non-strings, oversized and malformed values', () => {
|
||||
for (const value of [
|
||||
'https://tenant.avasecurity.com\r\nX-Test: injected',
|
||||
' https://tenant.avasecurity.com',
|
||||
'https://tenant.avasecurity.com ',
|
||||
'not a URL',
|
||||
'',
|
||||
null,
|
||||
7,
|
||||
`https://${'a'.repeat(513)}.avasecurity.com`,
|
||||
]) {
|
||||
assert.throws(() => canonicalizeAltaOrigin(value), { code: 'INVALID_ALTA_ORIGIN' }, String(value));
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user