108 lines
4.2 KiB
JavaScript
108 lines
4.2 KiB
JavaScript
'use strict';
|
|
|
|
const dns = require('node:dns').promises;
|
|
const https = require('node:https');
|
|
const { BlockList, isIP } = require('node:net');
|
|
const { SlowHttp } = require('../lib/http');
|
|
|
|
const blocked = new BlockList();
|
|
for (const [address, bits] of [
|
|
['0.0.0.0', 8], ['10.0.0.0', 8], ['100.64.0.0', 10], ['127.0.0.0', 8],
|
|
['169.254.0.0', 16], ['172.16.0.0', 12], ['192.0.0.0', 24],
|
|
['192.0.2.0', 24], ['192.168.0.0', 16], ['198.18.0.0', 15],
|
|
['198.51.100.0', 24], ['203.0.113.0', 24], ['224.0.0.0', 4],
|
|
['240.0.0.0', 4], ['::', 128], ['::1', 128], ['fc00::', 7],
|
|
['fe80::', 10], ['ff00::', 8], ['2001:db8::', 32],
|
|
]) blocked.addSubnet(address, bits, isIP(address) === 6 ? 'ipv6' : 'ipv4');
|
|
|
|
function publicAddress(address) {
|
|
const family = isIP(address);
|
|
return !!family && !address.toLowerCase().startsWith('::ffff:')
|
|
&& !blocked.check(address, family === 4 ? 'ipv4' : 'ipv6');
|
|
}
|
|
|
|
function validateWeebRequest(value, options = {}) {
|
|
return validateHostRequest(value, 'weebcentral.com', options);
|
|
}
|
|
|
|
function validateHostRequest(value, sourceHost, options = {}) {
|
|
const url = new URL(value);
|
|
const host = url.hostname.toLowerCase();
|
|
if (url.protocol !== 'https:' || url.username || url.password || url.port
|
|
|| isIP(host) || host === 'localhost' || host.endsWith('.localhost')
|
|
|| host.endsWith('.local') || !host.includes('.')) {
|
|
throw new Error('Source request URL is not public HTTPS');
|
|
}
|
|
if (options.accept === 'text/html' && host !== sourceHost) {
|
|
throw new Error('Source catalog request left its source host');
|
|
}
|
|
return url;
|
|
}
|
|
|
|
function pinnedLookup(selected) {
|
|
return (_hostname, lookupOptions, callback) => lookupOptions.all
|
|
? callback(null, [{ address: selected.address, family: selected.family }])
|
|
: callback(null, selected.address, selected.family);
|
|
}
|
|
|
|
async function pinnedFetch(value, options = {}) {
|
|
const url = new URL(value);
|
|
if (url.protocol !== 'https:') throw new Error('Pinned transport requires HTTPS');
|
|
const answers = await dns.lookup(url.hostname, { all: true });
|
|
if (!answers.length || answers.some(answer => !publicAddress(answer.address))) {
|
|
throw Object.assign(new Error('Source hostname resolved to a non-public address'), { nonRetryable: true });
|
|
}
|
|
const selected = answers[0];
|
|
const maxBytes = responseByteLimit(options);
|
|
return new Promise((resolve, reject) => {
|
|
const request = https.request(url, {
|
|
method: 'GET', headers: options.headers, signal: options.signal,
|
|
agent: false,
|
|
lookup: pinnedLookup(selected),
|
|
}, response => {
|
|
let bytes = 0;
|
|
const chunks = [];
|
|
response.on('data', chunk => {
|
|
bytes += chunk.length;
|
|
if (bytes > maxBytes) {
|
|
response.destroy(Object.assign(new Error('Source response exceeds size limit'), { nonRetryable: true }));
|
|
return;
|
|
}
|
|
chunks.push(chunk);
|
|
});
|
|
response.on('error', reject);
|
|
response.on('end', () => {
|
|
const body = Buffer.concat(chunks);
|
|
const status = response.statusCode || 0;
|
|
resolve({
|
|
status, ok: status >= 200 && status < 300,
|
|
headers: { get: name => response.headers[String(name).toLowerCase()] || null },
|
|
text: async () => body.toString('utf8'),
|
|
json: async () => JSON.parse(body.toString('utf8')),
|
|
arrayBuffer: async () => body,
|
|
});
|
|
});
|
|
});
|
|
request.on('error', reject);
|
|
request.end();
|
|
});
|
|
}
|
|
|
|
function responseByteLimit(options = {}) {
|
|
// Chapter lists can be much larger than series/chapter HTML. Only an
|
|
// explicit first-party catalog request gets this bounded allowance.
|
|
if (options.headers?.accept === 'text/html') return (options.catalog ? 16 : 2) * 1024 * 1024;
|
|
return 50 * 1024 * 1024;
|
|
}
|
|
|
|
function createWeebHttp(options = {}) {
|
|
return new SlowHttp({ ...options, gateKey: 'weebcentral', transport: pinnedFetch, validateUrl: validateWeebRequest });
|
|
}
|
|
|
|
function createHostHttp(sourceHost, options = {}) {
|
|
return new SlowHttp({ ...options, gateKey: sourceHost, transport: pinnedFetch,
|
|
validateUrl: (value, requestOptions) => validateHostRequest(value, sourceHost, requestOptions) });
|
|
}
|
|
|
|
module.exports = { publicAddress, validateWeebRequest, validateHostRequest, pinnedLookup, pinnedFetch, responseByteLimit, createWeebHttp, createHostHttp };
|