Skip to content
Closed
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
155 changes: 148 additions & 7 deletions src/commands/tools/http.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,130 @@
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";

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;
if (a === 0) return true; // 0.0.0.0/8
if (a === 10) return true; // 10.0.0.0/8
if (a === 127) return true; // 127.0.0.0/8
if (a === 169 && b === 254) return true; // 169.254.0.0/16 link-local / metadata
if (a === 172 && b >= 16 && b <= 31) return true; // 172.16.0.0/12
if (a === 192 && b === 168) return true; // 192.168.0.0/16
if (a === 100 && b >= 64 && b <= 127) return true; // 100.64.0.0/10 CGNAT
if (a === 198 && (b === 18 || b === 19)) return true; // 198.18.0.0/15 benchmark
if (a >= 224) return true; // multicast + reserved
return false;
}

if (net.isIPv6(ip)) {
const n = ipv6ToBigInt(ip);

// ::/128 unspecified, ::1/128 loopback
if (n === 0n || n === 1n) return true;

// ::ffff:0:0/96 IPv4-mapped
if (n >> 32n === 0xffffn) {
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;
}
}

async function assertSafeUrl(raw: string): Promise<URL> {
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 = 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 (net.isIP(host)) {
if (isBlockedIp(host)) {
throw new Error("Requests to private or local addresses are not allowed.");
}
} else {
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.");
}
}

return u;
}

export default {
name: "http",
description: "Make HTTP requests and inspect responses",
Expand Down Expand Up @@ -43,7 +166,7 @@ export default {
};

try {
if (!url || !/^https?:\/\//.test(url)) {
if (!url) {
return message.reply({
embeds: [
new EmbedBuilder()
Expand All @@ -56,6 +179,24 @@ export default {
});
}

let safeUrl: URL;
try {
safeUrl = await assertSafeUrl(url);
} catch (e) {
return message.reply({
embeds: [
new EmbedBuilder()
.setTitle("❌ URL not allowed")
.setDescription(
e instanceof Error
? e.message
: "The provided URL is not allowed.",
)
.setColor(0xd21872),
],
});
}

if (!validMethods.includes(method)) {
return message.reply(`Invalid method: ${validMethods.join(", ")}`);
}
Expand All @@ -78,10 +219,12 @@ export default {
}
}

const res = await fetch(url, {
const res = await fetch(safeUrl.href, {
method,
headers: body ? { "Content-Type": "application/json" } : undefined,
body,
redirect: "manual",
signal: AbortSignal.timeout(10_000),
});

const duration = Date.now() - start;
Expand All @@ -95,8 +238,6 @@ export default {
parsed = text;
}

const headers = Object.fromEntries(res.headers.entries());

const filteredHeaders: Record<string, string> = {};
for (const [key, value] of res.headers.entries()) {
if (importantHeaders.includes(key.toLowerCase())) {
Expand All @@ -109,7 +250,7 @@ export default {
.addFields(
{
name: "Request",
value: `${method} ${url}`.slice(0, 1024),
value: `${method} ${safeUrl.href}`.slice(0, 1024),
},
{
name: "Latency",
Expand All @@ -126,7 +267,7 @@ export default {
},
{
name: "Status Type",
value: res.statusText,
value: res.statusText || "Unknown",
inline: true,
},
{
Expand Down
Loading