mirror of
https://github.com/ancsemi/Haven.git
synced 2026-10-26 00:29:14 +00:00
157 lines
6.2 KiB
JavaScript
157 lines
6.2 KiB
JavaScript
'use strict';
|
|
|
|
const assert = require('node:assert/strict');
|
|
const { EventEmitter } = require('node:events');
|
|
const http = require('node:http');
|
|
const { PassThrough } = require('node:stream');
|
|
const test = require('node:test');
|
|
|
|
const {
|
|
UnsafeCallbackError,
|
|
createPinnedLookup,
|
|
postWebhookCallback,
|
|
resolveCallbackDestination,
|
|
validateCallbackUrl
|
|
} = require('../src/webhookCallback');
|
|
|
|
test('callback URL validation rejects credentials and local address forms', () => {
|
|
assert.equal(validateCallbackUrl('ftp://example.com/hook'), false);
|
|
assert.equal(validateCallbackUrl('https://user:secret@example.com/hook'), false);
|
|
assert.equal(validateCallbackUrl('http://127.0.0.2/hook'), false);
|
|
assert.equal(validateCallbackUrl('http://2130706433/hook'), false);
|
|
assert.equal(validateCallbackUrl('http://0x7f000001/hook'), false);
|
|
assert.equal(validateCallbackUrl('http://[::1]/hook'), false);
|
|
assert.equal(validateCallbackUrl('http://[::ffff:7f00:1]/hook'), false);
|
|
assert.equal(validateCallbackUrl('http://[::7f00:1]/hook'), false);
|
|
assert.equal(validateCallbackUrl('http://[::ffff:0:7f00:1]/hook'), false);
|
|
assert.equal(validateCallbackUrl('http://[2001:2::1]/hook'), false);
|
|
assert.equal(validateCallbackUrl('http://[2001:20::1]/hook'), false);
|
|
assert.equal(validateCallbackUrl('http://[4000::1]/hook'), false);
|
|
assert.equal(validateCallbackUrl('http://service.local/hook'), false);
|
|
assert.equal(validateCallbackUrl('https://example.com/hook'), true);
|
|
assert.equal(validateCallbackUrl('https://[2606:4700:4700::1111]/hook'), true);
|
|
});
|
|
|
|
test('private callback opt-in permits local bots but never link-local metadata', async () => {
|
|
assert.equal(validateCallbackUrl('http://127.0.0.2/hook', true), true);
|
|
assert.equal(validateCallbackUrl('http://[::1]/hook', true), true);
|
|
assert.equal(validateCallbackUrl('http://[fd00::7]/hook', true), true);
|
|
assert.equal(validateCallbackUrl('http://[fe80::1]/hook', true), false);
|
|
assert.equal(validateCallbackUrl('http://169.254.169.254/latest/meta-data', true), false);
|
|
|
|
const local = await resolveCallbackDestination('http://bot.internal/hook', {
|
|
allowPrivateCallbacks: true,
|
|
lookup: async () => [{ address: '10.0.0.7', family: 4 }]
|
|
});
|
|
assert.equal(local.address, '10.0.0.7');
|
|
|
|
await assert.rejects(
|
|
resolveCallbackDestination('http://metadata.example/hook', {
|
|
allowPrivateCallbacks: true,
|
|
lookup: async () => [{ address: '169.254.169.254', family: 4 }]
|
|
}),
|
|
error => error instanceof UnsafeCallbackError && /blocked network/.test(error.message)
|
|
);
|
|
});
|
|
|
|
test('DNS resolution is rejected when any answer points to a blocked network', async () => {
|
|
await assert.rejects(
|
|
resolveCallbackDestination('https://mixed.example/hook', {
|
|
lookup: async () => [
|
|
{ address: '93.184.216.34', family: 4 },
|
|
{ address: '127.0.0.9', family: 4 }
|
|
]
|
|
}),
|
|
error => error instanceof UnsafeCallbackError && /blocked network/.test(error.message)
|
|
);
|
|
});
|
|
|
|
test('pinned lookup always returns the address that passed validation', () => {
|
|
const lookup = createPinnedLookup('93.184.216.34', 4);
|
|
lookup('changed.example', {}, (error, address, family) => {
|
|
assert.ifError(error);
|
|
assert.equal(address, '93.184.216.34');
|
|
assert.equal(family, 4);
|
|
});
|
|
lookup('changed.example', { all: true }, (error, addresses) => {
|
|
assert.ifError(error);
|
|
assert.deepEqual(addresses, [{ address: '93.184.216.34', family: 4 }]);
|
|
});
|
|
});
|
|
|
|
test('HTTP delivery uses the validated address without a second DNS lookup', async () => {
|
|
const originalRequest = http.request;
|
|
let dnsLookups = 0;
|
|
|
|
try {
|
|
http.request = (url, options, onResponse) => {
|
|
assert.equal(url.hostname, 'callback.example');
|
|
assert.equal(options.agent, false);
|
|
assert.equal(options.headers['Content-Length'], Buffer.byteLength('{"event":"olá"}'));
|
|
options.lookup(url.hostname, {}, (error, address, family) => {
|
|
assert.ifError(error);
|
|
assert.equal(address, '93.184.216.34');
|
|
assert.equal(family, 4);
|
|
});
|
|
|
|
const request = new EventEmitter();
|
|
request.end = payload => {
|
|
assert.equal(payload, '{"event":"olá"}');
|
|
process.nextTick(() => {
|
|
const response = new PassThrough();
|
|
response.statusCode = 204;
|
|
onResponse(response);
|
|
response.end();
|
|
});
|
|
};
|
|
request.destroy = error => request.emit('error', error);
|
|
return request;
|
|
};
|
|
|
|
const response = await postWebhookCallback(
|
|
'http://callback.example/hook',
|
|
'{"event":"olá"}',
|
|
{ 'Content-Type': 'application/json' },
|
|
{
|
|
timeoutMs: 1000,
|
|
lookup: async () => {
|
|
dnsLookups++;
|
|
return [{ address: '93.184.216.34', family: 4 }];
|
|
}
|
|
}
|
|
);
|
|
|
|
assert.equal(dnsLookups, 1);
|
|
assert.deepEqual(response, { ok: true, status: 204 });
|
|
} finally {
|
|
http.request = originalRequest;
|
|
}
|
|
});
|
|
|
|
test('callback deadline includes DNS resolution', async () => {
|
|
await assert.rejects(
|
|
postWebhookCallback('https://slow-dns.example/hook', '{}', {}, {
|
|
timeoutMs: 20,
|
|
lookup: () => new Promise(() => {})
|
|
}),
|
|
/DNS resolution timed out/
|
|
);
|
|
});
|
|
|
|
test('an agent for a checked destination connects only to the checked address', async () => {
|
|
const { agentForDestination } = require('../src/webhookCallback');
|
|
const https = require('node:https');
|
|
const agent = agentForDestination({ url: new URL('https://push.example.com/send'), address: '93.184.216.34', family: 4 });
|
|
assert.ok(agent instanceof https.Agent, 'web-push only takes an https.Agent');
|
|
const answer = await new Promise(resolve => agent.options.lookup('evil.example', {}, (err, address) => resolve(address)));
|
|
assert.equal(answer, '93.184.216.34');
|
|
});
|
|
|
|
test('cloud metadata addresses stay blocked even with private callbacks allowed', () => {
|
|
for (const url of ['http://100.100.100.200/latest/meta-data', 'http://168.63.129.16/machine', 'http://[fd00:ec2::254]/latest/meta-data', 'http://[::ffff:168.63.129.16]/']) {
|
|
assert.equal(validateCallbackUrl(url, true), false, url);
|
|
assert.equal(validateCallbackUrl(url, false), false, url);
|
|
}
|
|
assert.equal(validateCallbackUrl('http://100.100.100.201/', true), true);
|
|
});
|