fix: close APT adversarial runtime gaps
This commit is contained in:
+133
-66
@@ -7,108 +7,175 @@ const { PassThrough, Readable } = require('node:stream');
|
||||
const {
|
||||
BridgeAuth,
|
||||
BridgeAuthError,
|
||||
computeCookieProof,
|
||||
computeServerProof,
|
||||
generatePairingSecret,
|
||||
hashPairingSecret,
|
||||
verifyPairingSecret,
|
||||
readJsonBody,
|
||||
createRequestLimiter
|
||||
} = require('../src/bridge-auth');
|
||||
|
||||
const EXTENSION_ORIGIN = 'chrome-extension://onbkfpbggekakjddomjjnboippimlmch';
|
||||
const CLIENT_NONCE = Buffer.alloc(32, 1).toString('base64url');
|
||||
const protect = (plaintext) => Buffer.from(`protected:${plaintext}`, 'utf8');
|
||||
const unprotect = (ciphertext) => {
|
||||
const value = Buffer.from(ciphertext).toString('utf8');
|
||||
if (!value.startsWith('protected:')) throw new Error('bad ciphertext');
|
||||
return value.slice('protected:'.length);
|
||||
};
|
||||
|
||||
test('pairing secrets are random, URL-safe, and stored as a hash envelope', () => {
|
||||
function createAuth(options = {}) {
|
||||
return new BridgeAuth({ expectedOrigin: EXTENSION_ORIGIN, protect, unprotect, ...options });
|
||||
}
|
||||
|
||||
test('pairing secrets are random and persisted only in a versioned protected envelope', () => {
|
||||
const first = generatePairingSecret();
|
||||
const second = generatePairingSecret();
|
||||
assert.match(first, /^[A-Za-z0-9_-]{43}$/);
|
||||
assert.notEqual(first, second);
|
||||
|
||||
const envelope = hashPairingSecret(first);
|
||||
assert.equal(envelope.version, 1);
|
||||
assert.equal(envelope.algorithm, 'scrypt');
|
||||
assert.equal(Object.values(envelope).includes(first), false);
|
||||
assert.equal(verifyPairingSecret(first, envelope), true);
|
||||
assert.equal(verifyPairingSecret(second, envelope), false);
|
||||
assert.equal(verifyPairingSecret('', envelope), false);
|
||||
assert.equal(verifyPairingSecret(null, envelope), false);
|
||||
const auth = createAuth();
|
||||
const { secret, envelope } = auth.rotate();
|
||||
assert.deepEqual(Object.keys(envelope).sort(), ['algorithm', 'ciphertext', 'digest', 'version']);
|
||||
assert.equal(envelope.version, 2);
|
||||
assert.equal(envelope.algorithm, 'electron-safe-storage');
|
||||
assert.equal(JSON.stringify(envelope).includes(secret), false);
|
||||
assert.equal(Buffer.from(envelope.ciphertext, 'base64').toString('utf8').includes(secret), true,
|
||||
'test protector is intentionally transparent after decoding; disk JSON itself is never plaintext');
|
||||
});
|
||||
|
||||
test('auth rejects missing/wrong secrets and unknown or malformed origins', () => {
|
||||
const auth = new BridgeAuth({ expectedOrigin: EXTENSION_ORIGIN });
|
||||
const { secret } = auth.rotate();
|
||||
test('protected envelope restores server authentication without request-time scrypt', () => {
|
||||
let protectCalls = 0;
|
||||
let unprotectCalls = 0;
|
||||
const first = new BridgeAuth({
|
||||
protect(value) { protectCalls += 1; return protect(value); },
|
||||
unprotect(value) { unprotectCalls += 1; return unprotect(value); }
|
||||
});
|
||||
const { secret, envelope } = first.rotate();
|
||||
const restored = new BridgeAuth({
|
||||
envelope: JSON.parse(JSON.stringify(envelope)),
|
||||
protect,
|
||||
unprotect(value) { unprotectCalls += 1; return unprotect(value); }
|
||||
});
|
||||
const challenge = restored.issueChallenge(CLIENT_NONCE);
|
||||
|
||||
assert.equal(auth.authenticate({ origin: EXTENSION_ORIGIN, secret }), true);
|
||||
assert.equal(auth.authenticate({ origin: EXTENSION_ORIGIN }), false);
|
||||
assert.equal(auth.authenticate({ origin: EXTENSION_ORIGIN, secret: 'wrong' }), false);
|
||||
assert.equal(auth.authenticate({ origin: 'chrome-extension://aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', secret }), false);
|
||||
assert.equal(auth.authenticate({ origin: `${EXTENSION_ORIGIN}/`, secret }), false);
|
||||
assert.equal(auth.authenticate({ origin: `${EXTENSION_ORIGIN}\r\nX-Evil: yes`, secret }), false);
|
||||
assert.equal(auth.authenticate({ origin: EXTENSION_ORIGIN, secret: `${secret}\r\n` }), false);
|
||||
assert.equal(protectCalls, 1);
|
||||
assert.equal(unprotectCalls, 1);
|
||||
assert.equal(challenge.serverProof, computeServerProof(secret, CLIENT_NONCE, challenge.serverNonce));
|
||||
assert.equal(Object.hasOwn(challenge, 'secret'), false);
|
||||
});
|
||||
|
||||
test('rotation invalidates the previous secret and revoke invalidates the current secret', () => {
|
||||
const auth = new BridgeAuth({ expectedOrigin: EXTENSION_ORIGIN });
|
||||
const first = auth.rotate().secret;
|
||||
const second = auth.rotate().secret;
|
||||
test('challenge proof and cookie proof are domain-separated, constant-time authenticated, and one-time', () => {
|
||||
const auth = createAuth();
|
||||
const secret = auth.rotate().secret;
|
||||
const challenge = auth.issueChallenge(CLIENT_NONCE);
|
||||
const cookieRequest = {
|
||||
clientNonce: CLIENT_NONCE,
|
||||
serverNonce: challenge.serverNonce,
|
||||
deploymentUrl: 'https://customer.avasecurity.com',
|
||||
cookieValue: 'synthetic-cookie'
|
||||
};
|
||||
cookieRequest.proof = computeCookieProof(secret, cookieRequest);
|
||||
|
||||
assert.equal(auth.authenticate({ origin: EXTENSION_ORIGIN, secret: first }), false);
|
||||
assert.equal(auth.authenticate({ origin: EXTENSION_ORIGIN, secret: second }), true);
|
||||
assert.notEqual(challenge.serverProof, cookieRequest.proof);
|
||||
assert.equal(auth.authenticateCookie(cookieRequest), true);
|
||||
assert.equal(auth.authenticateCookie(cookieRequest), false, 'challenge replay must fail');
|
||||
});
|
||||
|
||||
test('challenge cache is bounded, expires entries, and rejects malformed nonces', () => {
|
||||
let now = 100;
|
||||
const auth = createAuth({ maxChallenges: 1, challengeTtlMs: 50, now: () => now });
|
||||
auth.rotate();
|
||||
auth.issueChallenge(CLIENT_NONCE);
|
||||
assert.throws(() => auth.issueChallenge(Buffer.alloc(32, 2).toString('base64url')),
|
||||
(error) => error.code === 'CHALLENGE_CAPACITY');
|
||||
now = 151;
|
||||
const replacement = auth.issueChallenge(Buffer.alloc(32, 2).toString('base64url'));
|
||||
assert.match(replacement.serverNonce, /^[A-Za-z0-9_-]{43}$/);
|
||||
assert.throws(() => auth.issueChallenge('short'), (error) => error.code === 'INVALID_NONCE');
|
||||
});
|
||||
|
||||
test('expired and forged cookie proofs fail and consume the one-time challenge', () => {
|
||||
let now = 100;
|
||||
const auth = createAuth({ challengeTtlMs: 50, now: () => now });
|
||||
const secret = auth.rotate().secret;
|
||||
const expired = auth.issueChallenge(CLIENT_NONCE);
|
||||
now = 151;
|
||||
assert.equal(auth.authenticateCookie({
|
||||
clientNonce: CLIENT_NONCE,
|
||||
serverNonce: expired.serverNonce,
|
||||
deploymentUrl: 'https://customer.avasecurity.com',
|
||||
cookieValue: 'cookie',
|
||||
proof: computeCookieProof(secret, {
|
||||
clientNonce: CLIENT_NONCE,
|
||||
serverNonce: expired.serverNonce,
|
||||
deploymentUrl: 'https://customer.avasecurity.com',
|
||||
cookieValue: 'cookie'
|
||||
})
|
||||
}), false);
|
||||
|
||||
now = 200;
|
||||
const forged = auth.issueChallenge(CLIENT_NONCE);
|
||||
const request = {
|
||||
clientNonce: CLIENT_NONCE,
|
||||
serverNonce: forged.serverNonce,
|
||||
deploymentUrl: 'https://customer.avasecurity.com',
|
||||
cookieValue: 'cookie',
|
||||
proof: 'A'.repeat(43)
|
||||
};
|
||||
assert.equal(auth.authenticateCookie(request), false);
|
||||
request.proof = computeCookieProof(secret, request);
|
||||
assert.equal(auth.authenticateCookie(request), false, 'forged attempt consumes challenge');
|
||||
});
|
||||
|
||||
test('rotation clears outstanding challenges and revoke disables challenge issuance', () => {
|
||||
const auth = createAuth();
|
||||
auth.rotate();
|
||||
const prior = auth.issueChallenge(CLIENT_NONCE);
|
||||
auth.rotate();
|
||||
assert.equal(auth.authenticateCookie({
|
||||
clientNonce: CLIENT_NONCE,
|
||||
serverNonce: prior.serverNonce,
|
||||
deploymentUrl: 'https://customer.avasecurity.com',
|
||||
cookieValue: 'cookie',
|
||||
proof: 'A'.repeat(43)
|
||||
}), false);
|
||||
auth.revoke();
|
||||
assert.equal(auth.authenticate({ origin: EXTENSION_ORIGIN, secret: second }), false);
|
||||
assert.throws(() => auth.issueChallenge(CLIENT_NONCE), (error) => error.code === 'PAIRING_UNAVAILABLE');
|
||||
assert.equal(auth.envelope, null);
|
||||
});
|
||||
|
||||
test('an envelope can be safely persisted and loaded by a future desktop wiring', () => {
|
||||
const first = new BridgeAuth({ expectedOrigin: EXTENSION_ORIGIN });
|
||||
const { secret, envelope } = first.rotate();
|
||||
const persisted = JSON.parse(JSON.stringify(envelope));
|
||||
const restored = new BridgeAuth({ expectedOrigin: EXTENSION_ORIGIN, envelope: persisted });
|
||||
|
||||
assert.equal(restored.authenticate({ origin: EXTENSION_ORIGIN, secret }), true);
|
||||
assert.throws(
|
||||
() => new BridgeAuth({ expectedOrigin: 'https://example.test' }),
|
||||
(error) => error instanceof BridgeAuthError && error.code === 'INVALID_EXTENSION_ORIGIN'
|
||||
);
|
||||
assert.throws(
|
||||
() => new BridgeAuth({ expectedOrigin: 'chrome-extension://aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa' }),
|
||||
(error) => error instanceof BridgeAuthError && error.code === 'INVALID_EXTENSION_ORIGIN'
|
||||
);
|
||||
test('invalid origin and protected-envelope dependencies fail closed', () => {
|
||||
assert.throws(() => new BridgeAuth({ expectedOrigin: 'https://example.test', protect, unprotect }),
|
||||
(error) => error instanceof BridgeAuthError && error.code === 'INVALID_EXTENSION_ORIGIN');
|
||||
assert.throws(() => new BridgeAuth({ expectedOrigin: 'chrome-extension://aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', protect, unprotect }),
|
||||
(error) => error instanceof BridgeAuthError && error.code === 'INVALID_EXTENSION_ORIGIN');
|
||||
assert.throws(() => new BridgeAuth(), (error) => error.code === 'SECURE_STORAGE_UNAVAILABLE');
|
||||
assert.throws(() => new BridgeAuth({ protect, unprotect, envelope: { version: 1, algorithm: 'scrypt' } }),
|
||||
(error) => error.code === 'INVALID_SECRET_ENVELOPE');
|
||||
});
|
||||
|
||||
test('readJsonBody accepts a bounded JSON object and rejects malformed or oversized input', async () => {
|
||||
assert.deepEqual(
|
||||
await readJsonBody(Readable.from(['{"ok":true}']), { maxBytes: 64 }),
|
||||
{ ok: true }
|
||||
);
|
||||
|
||||
await assert.rejects(
|
||||
readJsonBody(Readable.from(['{"nope"']), { maxBytes: 64 }),
|
||||
(error) => error.code === 'MALFORMED_JSON'
|
||||
);
|
||||
await assert.rejects(
|
||||
readJsonBody(Readable.from(['{"value":"', 'x'.repeat(100), '"}']), { maxBytes: 32 }),
|
||||
(error) => error.code === 'BODY_TOO_LARGE'
|
||||
);
|
||||
await assert.rejects(
|
||||
readJsonBody(Readable.from(['[]']), { maxBytes: 64 }),
|
||||
(error) => error.code === 'INVALID_JSON_BODY'
|
||||
);
|
||||
assert.deepEqual(await readJsonBody(Readable.from(['{"ok":true}']), { maxBytes: 64 }), { ok: true });
|
||||
await assert.rejects(readJsonBody(Readable.from(['{"nope"']), { maxBytes: 64 }), (error) => error.code === 'MALFORMED_JSON');
|
||||
await assert.rejects(readJsonBody(Readable.from(['{"value":"', 'x'.repeat(100), '"}']), { maxBytes: 32 }),
|
||||
(error) => error.code === 'BODY_TOO_LARGE');
|
||||
await assert.rejects(readJsonBody(Readable.from(['[]']), { maxBytes: 64 }), (error) => error.code === 'INVALID_JSON_BODY');
|
||||
});
|
||||
|
||||
test('readJsonBody aborts a slow body at its deadline', async () => {
|
||||
test('readJsonBody aborts a slow body at its absolute deadline', async () => {
|
||||
const stream = new PassThrough();
|
||||
const pending = readJsonBody(stream, { maxBytes: 64, deadlineMs: 15 });
|
||||
stream.write('{');
|
||||
await assert.rejects(pending, (error) => error.code === 'BODY_DEADLINE_EXCEEDED');
|
||||
});
|
||||
|
||||
test('request limiter rejects excess concurrent bodies before work begins', async () => {
|
||||
test('request limiter rejects excess concurrent work before it begins', async () => {
|
||||
const limiter = createRequestLimiter({ maxConcurrent: 1 });
|
||||
let release;
|
||||
const active = limiter.run(() => new Promise((resolve) => { release = resolve; }));
|
||||
await assert.rejects(
|
||||
limiter.run(async () => 'never'),
|
||||
(error) => error.code === 'TOO_MANY_REQUESTS'
|
||||
);
|
||||
let entered = false;
|
||||
await assert.rejects(limiter.run(async () => { entered = true; }), (error) => error.code === 'TOO_MANY_REQUESTS');
|
||||
assert.equal(entered, false);
|
||||
release('done');
|
||||
assert.equal(await active, 'done');
|
||||
assert.equal(limiter.active, 0);
|
||||
|
||||
@@ -37,7 +37,7 @@ function makeElement() {
|
||||
};
|
||||
}
|
||||
|
||||
function makePopupHarness({ paired = true, confirmCopy = false, clipboardReject = null } = {}) {
|
||||
function makePopupHarness({ paired = true, confirmCopy = false, clipboardReject = null, validServerProof = true } = {}) {
|
||||
const elements = Object.fromEntries(
|
||||
['tabInfo', 'pairingInfo', 'sendBtn', 'copyBtn', 'statusMsg', 'copyWarning', 'confirmCopy', 'openOptionsBtn']
|
||||
.map((id) => [id, makeElement()])
|
||||
@@ -66,8 +66,18 @@ function makePopupHarness({ paired = true, confirmCopy = false, clipboardReject
|
||||
documentApi,
|
||||
navigatorApi,
|
||||
confirmCopy: () => confirmCopy,
|
||||
cryptoApi: crypto.webcrypto,
|
||||
fetchImpl: async (...args) => {
|
||||
fetchCalls.push(args);
|
||||
if (args[0].endsWith('/challenge')) {
|
||||
const { clientNonce } = JSON.parse(args[1].body);
|
||||
const serverNonce = 'B'.repeat(43);
|
||||
const canonical = JSON.stringify(['apt-server-challenge-v1', clientNonce, serverNonce]);
|
||||
const serverProof = validServerProof
|
||||
? crypto.createHmac('sha256', 'A'.repeat(43)).update(canonical).digest('base64url')
|
||||
: 'C'.repeat(43);
|
||||
return { ok: true, json: async () => ({ success: true, serverNonce, serverProof }) };
|
||||
}
|
||||
return { ok: true, json: async () => ({ success: true }) };
|
||||
}
|
||||
});
|
||||
@@ -122,18 +132,37 @@ test('deployment detection rejects non-HTTPS, bare, and lookalike Alta hosts', (
|
||||
assert.equal(isSupportedDeploymentUrl('https://customer.avasecurity.com.evil.test/path'), false);
|
||||
});
|
||||
|
||||
test('Send to APT uses the paired secret header and exact endpoint', async () => {
|
||||
test('Send to APT authenticates the listener before reading or sending the cookie', async () => {
|
||||
const harness = makePopupHarness({ paired: true });
|
||||
await harness.controller.init();
|
||||
await harness.controller.sendToApt();
|
||||
assert.equal(harness.fetchCalls.length, 1);
|
||||
const [url, request] = harness.fetchCalls[0];
|
||||
assert.equal(harness.fetchCalls.length, 2);
|
||||
const [challengeUrl, challengeRequest] = harness.fetchCalls[0];
|
||||
assert.equal(challengeUrl, 'http://127.0.0.1:18247/challenge');
|
||||
assert.equal(JSON.stringify(challengeRequest).includes('sensitive-va-token'), false);
|
||||
assert.equal(JSON.stringify(challengeRequest).includes('A'.repeat(43)), false);
|
||||
const [url, request] = harness.fetchCalls[1];
|
||||
assert.equal(url, 'http://127.0.0.1:18247/cookie');
|
||||
assert.equal(request.headers['X-APT-Pairing'], 'A'.repeat(43));
|
||||
assert.deepEqual(JSON.parse(request.body), {
|
||||
deploymentUrl: 'https://customer.avasecurity.com',
|
||||
cookieValue: 'sensitive-va-token'
|
||||
});
|
||||
assert.equal(Object.keys(request.headers).some((name) => /pairing/i.test(name)), false);
|
||||
const body = JSON.parse(request.body);
|
||||
assert.equal(body.deploymentUrl, 'https://customer.avasecurity.com');
|
||||
assert.equal(body.cookieValue, 'sensitive-va-token');
|
||||
assert.match(body.clientNonce, /^[A-Za-z0-9_-]{43}$/);
|
||||
assert.equal(body.serverNonce, 'B'.repeat(43));
|
||||
assert.match(body.proof, /^[A-Za-z0-9_-]{43}$/);
|
||||
assert.equal(request.body.includes('A'.repeat(43)), false);
|
||||
});
|
||||
|
||||
test('a port-squatting fake server with an invalid proof receives no cookie or pairing secret', async () => {
|
||||
const harness = makePopupHarness({ paired: true, validServerProof: false });
|
||||
await harness.controller.init();
|
||||
await harness.controller.sendToApt();
|
||||
assert.equal(harness.fetchCalls.length, 1);
|
||||
assert.equal(harness.fetchCalls[0][0], 'http://127.0.0.1:18247/challenge');
|
||||
assert.equal(harness.cookieReads, 0);
|
||||
const network = JSON.stringify(harness.fetchCalls);
|
||||
assert.equal(network.includes('sensitive-va-token'), false);
|
||||
assert.equal(network.includes('A'.repeat(43)), false);
|
||||
});
|
||||
|
||||
test('copy cancellation occurs before cookie access and never writes the token', async () => {
|
||||
|
||||
+148
-27
@@ -13,7 +13,12 @@ const {
|
||||
createBridgeHandler,
|
||||
loadPairingEnvelope,
|
||||
} = require('../src/electron-runtime');
|
||||
const { APT_EXTENSION_ORIGIN, BridgeAuth } = require('../src/bridge-auth');
|
||||
const {
|
||||
APT_EXTENSION_ORIGIN,
|
||||
BridgeAuth,
|
||||
computeCookieProof,
|
||||
createRequestLimiter,
|
||||
} = require('../src/bridge-auth');
|
||||
const { createSessionStore } = require('../src/session-store');
|
||||
|
||||
const ROOT = path.join(__dirname, '..');
|
||||
@@ -33,12 +38,11 @@ function responseHarness() {
|
||||
};
|
||||
}
|
||||
|
||||
function requestHarness({ method = 'POST', origin = APT_EXTENSION_ORIGIN, secret, body = '{}' } = {}) {
|
||||
function requestHarness({ method = 'POST', origin = APT_EXTENSION_ORIGIN, url = '/cookie', body = '{}' } = {}) {
|
||||
const request = new PassThrough();
|
||||
request.method = method;
|
||||
request.url = '/cookie';
|
||||
request.url = url;
|
||||
request.headers = { origin };
|
||||
if (secret !== undefined) request.headers['x-apt-pairing'] = secret;
|
||||
process.nextTick(() => request.end(body));
|
||||
return request;
|
||||
}
|
||||
@@ -47,10 +51,19 @@ test('runtime keeps Alta credentials in main-owned modules and exposes only non-
|
||||
const sessionStore = createSessionStore();
|
||||
sessionStore.establish('https://customer.avasecurity.com', 'top-secret-cookie');
|
||||
const calls = [];
|
||||
const tracked = [];
|
||||
const proxyManager = {
|
||||
launchProxy(request) { calls.push(request); return { success: true, processId: 91, deviceId: request.deviceId, status: 'running' }; },
|
||||
stopProxy(processId) { calls.push({ processId }); return { success: true, processId, status: 'stop-requested' }; },
|
||||
listTrackedProxies() { return []; },
|
||||
launchProxy(request) {
|
||||
calls.push(request);
|
||||
tracked.push({ processId: 91, deviceId: request.deviceId, status: 'running', startedAt: 1 });
|
||||
return { success: true, processId: 91, deviceId: request.deviceId, status: 'running' };
|
||||
},
|
||||
stopProxy(processId) {
|
||||
calls.push({ processId });
|
||||
tracked.splice(0, tracked.length);
|
||||
return { success: true, processId, status: 'stop-requested' };
|
||||
},
|
||||
listTrackedProxies() { return tracked.slice(); },
|
||||
};
|
||||
const runtime = new AppRuntime({
|
||||
sessionStore,
|
||||
@@ -86,8 +99,10 @@ test('runtime keeps Alta credentials in main-owned modules and exposes only non-
|
||||
assert.equal((await runtime.stopProxy(999)).success, false);
|
||||
});
|
||||
|
||||
test('bridge rejects unknown preflight and unauthenticated requests before reading their body', async () => {
|
||||
const auth = new BridgeAuth();
|
||||
test('bridge authenticates itself before accepting a one-time HMAC cookie request', async () => {
|
||||
const protect = (value) => Buffer.from(`protected:${value}`);
|
||||
const unprotect = (value) => Buffer.from(value).toString().slice('protected:'.length);
|
||||
const auth = new BridgeAuth({ protect, unprotect });
|
||||
const secret = auth.rotate().secret;
|
||||
const sessionStore = createSessionStore();
|
||||
let stateNotifications = 0;
|
||||
@@ -103,44 +118,72 @@ test('bridge rejects unknown preflight and unauthenticated requests before readi
|
||||
assert.equal(unknownResponse.statusCode, 403);
|
||||
assert.equal(unknownResponse.headers['access-control-allow-origin'], undefined);
|
||||
|
||||
const unauthenticated = requestHarness({ body: '{'.repeat(1000) });
|
||||
let dataRead = false;
|
||||
unauthenticated.on('data', () => { dataRead = true; });
|
||||
const unauthenticatedResponse = responseHarness();
|
||||
await handler(unauthenticated, unauthenticatedResponse);
|
||||
assert.equal(unauthenticatedResponse.statusCode, 403);
|
||||
assert.equal(dataRead, false);
|
||||
|
||||
const allowedPreflight = requestHarness({ method: 'OPTIONS', secret });
|
||||
const allowedPreflight = requestHarness({ method: 'OPTIONS' });
|
||||
const allowedResponse = responseHarness();
|
||||
await handler(allowedPreflight, allowedResponse);
|
||||
assert.equal(allowedResponse.statusCode, 204);
|
||||
assert.equal(allowedResponse.headers['access-control-allow-origin'], APT_EXTENSION_ORIGIN);
|
||||
assert.match(allowedResponse.headers['access-control-allow-headers'], /X-APT-Pairing/);
|
||||
assert.doesNotMatch(allowedResponse.headers['access-control-allow-headers'], /X-APT-Pairing/i);
|
||||
|
||||
const accepted = requestHarness({
|
||||
secret,
|
||||
body: JSON.stringify({ deploymentUrl: 'https://customer.avasecurity.com', cookieValue: 'valid-cookie' }),
|
||||
});
|
||||
const clientNonce = Buffer.alloc(32, 3).toString('base64url');
|
||||
const challengeResponse = responseHarness();
|
||||
await handler(requestHarness({ url: '/challenge', body: JSON.stringify({ clientNonce }) }), challengeResponse);
|
||||
assert.equal(challengeResponse.statusCode, 200);
|
||||
const challenge = JSON.parse(challengeResponse.body);
|
||||
assert.equal(JSON.stringify(challenge).includes(secret), false);
|
||||
|
||||
const cookieBody = {
|
||||
clientNonce,
|
||||
serverNonce: challenge.serverNonce,
|
||||
deploymentUrl: 'https://customer.avasecurity.com',
|
||||
cookieValue: 'valid-cookie',
|
||||
};
|
||||
cookieBody.proof = computeCookieProof(secret, cookieBody);
|
||||
const acceptedResponse = responseHarness();
|
||||
await handler(accepted, acceptedResponse);
|
||||
await handler(requestHarness({ body: JSON.stringify(cookieBody) }), acceptedResponse);
|
||||
assert.equal(acceptedResponse.statusCode, 200);
|
||||
assert.deepEqual(sessionStore.describe(), { connected: true, origin: 'https://customer.avasecurity.com' });
|
||||
assert.equal(stateNotifications, 1);
|
||||
assert.equal(acceptedResponse.body.includes('valid-cookie'), false);
|
||||
|
||||
const replayResponse = responseHarness();
|
||||
await handler(requestHarness({ body: JSON.stringify(cookieBody) }), replayResponse);
|
||||
assert.equal(replayResponse.statusCode, 403);
|
||||
});
|
||||
|
||||
test('pairing envelope persists with restrictive permissions and secrets are returned once', () => {
|
||||
test('bridge limiter encloses body reads and HMAC authentication under forged floods', async () => {
|
||||
const auth = new BridgeAuth({ protect: (value) => Buffer.from(value), unprotect: (value) => Buffer.from(value).toString() });
|
||||
auth.rotate();
|
||||
const limiter = createRequestLimiter({ maxConcurrent: 1 });
|
||||
const handler = createBridgeHandler({ bridgeAuth: auth, sessionStore: createSessionStore(), limiter, deadlineMs: 50 });
|
||||
const held = new PassThrough();
|
||||
held.method = 'POST';
|
||||
held.url = '/challenge';
|
||||
held.headers = { origin: APT_EXTENSION_ORIGIN };
|
||||
const first = handler(held, responseHarness());
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
assert.equal(limiter.active, 1);
|
||||
const rejected = responseHarness();
|
||||
await handler(requestHarness({ url: '/challenge', body: JSON.stringify({ clientNonce: 'A'.repeat(43) }) }), rejected);
|
||||
assert.equal(rejected.statusCode, 429);
|
||||
held.end('{}');
|
||||
await first;
|
||||
});
|
||||
|
||||
test('pairing envelope persists encrypted with restrictive permissions and secrets are returned once', () => {
|
||||
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'apt-pairing-'));
|
||||
const envelopePath = path.join(directory, 'bridge-pairing.json');
|
||||
const controller = new PairingController({ envelopePath });
|
||||
const protect = (value) => Buffer.from(`dpapi:${value}`);
|
||||
const unprotect = (value) => Buffer.from(value).toString().slice('dpapi:'.length);
|
||||
const controller = new PairingController({ envelopePath, protect, unprotect });
|
||||
|
||||
const first = controller.initialize();
|
||||
assert.equal(first.paired, true);
|
||||
assert.match(first.secret, /^[A-Za-z0-9_-]{43}$/);
|
||||
assert.deepEqual(controller.getStatus(), { paired: true });
|
||||
const persisted = loadPairingEnvelope(envelopePath);
|
||||
const persisted = loadPairingEnvelope(envelopePath, { protect, unprotect });
|
||||
assert.equal(Object.values(persisted).includes(first.secret), false);
|
||||
assert.equal(fs.readFileSync(envelopePath, 'utf8').includes(first.secret), false);
|
||||
if (process.platform !== 'win32') assert.equal(fs.statSync(envelopePath).mode & 0o777, 0o600);
|
||||
|
||||
const rotated = controller.rotate();
|
||||
@@ -152,6 +195,84 @@ test('pairing envelope persists with restrictive permissions and secrets are ret
|
||||
assert.equal(fs.existsSync(envelopePath), false);
|
||||
});
|
||||
|
||||
test('pairing fails closed and reports unavailable without secure storage', () => {
|
||||
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'apt-pairing-unavailable-'));
|
||||
const controller = new PairingController({ envelopePath: path.join(directory, 'bridge-pairing.json') });
|
||||
assert.deepEqual(controller.initialize(), { paired: false, unavailable: true });
|
||||
assert.deepEqual(controller.getStatus(), { paired: false, unavailable: true });
|
||||
assert.throws(() => controller.rotate(), (error) => error.code === 'SECURE_STORAGE_UNAVAILABLE');
|
||||
});
|
||||
|
||||
test('runtime reconciles exited children and permits relaunch for the same device', async () => {
|
||||
const sessionStore = createSessionStore();
|
||||
sessionStore.establish('https://customer.avasecurity.com', 'synthetic-cookie');
|
||||
const deviceId = '550e8400-e29b-41d4-a716-446655440000';
|
||||
const tracked = [];
|
||||
let nextPid = 4101;
|
||||
const runtime = new AppRuntime({
|
||||
sessionStore,
|
||||
altaClient: { getDevices: async () => [{ guid: deviceId }] },
|
||||
proxyManager: {
|
||||
launchProxy() {
|
||||
const entry = { processId: nextPid++, deviceId, status: 'running', startedAt: 1 };
|
||||
tracked.push(entry);
|
||||
return { success: true, ...entry };
|
||||
},
|
||||
stopProxy() { throw new Error('unused'); },
|
||||
listTrackedProxies() { return tracked.slice(); },
|
||||
},
|
||||
});
|
||||
await runtime.getDevices();
|
||||
assert.equal((await runtime.launchProxy(deviceId)).processId, 4101);
|
||||
tracked.length = 0;
|
||||
assert.deepEqual(runtime.getConnectionState().activeProxies, []);
|
||||
assert.equal((await runtime.launchProxy(deviceId)).processId, 4102);
|
||||
});
|
||||
|
||||
test('disconnect stops every owned proxy before clearing the Alta session', async () => {
|
||||
const sessionStore = createSessionStore();
|
||||
sessionStore.establish('https://customer.avasecurity.com', 'synthetic-cookie');
|
||||
const tracked = [
|
||||
{ processId: 4101, deviceId: '550e8400-e29b-41d4-a716-446655440000', status: 'running', startedAt: 1 },
|
||||
{ processId: 4102, deviceId: '550e8400-e29b-41d4-a716-446655440001', status: 'running', startedAt: 1 },
|
||||
];
|
||||
const stopped = [];
|
||||
const runtime = new AppRuntime({
|
||||
sessionStore,
|
||||
altaClient: {},
|
||||
proxyManager: {
|
||||
listTrackedProxies() { return tracked.slice(); },
|
||||
stopProxy(processId) {
|
||||
stopped.push(processId);
|
||||
tracked.splice(tracked.findIndex((entry) => entry.processId === processId), 1);
|
||||
return { success: true, processId, status: 'stop-requested' };
|
||||
},
|
||||
},
|
||||
});
|
||||
const state = runtime.disconnect();
|
||||
assert.deepEqual(stopped, [4101, 4102]);
|
||||
assert.equal(state.connected, false);
|
||||
assert.deepEqual(state.activeProxies, []);
|
||||
});
|
||||
|
||||
test('disconnect reports failure truthfully and retains session when an owned proxy cannot stop', () => {
|
||||
const sessionStore = createSessionStore();
|
||||
sessionStore.establish('https://customer.avasecurity.com', 'synthetic-cookie');
|
||||
const tracked = [{ processId: 4101, deviceId: '550e8400-e29b-41d4-a716-446655440000', status: 'running', startedAt: 1 }];
|
||||
const runtime = new AppRuntime({
|
||||
sessionStore,
|
||||
altaClient: {},
|
||||
proxyManager: {
|
||||
listTrackedProxies() { return tracked.slice(); },
|
||||
stopProxy() { return { success: false, processId: 4101, status: 'permission-denied' }; },
|
||||
},
|
||||
});
|
||||
const state = runtime.disconnect();
|
||||
assert.equal(state.success, false);
|
||||
assert.equal(state.connected, true);
|
||||
assert.deepEqual(state.activeProxies.map((entry) => entry.processId), [4101]);
|
||||
});
|
||||
|
||||
test('update runtime is check-only and opens only the fixed GitPeji releases page', async () => {
|
||||
const opened = [];
|
||||
const runtime = new AppRuntime({
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
const assert = require('node:assert/strict');
|
||||
const test = require('node:test');
|
||||
const { EventEmitter } = require('node:events');
|
||||
|
||||
const {
|
||||
LATEST_RELEASE_URL,
|
||||
@@ -9,6 +10,7 @@ const {
|
||||
MAX_BODY_BYTES,
|
||||
checkForUpdate,
|
||||
compareSemver,
|
||||
defaultRequest,
|
||||
} = require('../src/update-policy');
|
||||
|
||||
function response(body, overrides = {}) {
|
||||
@@ -188,3 +190,35 @@ test('unexpected HTTP and content types fail closed', async () => {
|
||||
'INVALID_CONTENT_TYPE',
|
||||
);
|
||||
});
|
||||
|
||||
test('default transport enforces an absolute deadline despite trickled response bytes', async () => {
|
||||
const request = new EventEmitter();
|
||||
const responseStream = new EventEmitter();
|
||||
request.destroyedWith = null;
|
||||
responseStream.destroyed = false;
|
||||
request.destroy = (error) => { request.destroyedWith = error; request.emit('error', error); };
|
||||
request.setTimeout = () => { throw new Error('inactivity timeout must not be used'); };
|
||||
responseStream.destroy = () => { responseStream.destroyed = true; };
|
||||
responseStream.headers = { 'content-type': 'application/json' };
|
||||
responseStream.statusCode = 200;
|
||||
let deadline;
|
||||
let cleared = false;
|
||||
const pending = defaultRequest({
|
||||
url: LATEST_RELEASE_URL,
|
||||
timeoutMs: 5000,
|
||||
maxBodyBytes: MAX_BODY_BYTES,
|
||||
httpsGet: (_url, _options, onResponse) => {
|
||||
onResponse(responseStream);
|
||||
return request;
|
||||
},
|
||||
setTimer: (callback, milliseconds) => { assert.equal(milliseconds, 5000); deadline = callback; return 7; },
|
||||
clearTimer: (timer) => { assert.equal(timer, 7); cleared = true; },
|
||||
});
|
||||
responseStream.emit('data', Buffer.from('{'));
|
||||
responseStream.emit('data', Buffer.from(' '));
|
||||
deadline();
|
||||
await rejectsWithCode(pending, 'REQUEST_TIMEOUT');
|
||||
assert.equal(responseStream.destroyed, true);
|
||||
assert.equal(request.destroyedWith.code, 'REQUEST_TIMEOUT');
|
||||
assert.equal(cleared, true);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user