From 9d3b8c3abcd60aa5a6d85ff804008e7d8345a95b Mon Sep 17 00:00:00 2001 From: Julian Lam Date: Thu, 22 May 2025 14:14:53 -0400 Subject: [PATCH] feat: add protection mechanism to request lib so that network requests to reserved IP ranges throw an error --- public/language/en-GB/error.json | 2 ++ src/request.js | 47 ++++++++++++++++++++++++++++++++ 2 files changed, 49 insertions(+) diff --git a/public/language/en-GB/error.json b/public/language/en-GB/error.json index 82a623ea32..2ca10a839f 100644 --- a/public/language/en-GB/error.json +++ b/public/language/en-GB/error.json @@ -4,6 +4,8 @@ "wrong-parameter-type": "A value of type %3 was expected for property `%1`, but %2 was received instead", "required-parameters-missing": "Required parameters were missing from this API call: %1", + "reserved-ip-address": "Network requests to reserved IP ranges are not allowed.", + "not-logged-in": "You don't seem to be logged in.", "account-locked": "Your account has been locked temporarily", "search-requires-login": "Searching requires an account - please login or register.", diff --git a/src/request.js b/src/request.js index b84b198914..4806eabc41 100644 --- a/src/request.js +++ b/src/request.js @@ -1,10 +1,18 @@ 'use strict'; +const dns = require('dns').promises; + const nconf = require('nconf'); +const ipaddr = require('ipaddr.js'); const { CookieJar } = require('tough-cookie'); const fetchCookie = require('fetch-cookie').default; const { version } = require('../package.json'); +const ttl = require('./cache/ttl'); +const checkCache = ttl({ + ttl: 1000 * 60 * 60, // 1 hour +}); + exports.jar = function () { return new CookieJar(); }; @@ -13,6 +21,11 @@ const userAgent = `NodeBB/${version.split('.').shift()}.x (${nconf.get('url')})` // Initialize fetch - somewhat hacky, but it's required for globalDispatcher to be available async function call(url, method, { body, timeout, jar, ...config } = {}) { + const ok = await check(url); + if (!ok) { + throw new Error('[[error:reserved-ip-address]]'); + } + let fetchImpl = fetch; if (jar) { fetchImpl = fetchCookie(fetch, jar); @@ -75,6 +88,40 @@ async function call(url, method, { body, timeout, jar, ...config } = {}) { }; } +// Checks url to ensure it is not in reserved IP range (private, etc.) +async function check(url) { + const cached = checkCache.get(url); + if (cached) { + return cached; + } + + const addresses = new Set(); + if (ipaddr.isValid(url)) { + addresses.add(url); + } else { + const { host } = new URL(url); + const [v4, v6] = await Promise.all([ + dns.resolve4(host), + dns.resolve6(host), + ]); + v4.forEach((ip) => { + addresses.add(ip); + }); + v6.forEach((ip) => { + addresses.add(ip); + }); + } + + // 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'; + }); + + checkCache.set(url, ok); + return ok; +} + /* const { body, response } = await request.get('someurl?foo=1&baz=2') */