activitypub-proxy/index.js

192 lines
7.0 KiB
JavaScript

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;
}