Files
Haven/test/webhookCallback.test.js

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);
});