2023-12-18 12:08:34 -05:00
|
|
|
'use strict';
|
|
|
|
|
|
2025-05-22 14:14:53 -04:00
|
|
|
const dns = require('dns').promises;
|
|
|
|
|
|
2025-01-22 13:26:29 -05:00
|
|
|
const nconf = require('nconf');
|
2025-05-22 14:14:53 -04:00
|
|
|
const ipaddr = require('ipaddr.js');
|
2023-12-18 12:08:34 -05:00
|
|
|
const { CookieJar } = require('tough-cookie');
|
2024-02-22 11:49:11 -05:00
|
|
|
const fetchCookie = require('fetch-cookie').default;
|
2024-12-11 10:19:55 -05:00
|
|
|
const { version } = require('../package.json');
|
2023-12-18 12:08:34 -05:00
|
|
|
|
2025-05-22 14:14:53 -04:00
|
|
|
const ttl = require('./cache/ttl');
|
|
|
|
|
const checkCache = ttl({
|
|
|
|
|
ttl: 1000 * 60 * 60, // 1 hour
|
|
|
|
|
});
|
|
|
|
|
|
2023-12-18 12:08:34 -05:00
|
|
|
exports.jar = function () {
|
|
|
|
|
return new CookieJar();
|
|
|
|
|
};
|
|
|
|
|
|
2025-01-22 13:26:29 -05:00
|
|
|
const userAgent = `NodeBB/${version.split('.').shift()}.x (${nconf.get('url')})`;
|
2024-12-04 15:16:26 -05:00
|
|
|
|
2024-12-11 10:19:55 -05:00
|
|
|
// Initialize fetch - somewhat hacky, but it's required for globalDispatcher to be available
|
2023-12-18 12:08:34 -05:00
|
|
|
async function call(url, method, { body, timeout, jar, ...config } = {}) {
|
2025-05-22 14:14:53 -04:00
|
|
|
const ok = await check(url);
|
|
|
|
|
if (!ok) {
|
|
|
|
|
throw new Error('[[error:reserved-ip-address]]');
|
|
|
|
|
}
|
|
|
|
|
|
2023-12-18 12:08:34 -05:00
|
|
|
let fetchImpl = fetch;
|
|
|
|
|
if (jar) {
|
|
|
|
|
fetchImpl = fetchCookie(fetch, jar);
|
|
|
|
|
}
|
2023-12-22 15:52:38 -05:00
|
|
|
const jsonTest = /application\/([a-z]+\+)?json/;
|
2023-12-18 12:08:34 -05:00
|
|
|
const opts = {
|
|
|
|
|
...config,
|
|
|
|
|
method,
|
|
|
|
|
headers: {
|
|
|
|
|
'content-type': 'application/json',
|
2024-12-04 15:16:26 -05:00
|
|
|
'user-agent': userAgent,
|
2023-12-18 12:08:34 -05:00
|
|
|
...config.headers,
|
|
|
|
|
},
|
|
|
|
|
};
|
|
|
|
|
if (timeout > 0) {
|
|
|
|
|
opts.signal = AbortSignal.timeout(timeout);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (body && ['POST', 'PUT', 'PATCH', 'DEL', 'DELETE'].includes(method)) {
|
2023-12-22 15:52:38 -05:00
|
|
|
if (opts.headers['content-type'] && jsonTest.test(opts.headers['content-type'])) {
|
2023-12-18 12:08:34 -05:00
|
|
|
opts.body = JSON.stringify(body);
|
|
|
|
|
} else {
|
|
|
|
|
opts.body = body;
|
|
|
|
|
}
|
|
|
|
|
}
|
2024-01-22 23:17:20 +01:00
|
|
|
// Workaround for https://github.com/nodejs/undici/issues/1305
|
|
|
|
|
if (global[Symbol.for('undici.globalDispatcher.1')] !== undefined) {
|
|
|
|
|
class FetchAgent extends global[Symbol.for('undici.globalDispatcher.1')].constructor {
|
|
|
|
|
dispatch(opts, handler) {
|
|
|
|
|
delete opts.headers['sec-fetch-mode'];
|
|
|
|
|
return super.dispatch(opts, handler);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
opts.dispatcher = new FetchAgent();
|
|
|
|
|
}
|
2023-12-18 12:08:34 -05:00
|
|
|
|
|
|
|
|
const response = await fetchImpl(url, opts);
|
|
|
|
|
|
|
|
|
|
const { headers } = response;
|
|
|
|
|
const contentType = headers.get('content-type');
|
2023-12-19 14:33:14 -05:00
|
|
|
const isJSON = contentType && jsonTest.test(contentType);
|
2023-12-18 12:08:34 -05:00
|
|
|
let respBody = await response.text();
|
|
|
|
|
if (isJSON && respBody) {
|
|
|
|
|
try {
|
|
|
|
|
respBody = JSON.parse(respBody);
|
|
|
|
|
} catch (err) {
|
|
|
|
|
throw new Error('invalid json in response body', url);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
body: respBody,
|
|
|
|
|
response: {
|
|
|
|
|
ok: response.ok,
|
|
|
|
|
status: response.status,
|
|
|
|
|
statusCode: response.status,
|
|
|
|
|
statusText: response.statusText,
|
|
|
|
|
headers: Object.fromEntries(response.headers.entries()),
|
|
|
|
|
},
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
2025-05-22 14:14:53 -04:00
|
|
|
// Checks url to ensure it is not in reserved IP range (private, etc.)
|
|
|
|
|
async function check(url) {
|
2025-05-22 15:36:22 -04:00
|
|
|
const { host } = new URL(url);
|
|
|
|
|
if (host === nconf.get('url_parsed').host) {
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
|
2025-05-22 14:14:53 -04:00
|
|
|
const cached = checkCache.get(url);
|
|
|
|
|
if (cached) {
|
|
|
|
|
return cached;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const addresses = new Set();
|
|
|
|
|
if (ipaddr.isValid(url)) {
|
|
|
|
|
addresses.add(url);
|
|
|
|
|
} else {
|
2025-05-22 15:36:22 -04:00
|
|
|
const lookup = await dns.lookup(host, { all: true });
|
|
|
|
|
lookup.forEach(({ address }) => {
|
|
|
|
|
addresses.add(address);
|
2025-05-22 14:14:53 -04:00
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Every IP address that the host resolves to should be a unicast address
|
|
|
|
|
const ok = Array.from(addresses).every((ip) => {
|
|
|
|
|
const parsed = ipaddr.parse(ip);
|
|
|
|
|
return parsed.range() === 'unicast';
|
|
|
|
|
});
|
|
|
|
|
|
2025-05-22 15:36:22 -04:00
|
|
|
checkCache.set(host, ok);
|
2025-05-22 14:14:53 -04:00
|
|
|
return ok;
|
|
|
|
|
}
|
|
|
|
|
|
2023-12-18 12:08:34 -05:00
|
|
|
/*
|
|
|
|
|
const { body, response } = await request.get('someurl?foo=1&baz=2')
|
|
|
|
|
*/
|
|
|
|
|
exports.get = async (url, config) => call(url, 'GET', config);
|
|
|
|
|
|
|
|
|
|
exports.head = async (url, config) => call(url, 'HEAD', config);
|
|
|
|
|
exports.del = async (url, config) => call(url, 'DELETE', config);
|
|
|
|
|
exports.delete = exports.del;
|
|
|
|
|
exports.options = async (url, config) => call(url, 'OPTIONS', config);
|
|
|
|
|
|
|
|
|
|
/*
|
|
|
|
|
const { body, response } = await request.post('someurl', { body: { foo: 1, baz: 2}})
|
|
|
|
|
*/
|
|
|
|
|
exports.post = async (url, config) => call(url, 'POST', config);
|
|
|
|
|
exports.put = async (url, config) => call(url, 'PUT', config);
|
|
|
|
|
exports.patch = async (url, config) => call(url, 'PATCH', config);
|
|
|
|
|
|
|
|
|
|
|