UNPKG

activitypub-proxy

Version:

A brutally-simple proxy for ActivityPub that lets you circumvent instance blocks by masquerading as another domain name. All it does is replace all hostnames in the text proxied through, and for signed POST requests, it swaps the public keys and re-signs

192 lines (165 loc) 7.13 kB
import * as dotenv from "dotenv"; dotenv.config(); if (process.env.NODE_ENV != "development") { process.env.NODE_ENV = "production"; console.debug = () => {}; } if (process.env.DOMAIN_WHITELIST) var DOMAIN_WHITELIST = process.env.DOMAIN_WHITELIST.split(',').map(x=>x.trim().toLowerCase()).filter(x=>x); if (process.env.USER_WHITELIST) var USER_WHITELIST = process.env.USER_WHITELIST.split(',').map(x=>x.trim()).filter(x=>x); import express from "express"; import "express-async-errors"; import fetch from "node-fetch"; import Keyv from "keyv"; import {Sha256Signer, Parser} from "activitypub-http-signatures"; import * as crypto from "crypto"; import * as util from "util"; var generateKeyPair = util.promisify(crypto.generateKeyPair); var parser = new Parser(); var keystore = new Keyv("sqlite://keys.sqlite"); var app = express(); app.set("trust proxy", true); app.listen(process.env.PORT || 80, process.env.BIND_IP); app.use(express.text({ type: [ "application/jrd+json", "application/activity+json", "application/json", "text/*" ] })); app.use(async (req, res, next) => { console.debug({ ip: req.ip, method: req.method, url: req.url, headers: req.headers, body: req.body }); if (!["GET","POST"].includes(req.method)) return next(); if (!req.subdomains[0]) return next(); var TARGET_NODE = req.subdomains[0].replaceAll(/(?<!-)-(?!-)/g, '.').replaceAll('--','-'); var TARGET_REGEXP = new RegExp(`(?<!\\.)${TARGET_NODE.replaceAll('.','\\.')}`, 'gi'); var TARGET_MASQUERADE = req.hostname; var TARGET_URL = `https://${TARGET_NODE}${req.url.replaceAll(TARGET_MASQUERADE, TARGET_NODE)}`; if (DOMAIN_WHITELIST && !DOMAIN_WHITELIST.includes(TARGET_NODE)) { console.debug(`target ${TARGET_NODE} blocked by whitelist`); //res.status(403).send(`target ${TARGET_NODE} is not whitelisted`); res.redirect(308, TARGET_URL); return; } var CLIENT_NODE = req.get("User-Agent").match(/(?<=https:\/\/)[a-z0-9-\.]+/i)?.[0]; if (CLIENT_NODE) { if (DOMAIN_WHITELIST && !DOMAIN_WHITELIST.includes(CLIENT_NODE)) { console.debug(`client ${CLIENT_NODE} blocked by whitelist`); //res.status(403).send(`client ${CLIENT_NODE} is not whitelisted`); res.redirect(308, TARGET_URL); return; } var CLIENT_REGEXP = new RegExp(`(?<!\\.)${CLIENT_NODE.replaceAll('.','\\.')}`, 'gi'); var CLIENT_MASQUERADE = [CLIENT_NODE.replaceAll('-','--').replaceAll('.','-'), ...req.hostname.split('.').slice(-2)].join('.'); } var opts = {method: req.method, headers: { "host": TARGET_NODE, "date": new Date().toUTCString(), }}; if (req.method == "POST") { var signature = parser.parse({url: req.url, method: req.method, headers: req.headers}); console.debug({signature}); var publicKeyPem = await getRemotePubkey(signature.keyId); if (!publicKeyPem) return res.status(400).send("could not get pubkey"); if (!signature.verify(publicKeyPem)) { res.status(400).send("bad signature"); console.debug("bad signature"); return; } var modifiedPayload = req.body.replaceAll(TARGET_MASQUERADE, TARGET_NODE).replaceAll(CLIENT_REGEXP, CLIENT_MASQUERADE); console.debug({CLIENT_NODE, CLIENT_REGEXP, CLIENT_MASQUERADE, original:req.body, modified:modifiedPayload}); var digest = crypto.createHash("sha256").update(modifiedPayload, "utf-8").digest("base64"); opts.headers["digest"] = `sha-256=${digest}`; var clientMasqueradeKeyId = signature.keyId.replaceAll(CLIENT_NODE, CLIENT_MASQUERADE); var clientMasqueradePrivateKeyPem = (await getLocalKeypair(clientMasqueradeKeyId))?.privateKey; var signer = new Sha256Signer({ publicKeyId: clientMasqueradeKeyId, privateKey: clientMasqueradePrivateKeyPem, headerNames: ['(request-target)', 'host', 'date', 'digest'] }); opts.headers["signature"] = signer.sign({url: TARGET_URL, method: opts.method, headers: opts.headers}); opts.headers["content-tength"] = modifiedPayload.length; if (req.get("Content-Type")) opts.headers["content-type"] = req.get("Content-Type"); opts.body = modifiedPayload; } if (req.get("User-Agent")) opts.headers["user-agent"] = req.get("User-Agent").replaceAll(CLIENT_NODE, CLIENT_MASQUERADE); if (req.get("Accept")) opts.headers["accept"] = req.get("Accept"); var target_res = await fetch(TARGET_URL, opts); var contentType = target_res.headers.get("content-type"); console.debug(target_res.status, target_res.statusText, contentType); //note this affects html attachments from pleroma if (contentType.startsWith("text/html")) { //res.status(403).send("html is not allowed"); res.redirect(308, TARGET_URL); return; } if ([ "application/jrd+json", "application/activity+json", "application/json", "application/xrd+xml", "application/xml" ].some(t => contentType?.toLowerCase().startsWith(t))) { var originalText = await target_res.text(), modifiedText = originalText; console.debug({originalText}) if (contentType.includes("json")) { var json = JSON.parse(originalText); if (json.preferredUsername && USER_WHITELIST && !USER_WHITELIST.includes(json.preferredUsername)) { console.debug(`user ${json.preferredUsername} blocked by whitelist`); //res.status(403).send(`${json.preferredUsername} is not whitelisted`); res.redirect(308, TARGET_URL); return; } if (json.publicKey) { console.debug("has key"); await keystore.set(json.publicKey.id, {publicKey: json.publicKey.publicKeyPem}); var masqueradeKeyId = json.publicKey.id.replaceAll(TARGET_REGEXP, TARGET_MASQUERADE); var masqueradeKeyPem = (await getLocalKeypair(masqueradeKeyId)).publicKey; json.publicKey.id = masqueradeKeyId; json.publicKey.publicKeyPem = masqueradeKeyPem; modifiedText = JSON.stringify(json); } } modifiedText = modifiedText.replaceAll(TARGET_REGEXP, TARGET_MASQUERADE); console.debug({modifiedText}); } else console.debug("passthrough"); if (!target_res.ok && !modifiedText && contentType.startsWith("text/")) console.debug("response:", await target_res.text()); res.status(target_res.status); res.header("Content-Type", contentType); if (modifiedText) res.send(modifiedText); else target_res.body.pipe(res); }); async function getLocalKeypair(id) { var keys = await keystore.get(id); if (keys) return keys; console.debug("making new masquerade key"); keys = await generateKeyPair('rsa', { publicKeyEncoding: {type:'pkcs1', format: 'pem'}, privateKeyEncoding: {type:'pkcs1', format: 'pem'}, modulusLength: 2048 }); await keystore.set(id, keys); return keys; } async function getRemotePubkey(id) { var publicKey = (await keystore.get(id))?.publicKey; if (publicKey) return publicKey; console.debug("fetching public key", id); var res = await fetch(id, {headers: {Accept: "application/activity+json"}}); console.debug(res.status, res.statusText, res.headers.get("content-type")); if (!res.ok || !res.headers.get("content-type").includes("json")) { console.debug("could not get key"); return false; }; var json = await res.json(); console.debug(json); var publicKey = json?.publicKey?.publicKeyPem; if (publicKey) keystore.set(id, {publicKey}); return publicKey; }