Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
120 changes: 106 additions & 14 deletions modules/sockets/routes/proxy.mjs
Original file line number Diff line number Diff line change
@@ -1,68 +1,160 @@
import path from "path";
import crypto from "crypto";
import fetch from "node-fetch";
import dns from "dns/promises";
import net from "net";
import { app, fs } from "../../../index.mjs";

const CACHE_DIR = "./cache/proxy";
const TTL = 1000 * 60 * 60 * 24;
const MAX_BYTES = 10 * 1024 * 1024;

// delete all cache files after some time if age expired,
// because otherwise its gonna turn into a junk folder
setInterval(() => {
if (!fs.existsSync(CACHE_DIR)) return;

const now = Date.now();
for (const f of fs.readdirSync(CACHE_DIR)) {
if (f.endsWith(".type")) continue;

const file = path.join(CACHE_DIR, f);

try {
const age = now - fs.statSync(file).mtimeMs;
if (age > TTL) {
fs.unlinkSync(file);
const type = file + ".type";
if (fs.existsSync(type)) fs.unlinkSync(type);
if (fs.existsSync(file + ".type")) fs.unlinkSync(file + ".type");
}
} catch {}
}
}, 1000 * 60 * 30);

function isBlockedIp(ip) {
if (net.isIP(ip) === 4) {
const p = ip.split(".").map(Number);
if (p[0] === 10) return true;
if (p[0] === 127) return true;
if (p[0] === 0) return true;
if (p[0] === 169 && p[1] === 254) return true;
if (p[0] === 172 && p[1] >= 16 && p[1] <= 31) return true;
if (p[0] === 192 && p[1] === 168) return true;
return false;
}

if (net.isIP(ip) === 6) {
const v = ip.toLowerCase();
if (v === "::1" || v === "::") return true;
if (v.startsWith("fc") || v.startsWith("fd")) return true;
if (v.startsWith("fe80:")) return true;
if (v.startsWith("::ffff:")) {
const mapped = v.slice(7);
if (net.isIP(mapped) === 4) return isBlockedIp(mapped);
return true;
}
return false;
}

return true;
}

async function assertSafeHost(hostname) {
if (!hostname) throw new Error("Invalid host");

if (net.isIP(hostname)) {
if (isBlockedIp(hostname)) throw new Error("Blocked host");
return;
}

const records = await dns.lookup(hostname, { all: true });
if (!records.length) throw new Error("DNS failed");

for (const record of records) {
if (isBlockedIp(record.address)) throw new Error("Blocked host");
}
}

function normalizeType(type) {
return String(type || "").split(";")[0].trim().toLowerCase();
}

function isAllowedImageType(type) {
if (!type.startsWith("image/")) return false;
if (type === "image/svg+xml") return false;
return true;
}

app.get("/proxy", async (req, res) => {
const url = req.query.url;
if (!url || !/^https?:\/\//.test(url)) return res.status(400).send("Invalid URL");
if (!url || typeof url !== "string") return res.status(400).send("Invalid URL");

let parsed;
try {
parsed = new URL(url);
} catch {
return res.status(400).send("Invalid URL");
}

if (!["http:", "https:"].includes(parsed.protocol)) {
return res.status(400).send("Invalid URL");
}

if (!fs.existsSync(CACHE_DIR)) fs.mkdirSync(CACHE_DIR, { recursive: true });

const hash = crypto.createHash("sha1").update(url).digest("hex");
const hash = crypto.createHash("sha1").update(parsed.toString()).digest("hex");
const file = path.join(CACHE_DIR, hash);
const typefile = file + ".type";

try {
if (fs.existsSync(file)) {
await assertSafeHost(parsed.hostname);

if (fs.existsSync(file) && fs.existsSync(typefile)) {
const age = Date.now() - fs.statSync(file).mtimeMs;

if (age < TTL) {
const type = fs.existsSync(typefile) ? fs.readFileSync(typefile, "utf8") : "application/octet-stream";
const type = normalizeType(fs.readFileSync(typefile, "utf8"));
if (!isAllowedImageType(type)) {
try { fs.unlinkSync(file); } catch {}
try { fs.unlinkSync(typefile); } catch {}
return res.status(415).send("Blocked content type");
}

res.setHeader("Content-Type", type);
res.setHeader("X-Content-Type-Options", "nosniff");
return fs.createReadStream(file).pipe(res);
} else {
fs.unlinkSync(file);
if (fs.existsSync(typefile)) fs.unlinkSync(typefile);
}

try { fs.unlinkSync(file); } catch {}
try { fs.unlinkSync(typefile); } catch {}
}

const r = await fetch(url, { timeout: 7000 });
const r = await fetch(parsed.toString(), {
timeout: 7000,
redirect: "error",
size: MAX_BYTES
});

if (!r.ok) return res.status(500).send("Fetch failed");

const type = r.headers.get("content-type") || "application/octet-stream";
const type = normalizeType(r.headers.get("content-type"));
if (!isAllowedImageType(type)) {
return res.status(415).send("Blocked content type");
}

const ws = fs.createWriteStream(file);
await new Promise((resolve, reject) => {
r.body.pipe(ws);
r.body.on("error", reject);
ws.on("error", reject);
ws.on("finish", resolve);
});

fs.writeFileSync(typefile, type);

res.setHeader("Content-Type", type);
res.setHeader("X-Content-Type-Options", "nosniff");
fs.createReadStream(file).pipe(res);
} catch {
res.status(500).send("Proxy error");
}
});

export default (io) => (socket) => {};
export default (io) => (socket) => {};
Loading