From b503b5777a7ac071d9c28118d83b422b53cdf85e Mon Sep 17 00:00:00 2001 From: PageZ948 Date: Wed, 19 Aug 2026 21:22:17 +0000 Subject: [PATCH] fix: add main-process Alta session boundary --- src/alta-client.js | 255 +++++++++++++++++++++++++++++++++++ src/error-redaction.js | 85 ++++++++++++ src/session-store.js | 81 +++++++++++ src/url-policy.js | 92 +++++++++++++ test/alta-client.test.js | 176 ++++++++++++++++++++++++ test/error-redaction.test.js | 109 +++++++++++++++ test/session-store.test.js | 58 ++++++++ test/url-policy.test.js | 57 ++++++++ 8 files changed, 913 insertions(+) create mode 100644 src/alta-client.js create mode 100644 src/error-redaction.js create mode 100644 src/session-store.js create mode 100644 src/url-policy.js create mode 100644 test/alta-client.test.js create mode 100644 test/error-redaction.test.js create mode 100644 test/session-store.test.js create mode 100644 test/url-policy.test.js diff --git a/src/alta-client.js b/src/alta-client.js new file mode 100644 index 0000000..6ea04ce --- /dev/null +++ b/src/alta-client.js @@ -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, +}; diff --git a/src/error-redaction.js b/src/error-redaction.js new file mode 100644 index 0000000..89a8bcf --- /dev/null +++ b/src/error-redaction.js @@ -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, +}; diff --git a/src/session-store.js b/src/session-store.js new file mode 100644 index 0000000..5246930 --- /dev/null +++ b/src/session-store.js @@ -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, +}; diff --git a/src/url-policy.js b/src/url-policy.js new file mode 100644 index 0000000..c6b5709 --- /dev/null +++ b/src/url-policy.js @@ -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, +}; diff --git a/test/alta-client.test.js b/test/alta-client.test.js new file mode 100644 index 0000000..dfc5f1f --- /dev/null +++ b/test/alta-client.test.js @@ -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); +}); diff --git a/test/error-redaction.test.js b/test/error-redaction.test.js new file mode 100644 index 0000000..bcedd6e --- /dev/null +++ b/test/error-redaction.test.js @@ -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'); +}); diff --git a/test/session-store.test.js b/test/session-store.test.js new file mode 100644 index 0000000..cd74ce5 --- /dev/null +++ b/test/session-store.test.js @@ -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()); +}); diff --git a/test/url-policy.test.js b/test/url-policy.test.js new file mode 100644 index 0000000..5b4f1d7 --- /dev/null +++ b/test/url-policy.test.js @@ -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)); + } +});