From 9dddd8f0c8a3ff14c671071b38bf88e237ca7e54 Mon Sep 17 00:00:00 2001 From: Bortseb Date: Thu, 20 Aug 2026 14:17:01 -0400 Subject: [PATCH] Allow opt-in anonymous /proxy for HTTP-only remote site fetches HTTPS origins cannot fetch HTTP wiki sites directly (mixed content), so how-to-wiki and other default neighbor resources stay broken for visitors who are not signed in. Add public_proxy so farms can open /proxy without a login, while signed-in owners keep existing localhost/LAN behavior. Anonymous requests resolve the host and refuse private addresses, and do not follow redirects. --- lib/defaultargs.js | 1 + lib/server.js | 35 +++++++++++++++++++++++++++++++++-- 2 files changed, 34 insertions(+), 2 deletions(-) diff --git a/lib/defaultargs.js b/lib/defaultargs.js index 1e06369d..89e60e06 100644 --- a/lib/defaultargs.js +++ b/lib/defaultargs.js @@ -66,6 +66,7 @@ export default argv => { argv.security_type = 'wiki-security-' + argv.security_type } argv.security_legacy ||= false + argv.public_proxy ||= false // resolve all relative paths argv.root = path.resolve(argv.root) diff --git a/lib/server.js b/lib/server.js index 547c29c0..71513108 100644 --- a/lib/server.js +++ b/lib/server.js @@ -23,6 +23,7 @@ import fs from 'fs' import path from 'path' import url from 'url' import { pipeline } from 'node:stream/promises' +import dns from 'node:dns/promises' // From npm import express from 'express' @@ -37,6 +38,16 @@ const DOMPurify = createDOMPurify(window) // Using native fetch API (available in Node.js 18+) +// Loopback, RFC1918, link-local IPv4; loopback/link-local/ULA IPv6. +const isPrivateIP = addr => { + if (addr.includes(':')) return addr === '::1' || addr.startsWith('fe80:') || addr.startsWith('fc') || addr.startsWith('fd') + const [a, b, c, d] = addr.split('.').map(Number) + return ( + [a, b, c, d].every(n => n >= 0 && n <= 255) && + (a === 10 || a === 127 || (a === 192 && b === 168) || (a === 172 && b >= 16 && b <= 31) || (a === 169 && b === 254)) + ) +} + // Express 4 middleware import logger from 'morgan' import cookieParser from 'cookie-parser' @@ -810,11 +821,30 @@ export default async argv => { // ##### Proxy routes ##### - app.get('/proxy/*splat', authorized, (req, res) => { + app.get('/proxy/*splat', async (req, res) => { + const signedIn = securityhandler.isAuthorized(req) + if (!signedIn && !argv.public_proxy) return res.sendStatus(403) const pathParts = req.originalUrl.split('/') const remoteHost = pathParts[2] pathParts.splice(0, 3) const remoteResource = pathParts.join('/') + // Anonymous: refuse hosts that are or resolve to private IPs (owners unchanged). + if (!signedIn) { + let host = remoteHost.toLowerCase() + if (host.startsWith('[')) { + const end = host.indexOf(']') + if (end < 0) return res.sendStatus(403) + host = host.slice(1, end) + } else { + host = host.split(':')[0] + } + try { + const { address } = await dns.lookup(host) + if (isPrivateIP(address.toLowerCase())) return res.sendStatus(403) + } catch { + return res.sendStatus(403) + } + } // this will fail if remote is TLS only! const requestURL = 'http://' + remoteHost + '/' + remoteResource console.log('PROXY Request: ', requestURL) @@ -824,7 +854,8 @@ export default async argv => { requestURL.endsWith('.jpg') || pathParts[0] === 'plugin' ) { - fetch(requestURL, { signal: AbortSignal.timeout(2000) }) + // Anonymous: do not follow redirects (could land on a private IP). + fetch(requestURL, { signal: AbortSignal.timeout(2000), redirect: signedIn ? 'follow' : 'error' }) .then(async fetchRes => { if (fetchRes.ok) { res.set('content-type', fetchRes.headers.get('content-type'))