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
+176
View File
@@ -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);
});
+109
View File
@@ -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');
});
+58
View File
@@ -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());
});
+57
View File
@@ -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));
}
});