Skip to content

Commit 4b4f5c2

Browse files
authored
Merge pull request #271 from hackthedev/dev
Security Patch
2 parents 2b66347 + 1dbf123 commit 4b4f5c2

1 file changed

Lines changed: 106 additions & 14 deletions

File tree

modules/sockets/routes/proxy.mjs

Lines changed: 106 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,68 +1,160 @@
11
import path from "path";
22
import crypto from "crypto";
33
import fetch from "node-fetch";
4+
import dns from "dns/promises";
5+
import net from "net";
46
import { app, fs } from "../../../index.mjs";
57

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

9-
// delete all cache files after some time if age expired,
10-
// because otherwise its gonna turn into a junk folder
1112
setInterval(() => {
13+
if (!fs.existsSync(CACHE_DIR)) return;
14+
1215
const now = Date.now();
1316
for (const f of fs.readdirSync(CACHE_DIR)) {
1417
if (f.endsWith(".type")) continue;
18+
1519
const file = path.join(CACHE_DIR, f);
1620

1721
try {
1822
const age = now - fs.statSync(file).mtimeMs;
1923
if (age > TTL) {
2024
fs.unlinkSync(file);
21-
const type = file + ".type";
22-
if (fs.existsSync(type)) fs.unlinkSync(type);
25+
if (fs.existsSync(file + ".type")) fs.unlinkSync(file + ".type");
2326
}
2427
} catch {}
2528
}
2629
}, 1000 * 60 * 30);
2730

31+
function isBlockedIp(ip) {
32+
if (net.isIP(ip) === 4) {
33+
const p = ip.split(".").map(Number);
34+
if (p[0] === 10) return true;
35+
if (p[0] === 127) return true;
36+
if (p[0] === 0) return true;
37+
if (p[0] === 169 && p[1] === 254) return true;
38+
if (p[0] === 172 && p[1] >= 16 && p[1] <= 31) return true;
39+
if (p[0] === 192 && p[1] === 168) return true;
40+
return false;
41+
}
42+
43+
if (net.isIP(ip) === 6) {
44+
const v = ip.toLowerCase();
45+
if (v === "::1" || v === "::") return true;
46+
if (v.startsWith("fc") || v.startsWith("fd")) return true;
47+
if (v.startsWith("fe80:")) return true;
48+
if (v.startsWith("::ffff:")) {
49+
const mapped = v.slice(7);
50+
if (net.isIP(mapped) === 4) return isBlockedIp(mapped);
51+
return true;
52+
}
53+
return false;
54+
}
55+
56+
return true;
57+
}
58+
59+
async function assertSafeHost(hostname) {
60+
if (!hostname) throw new Error("Invalid host");
61+
62+
if (net.isIP(hostname)) {
63+
if (isBlockedIp(hostname)) throw new Error("Blocked host");
64+
return;
65+
}
66+
67+
const records = await dns.lookup(hostname, { all: true });
68+
if (!records.length) throw new Error("DNS failed");
69+
70+
for (const record of records) {
71+
if (isBlockedIp(record.address)) throw new Error("Blocked host");
72+
}
73+
}
74+
75+
function normalizeType(type) {
76+
return String(type || "").split(";")[0].trim().toLowerCase();
77+
}
78+
79+
function isAllowedImageType(type) {
80+
if (!type.startsWith("image/")) return false;
81+
if (type === "image/svg+xml") return false;
82+
return true;
83+
}
84+
2885
app.get("/proxy", async (req, res) => {
2986
const url = req.query.url;
30-
if (!url || !/^https?:\/\//.test(url)) return res.status(400).send("Invalid URL");
87+
if (!url || typeof url !== "string") return res.status(400).send("Invalid URL");
88+
89+
let parsed;
90+
try {
91+
parsed = new URL(url);
92+
} catch {
93+
return res.status(400).send("Invalid URL");
94+
}
95+
96+
if (!["http:", "https:"].includes(parsed.protocol)) {
97+
return res.status(400).send("Invalid URL");
98+
}
99+
31100
if (!fs.existsSync(CACHE_DIR)) fs.mkdirSync(CACHE_DIR, { recursive: true });
32101

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

37106
try {
38-
if (fs.existsSync(file)) {
107+
await assertSafeHost(parsed.hostname);
108+
109+
if (fs.existsSync(file) && fs.existsSync(typefile)) {
39110
const age = Date.now() - fs.statSync(file).mtimeMs;
111+
40112
if (age < TTL) {
41-
const type = fs.existsSync(typefile) ? fs.readFileSync(typefile, "utf8") : "application/octet-stream";
113+
const type = normalizeType(fs.readFileSync(typefile, "utf8"));
114+
if (!isAllowedImageType(type)) {
115+
try { fs.unlinkSync(file); } catch {}
116+
try { fs.unlinkSync(typefile); } catch {}
117+
return res.status(415).send("Blocked content type");
118+
}
119+
42120
res.setHeader("Content-Type", type);
121+
res.setHeader("X-Content-Type-Options", "nosniff");
43122
return fs.createReadStream(file).pipe(res);
44-
} else {
45-
fs.unlinkSync(file);
46-
if (fs.existsSync(typefile)) fs.unlinkSync(typefile);
47123
}
124+
125+
try { fs.unlinkSync(file); } catch {}
126+
try { fs.unlinkSync(typefile); } catch {}
48127
}
49128

50-
const r = await fetch(url, { timeout: 7000 });
129+
const r = await fetch(parsed.toString(), {
130+
timeout: 7000,
131+
redirect: "error",
132+
size: MAX_BYTES
133+
});
134+
51135
if (!r.ok) return res.status(500).send("Fetch failed");
52136

53-
const type = r.headers.get("content-type") || "application/octet-stream";
137+
const type = normalizeType(r.headers.get("content-type"));
138+
if (!isAllowedImageType(type)) {
139+
return res.status(415).send("Blocked content type");
140+
}
141+
54142
const ws = fs.createWriteStream(file);
55143
await new Promise((resolve, reject) => {
56144
r.body.pipe(ws);
57145
r.body.on("error", reject);
146+
ws.on("error", reject);
58147
ws.on("finish", resolve);
59148
});
149+
60150
fs.writeFileSync(typefile, type);
151+
61152
res.setHeader("Content-Type", type);
153+
res.setHeader("X-Content-Type-Options", "nosniff");
62154
fs.createReadStream(file).pipe(res);
63155
} catch {
64156
res.status(500).send("Proxy error");
65157
}
66158
});
67159

68-
export default (io) => (socket) => {};
160+
export default (io) => (socket) => {};

0 commit comments

Comments
 (0)