From 4a816a0733404b179c931051c1e60b5c5d65bd22 Mon Sep 17 00:00:00 2001 From: Caleb Date: Mon, 13 Jul 2026 12:30:16 +0300 Subject: [PATCH] fix(http): harden http command against SSRF and DoS --- src/commands/tools/http.ts | 358 +++++++++++++++++++++++++++++++------ 1 file changed, 307 insertions(+), 51 deletions(-) diff --git a/src/commands/tools/http.ts b/src/commands/tools/http.ts index ca32108..c3573e6 100644 --- a/src/commands/tools/http.ts +++ b/src/commands/tools/http.ts @@ -1,7 +1,228 @@ import { EmbedBuilder } from "discord.js"; import fetch from "node-fetch"; +import dns from "node:dns/promises"; +import net from "node:net"; import type { CommandCallbackOpts } from "../../types/command.ts"; +const MAX_REDIRECTS = 5; +// 2MB cap on response body +const MAX_BODY_BYTES = 2 * 1024 * 1024; +const REQUEST_TIMEOUT_MS = 10_000; + +function ipv6ToBigInt(ip: string): bigint { + let full = ip.toLowerCase(); + if (full.includes("%")) full = full.slice(0, full.indexOf("%")); + + const sides = full.split("::"); + let groups: string[]; + if (sides.length === 2) { + const left = sides[0] ? sides[0].split(":") : []; + const right = sides[1] ? sides[1].split(":") : []; + const fill = 8 - left.length - right.length; + groups = [...left, ...Array(Math.max(0, fill)).fill("0"), ...right]; + } else { + groups = full.split(":"); + } + + if (groups.length > 0 && groups[groups.length - 1]!.includes(".")) { + const v4 = groups + .pop()! + .split(".") + .map((x) => Number(x)); + groups.push(((v4[0]! << 8) | v4[1]!).toString(16)); + groups.push(((v4[2]! << 8) | v4[3]!).toString(16)); + } + + while (groups.length < 8) groups.push("0"); + return groups + .slice(0, 8) + .reduce((acc, g) => (acc << 16n) + BigInt(parseInt(g || "0", 16)), 0n); +} + +function isBlockedIp(ip: string): boolean { + try { + if (net.isIPv4(ip)) { + const parts = ip.split(".").map((x) => Number(x)); + const a = parts[0]; + const b = parts[1]; + if (a === undefined || b === undefined) return true; + // 0.0.0.0/8 + if (a === 0) return true; + // 10.0.0.0/8 + if (a === 10) return true; + // 127.0.0.0/8 + if (a === 127) return true; + // 169.254.0.0/16 link-local / metadata + if (a === 169 && b === 254) return true; + // 172.16.0.0/12 + if (a === 172 && b >= 16 && b <= 31) return true; + // 192.168.0.0/16 + if (a === 192 && b === 168) return true; + // 100.64.0.0/10 CGNAT + if (a === 100 && b >= 64 && b <= 127) return true; + // 198.18.0.0/15 benchmark + if (a === 198 && (b === 18 || b === 19)) return true; + // multicast + reserved + if (a >= 224) return true; + return false; + } + + if (net.isIPv6(ip)) { + const n = ipv6ToBigInt(ip); + + if (n === 0n || n === 1n) return true; + + if (n >> 32n === 0xffffn) { + // IPv4-mapped ::ffff:0:0/96 + const v4 = Number(n & 0xffffffffn); + const a = (v4 >>> 24) & 0xff; + const b = (v4 >>> 16) & 0xff; + const c = (v4 >>> 8) & 0xff; + const d = v4 & 0xff; + return isBlockedIp(`${a}.${b}.${c}.${d}`); + } + + // fe80::/10 link-local + if (n >> 118n === 0x3fan) return true; + // fc00::/7 unique local + if (n >> 121n === 0x7en) return true; + // ff00::/8 multicast + if (n >> 120n === 0xffn) return true; + // 2001:db8::/32 documentation + if (n >> 96n === 0x20010db8n) return true; + + return false; + } + + return true; + } catch { + return true; + } +} + +// Rejects IP literals written in decimal/octal/hex form (e.g. http://2130706433/, +// http://0x7f000001/, http://0177.0.0.1/) which some resolvers/libc will happily +// resolve to a normal dotted-quad, bypassing naive hostname checks. +function isSuspiciousNumericHost(host: string): boolean { + if (net.isIP(host)) return false; + if (/^0x[0-9a-f]+$/i.test(host)) return true; + if (/^\d+$/.test(host)) return true; + if (/^0[0-7]+(\.[0-9]+){0,3}$/.test(host)) return true; + return false; +} + +function stripBrackets(hostname: string): string { + return hostname.replace(/^\[/, "").replace(/\]$/, ""); +} + +interface SafeUrlResult { + url: URL; + host: string; + pinnedIp: string; + family: 4 | 6; +} + +async function assertSafeUrl(raw: string): Promise { + let u: URL; + try { + u = new URL(raw); + } catch { + throw new Error( + "Please provide a valid URL starting with http:// or https://", + ); + } + + if (u.protocol !== "http:" && u.protocol !== "https:") { + throw new Error("Only http:// and https:// URLs are allowed."); + } + if (u.username || u.password) { + throw new Error("URLs with embedded credentials are not allowed."); + } + + const host = stripBrackets(u.hostname); + const hostLower = host.toLowerCase(); + if ( + hostLower === "localhost" || + hostLower.endsWith(".localhost") || + hostLower.endsWith(".local") || + hostLower === "metadata.google.internal" + ) { + throw new Error("Requests to private or local addresses are not allowed."); + } + + if (isSuspiciousNumericHost(host)) { + throw new Error( + "IP addresses must be in standard dotted or bracketed form.", + ); + } + + if (net.isIP(host)) { + if (isBlockedIp(host)) { + throw new Error( + "Requests to private or local addresses are not allowed.", + ); + } + return { + url: u, + host, + pinnedIp: host, + family: net.isIPv6(host) ? 6 : 4, + }; + } + + const records = await dns.lookup(host, { all: true, verbatim: true }); + if (!records.length || records.some((r) => isBlockedIp(r.address))) { + throw new Error("Requests to private or local addresses are not allowed."); + } + + const chosen = records[0]!; + return { + url: u, + host, + pinnedIp: chosen.address, + family: chosen.family === 6 ? 6 : 4, + }; +} + +function pinnedLookup(pinnedIp: string, family: 4 | 6) { + return ( + _hostname: string, + options: unknown, + callback: ( + err: NodeJS.ErrnoException | null, + address: string, + family: number, + ) => void, + ) => { + callback(null, pinnedIp, family); + }; +} + +async function readBodyWithLimit( + res: Awaited>, + maxBytes: number, +): Promise<{ text: string; truncated: boolean }> { + const reader = res.body; + if (!reader) return { text: "", truncated: false }; + + let total = 0; + let truncated = false; + const chunks: Buffer[] = []; + + for await (const chunk of reader as AsyncIterable) { + total += chunk.length; + if (total > maxBytes) { + truncated = true; + const remaining = maxBytes - (total - chunk.length); + if (remaining > 0) chunks.push(chunk.subarray(0, remaining)); + break; + } + chunks.push(chunk); + } + + return { text: Buffer.concat(chunks).toString("utf8"), truncated }; +} + export default { name: "http", description: "Make HTTP requests and inspect responses", @@ -33,70 +254,103 @@ export default { const start = Date.now(); - const safe = (input: string, limit = 900) => { - if (typeof input !== "string") { - input = JSON.stringify(input, null, 2); - } - return input.length > limit - ? input.slice(0, limit) + "\n... (truncated)" - : input; + const safe = (input: unknown, limit = 900): string => { + const str = + typeof input === "string" ? input : JSON.stringify(input, null, 2); + return str.length > limit + ? str.slice(0, limit) + "\n... (truncated)" + : str; }; + const errorEmbed = (title: string, description: string) => + message.reply({ + embeds: [ + new EmbedBuilder() + .setTitle(title) + .setDescription(description) + .setColor(0xd21872), + ], + }); + try { - if (!url || !/^https?:\/\//.test(url)) { - return message.reply({ - embeds: [ - new EmbedBuilder() - .setTitle("❌ Invalid URL") - .setDescription( - "Please provide a valid URL starting with http:// or https://", - ) - .setColor(0xd21872), - ], - }); + if (!url) { + return errorEmbed( + "❌ Invalid URL", + "Please provide a valid URL starting with http:// or https://", + ); } - if (!validMethods.includes(method)) { return message.reply(`Invalid method: ${validMethods.join(", ")}`); } - let body; + let body: string | undefined; if (rawBody?.trim()) { try { body = JSON.stringify(JSON.parse(rawBody)); } catch { - return message.reply({ - embeds: [ - new EmbedBuilder() - .setTitle("❌ Invalid JSON body") - .setDescription( - "Please provide valid JSON for the request body.", - ) - .setColor(0xd21872), - ], - }); + return errorEmbed( + "❌ Invalid JSON body", + "Please provide valid JSON for the request body.", + ); } } - const res = await fetch(url, { - method, - headers: body ? { "Content-Type": "application/json" } : undefined, - body, - }); + let currentUrl = url; + let finalUrl: URL | undefined; + let res: Awaited> | undefined; - const duration = Date.now() - start; + for (let hop = 0; hop <= MAX_REDIRECTS; hop++) { + let safeUrl: SafeUrlResult; + try { + safeUrl = await assertSafeUrl(currentUrl); + } catch (e) { + return errorEmbed( + "❌ URL not allowed", + e instanceof Error ? e.message : "The provided URL is not allowed.", + ); + } - const text = await res.text(); + res = await fetch(safeUrl.url.href, { + method, + headers: body ? { "Content-Type": "application/json" } : undefined, + body: hop === 0 ? body : undefined, + redirect: "manual", + signal: AbortSignal.timeout( + REQUEST_TIMEOUT_MS, + ) as unknown as import("node-fetch").RequestInit["signal"], + // node-fetch forwards unknown options straight through to http(s).request, + // which supports a custom `lookup`. This pins the connection to the exact IP we just validated, closing the DNS-rebinding TOCTOU window. + lookup: pinnedLookup(safeUrl.pinnedIp, safeUrl.family), + } as import("node-fetch").RequestInit); - let parsed; + finalUrl = safeUrl.url; + + const isRedirect = res.status >= 300 && res.status < 400; + const location = res.headers.get("location"); + if (isRedirect && location && hop < MAX_REDIRECTS) { + currentUrl = new URL(location, safeUrl.url).href; + continue; + } + break; + } + + if (!res || !finalUrl) { + return errorEmbed( + "❌ Request failed", + "The request failed unexpectedly.", + ); + } + + const duration = Date.now() - start; + const { text, truncated } = await readBodyWithLimit(res, MAX_BODY_BYTES); + + let parsed: unknown; try { parsed = JSON.parse(text); } catch { parsed = text; } - const headers = Object.fromEntries(res.headers.entries()); - const filteredHeaders: Record = {}; for (const [key, value] of res.headers.entries()) { if (importantHeaders.includes(key.toLowerCase())) { @@ -104,12 +358,18 @@ export default { } } + const bodyValue = + "```json\n" + + safe(parsed) + + (truncated ? "\n... (response body truncated, exceeded 2MB)" : "") + + "\n```"; + const embed = new EmbedBuilder() .setTitle(`${res.status} ${res.statusText || "Status"}`) .addFields( { name: "Request", - value: `${method} ${url}`.slice(0, 1024), + value: `${method} ${finalUrl.href}`.slice(0, 1024), }, { name: "Latency", @@ -126,7 +386,7 @@ export default { }, { name: "Status Type", - value: res.statusText, + value: res.statusText || "Unknown", inline: true, }, { @@ -138,7 +398,7 @@ export default { }, { name: "Response Body", - value: "```json\n" + safe(parsed) + "\n```", + value: bodyValue, }, ) .setColor( @@ -155,14 +415,10 @@ export default { return message.reply({ embeds: [embed] }); } catch (err) { console.error(err); - return message.reply({ - embeds: [ - new EmbedBuilder() - .setTitle("❌ Request failed") - .setDescription("The request failed (network or timeout error).") - .setColor(0xd21872), - ], - }); + return errorEmbed( + "❌ Request failed", + "The request failed (network or timeout error).", + ); } }, };