281 lines
10 KiB
JavaScript
281 lines
10 KiB
JavaScript
'use strict';
|
|
|
|
const test = require('node:test');
|
|
const assert = require('node:assert/strict');
|
|
const { SessionStore } = require('../src/session-store');
|
|
const {
|
|
AltaClient,
|
|
DEVICE_MAX_RESPONSE_BYTES,
|
|
HIERARCHY_MAX_RESPONSE_BYTES,
|
|
MAX_ARRAY_OBJECTS,
|
|
} = 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' }]);
|
|
if (options.url.endsWith('/deviceGroups')) return response([{ id: 'group-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.getDeviceGroups(), [{ id: 'group-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/deviceGroups`,
|
|
`${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 <= 30_000);
|
|
assert.ok(call.maxResponseBytes > 0);
|
|
}
|
|
});
|
|
|
|
test('uses endpoint-specific 32 MiB/4 MiB limits and a 30 second request deadline', async () => {
|
|
const calls = [];
|
|
const client = new AltaClient({
|
|
sessionStore: readyStore(),
|
|
transport: async (options) => { calls.push(options); return response([]); },
|
|
});
|
|
|
|
await client.getDevices();
|
|
await client.getDeviceSites();
|
|
await client.getDeviceGroups();
|
|
|
|
assert.deepEqual(calls.map(({ maxResponseBytes }) => maxResponseBytes), [
|
|
DEVICE_MAX_RESPONSE_BYTES,
|
|
HIERARCHY_MAX_RESPONSE_BYTES,
|
|
HIERARCHY_MAX_RESPONSE_BYTES,
|
|
]);
|
|
assert.ok(calls.every(({ timeout }) => timeout > 0 && timeout <= 30_000));
|
|
});
|
|
|
|
test('accepts device bodies above 2 MiB and rejects bodies above 32 MiB before parsing', async () => {
|
|
const acceptedBody = JSON.stringify([{
|
|
guid: '550e8400-e29b-41d4-a716-446655440000',
|
|
padding: 'x'.repeat((2 * 1024 * 1024) + 1),
|
|
}]);
|
|
const accepted = new AltaClient({
|
|
sessionStore: readyStore(),
|
|
transport: async () => response(Buffer.from(acceptedBody)),
|
|
});
|
|
assert.equal((await accepted.getDevices()).length, 1);
|
|
|
|
const rejected = new AltaClient({
|
|
sessionStore: readyStore(),
|
|
transport: async () => response(Buffer.alloc(DEVICE_MAX_RESPONSE_BYTES + 1, 0x20)),
|
|
});
|
|
await assert.rejects(rejected.getDevices(), { code: 'ALTA_RESPONSE_TOO_LARGE' });
|
|
});
|
|
|
|
test('rejects plain array responses containing more than 10,000 objects', async () => {
|
|
const client = new AltaClient({
|
|
sessionStore: readyStore(),
|
|
transport: async () => response(Array.from({ length: MAX_ARRAY_OBJECTS + 1 }, () => ({}))),
|
|
});
|
|
await assert.rejects(client.getDevices(), { code: 'ALTA_RESPONSE_TOO_MANY_OBJECTS' });
|
|
});
|
|
|
|
test('rejects renderer-supplied URL/cookie parameters before transport', async () => {
|
|
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('returns a fresh allowlisted error when transport throws an ALTA error with secret-bearing fields', async () => {
|
|
const source = Object.assign(new Error(`Alta request failed for va=${SENTINEL}`), {
|
|
code: 'ALTA_TRANSPORT_FAILURE',
|
|
status: 503,
|
|
timeout: true,
|
|
config: { headers: { Cookie: `va=${SENTINEL}` }, data: SENTINEL },
|
|
request: { headers: { Cookie: `va=${SENTINEL}` }, body: SENTINEL },
|
|
response: { status: 502, headers: { 'set-cookie': `va=${SENTINEL}` }, data: SENTINEL },
|
|
body: SENTINEL,
|
|
});
|
|
const client = new AltaClient({
|
|
sessionStore: readyStore(),
|
|
transport: async () => { throw source; },
|
|
});
|
|
|
|
const caught = await client.getDevices().catch((error) => error);
|
|
assert.notEqual(caught, source);
|
|
assert.equal(caught.code, 'ALTA_TRANSPORT_FAILURE');
|
|
assert.equal(caught.message, 'Alta request failed for va=[REDACTED]');
|
|
assert.equal(caught.status, 502);
|
|
assert.equal(caught.timeout, true);
|
|
assert.deepEqual(Object.keys(caught).sort(), ['code', 'status', 'timeout']);
|
|
for (const field of ['config', 'request', 'response', 'headers', 'data', 'body']) {
|
|
assert.equal(field in caught, false, field);
|
|
}
|
|
assert.doesNotMatch(JSON.stringify(caught), new RegExp(SENTINEL));
|
|
});
|
|
|
|
test('safely formats a transport error whose code getter throws a secret', async () => {
|
|
const source = new Error('Transport failed safely');
|
|
Object.defineProperty(source, 'code', {
|
|
enumerable: true,
|
|
get() { throw new Error(SENTINEL); },
|
|
});
|
|
source.config = { headers: { Cookie: `va=${SENTINEL}` } };
|
|
const client = new AltaClient({
|
|
sessionStore: readyStore(),
|
|
transport: async () => { throw source; },
|
|
});
|
|
|
|
const caught = await client.getDevices().catch((error) => error);
|
|
assert.notEqual(caught, source);
|
|
assert.equal(caught.code, 'ALTA_ERROR');
|
|
assert.equal(caught.message, 'Transport failed safely');
|
|
assert.equal(caught.status, null);
|
|
assert.equal(caught.timeout, false);
|
|
assert.deepEqual(Object.keys(caught).sort(), ['code', 'status', 'timeout']);
|
|
assert.equal('config' in caught, false);
|
|
assert.doesNotMatch(JSON.stringify(caught), new RegExp(SENTINEL));
|
|
});
|
|
|
|
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);
|
|
});
|