178 lines
5.9 KiB
TypeScript
178 lines
5.9 KiB
TypeScript
import { isIP } from "node:net";
|
|
import ipaddr from "ipaddr.js";
|
|
|
|
export type IpClassification = "unknown" | "clear" | "vpn" | "proxy" | "hosting" | "tor";
|
|
|
|
export interface IpLocation {
|
|
city: string | null;
|
|
region: string | null;
|
|
country: string | null;
|
|
countryCode: string | null;
|
|
latitude: number | null;
|
|
longitude: number | null;
|
|
timezone: string | null;
|
|
}
|
|
|
|
export interface IpNetwork {
|
|
asn: string | null;
|
|
provider: string | null;
|
|
connectionType?: string | null;
|
|
proxy?: boolean | null;
|
|
}
|
|
|
|
export interface IpIntelligenceResult {
|
|
classification: IpClassification;
|
|
provider: string | null;
|
|
riskScore?: number | null;
|
|
location?: IpLocation;
|
|
network?: IpNetwork;
|
|
rawResponse?: Record<string, unknown>;
|
|
lookupError?: boolean;
|
|
}
|
|
|
|
export interface IpIntelligenceProvider {
|
|
classify(ipAddress: string): Promise<IpIntelligenceResult>;
|
|
}
|
|
|
|
export class NoopIpIntelligenceProvider implements IpIntelligenceProvider {
|
|
async classify(_ipAddress: string): Promise<IpIntelligenceResult> {
|
|
return { classification: "unknown", provider: null };
|
|
}
|
|
}
|
|
|
|
function stringValue(value: unknown) {
|
|
return typeof value === "string" && value.trim() ? value.trim() : null;
|
|
}
|
|
|
|
function numberValue(value: unknown) {
|
|
if (typeof value === "number" && Number.isFinite(value)) return value;
|
|
if (typeof value === "string" && value.trim() && Number.isFinite(Number(value))) return Number(value);
|
|
return null;
|
|
}
|
|
|
|
function proxyClassification(proxy: unknown, type: unknown): IpClassification {
|
|
const normalizedProxy = String(proxy).toLowerCase();
|
|
if (normalizedProxy === "no") return "clear";
|
|
if (normalizedProxy !== "yes") return "unknown";
|
|
const normalizedType = String(type ?? "").toLowerCase();
|
|
if (normalizedType.includes("tor")) return "tor";
|
|
if (normalizedType.includes("vpn")) return "vpn";
|
|
if (normalizedType.includes("hosting") || normalizedType.includes("server")) return "hosting";
|
|
return "proxy";
|
|
}
|
|
|
|
export class ProxyCheckProvider implements IpIntelligenceProvider {
|
|
private readonly apiKey: string;
|
|
private readonly request: typeof fetch;
|
|
private readonly timeoutMs: number;
|
|
|
|
constructor(options: { apiKey: string; request?: typeof fetch; timeoutMs?: number }) {
|
|
this.apiKey = options.apiKey;
|
|
this.request = options.request ?? fetch;
|
|
this.timeoutMs = options.timeoutMs ?? 2_500;
|
|
}
|
|
|
|
async classify(ipAddress: string): Promise<IpIntelligenceResult> {
|
|
if (!this.apiKey) throw new Error("ProxyCheck API key is not configured");
|
|
if (!isPublicIp(ipAddress)) throw new Error("ProxyCheck requires a public IP address");
|
|
|
|
const url = new URL(`https://proxycheck.io/v2/${encodeURIComponent(ipAddress)}`);
|
|
url.searchParams.set("key", this.apiKey);
|
|
url.searchParams.set("vpn", "1");
|
|
url.searchParams.set("asn", "1");
|
|
url.searchParams.set("risk", "1");
|
|
|
|
const response = await this.request(url.toString(), {
|
|
headers: { accept: "application/json" },
|
|
signal: AbortSignal.timeout(this.timeoutMs),
|
|
});
|
|
if (!response.ok) throw new Error(`ProxyCheck lookup failed (${response.status})`);
|
|
|
|
const payload: unknown = await response.json();
|
|
if (!payload || typeof payload !== "object" || Array.isArray(payload)) {
|
|
throw new Error("ProxyCheck lookup failed: invalid response");
|
|
}
|
|
|
|
const root = payload as Record<string, unknown>;
|
|
const details = root[ipAddress];
|
|
if (root.status !== "ok" || !details || typeof details !== "object" || Array.isArray(details)) {
|
|
throw new Error("ProxyCheck lookup failed: service rejected the request");
|
|
}
|
|
|
|
const data = details as Record<string, unknown>;
|
|
return {
|
|
classification: proxyClassification(data.proxy, data.type),
|
|
provider: "proxycheck",
|
|
riskScore: numberValue(data.risk),
|
|
location: {
|
|
city: stringValue(data.city),
|
|
region: stringValue(data.region),
|
|
country: stringValue(data.country),
|
|
countryCode: stringValue(data.isocode),
|
|
latitude: numberValue(data.latitude),
|
|
longitude: numberValue(data.longitude),
|
|
timezone: stringValue(data.timezone),
|
|
},
|
|
network: {
|
|
asn: stringValue(data.asn),
|
|
provider: stringValue(data.provider) ?? stringValue(data.organisation),
|
|
connectionType: stringValue(data.type),
|
|
proxy: String(data.proxy).toLowerCase() === "yes"
|
|
? true
|
|
: String(data.proxy).toLowerCase() === "no" ? false : null,
|
|
},
|
|
rawResponse: root,
|
|
};
|
|
}
|
|
}
|
|
|
|
export function addressGroup(ipAddress: string) {
|
|
const hostAddress = ipAddress.split("/", 1)[0] ?? ipAddress;
|
|
if (!isIP(hostAddress)) return ipAddress;
|
|
|
|
let address = ipaddr.parse(hostAddress);
|
|
if (address instanceof ipaddr.IPv6 && address.isIPv4MappedAddress()) {
|
|
address = address.toIPv4Address();
|
|
}
|
|
|
|
const prefixLength = address.kind() === "ipv4" ? 24 : 64;
|
|
const bytes = address.toByteArray();
|
|
for (let bit = prefixLength; bit < bytes.length * 8; bit += 1) {
|
|
const byteIndex = Math.floor(bit / 8);
|
|
const bitMask = 1 << (7 - (bit % 8));
|
|
bytes[byteIndex] = (bytes[byteIndex] ?? 0) & ~bitMask;
|
|
}
|
|
|
|
return `${ipaddr.fromByteArray(bytes).toString()}/${prefixLength}`;
|
|
}
|
|
|
|
export function isPublicIp(ipAddress: string) {
|
|
if (!isIP(ipAddress)) return false;
|
|
|
|
let address = ipaddr.parse(ipAddress);
|
|
if (address instanceof ipaddr.IPv6 && address.isIPv4MappedAddress()) {
|
|
address = address.toIPv4Address();
|
|
}
|
|
return address.range() === "unicast";
|
|
}
|
|
|
|
export function shouldBlockIpClassification(
|
|
classification: IpClassification,
|
|
blockHosting: boolean,
|
|
) {
|
|
if (classification === "unknown") return true;
|
|
if (["vpn", "proxy", "tor"].includes(classification)) return true;
|
|
return classification === "hosting" && blockHosting;
|
|
}
|
|
|
|
export function getClientIp(headers: Headers, trustProxy: boolean) {
|
|
if (!trustProxy) return null;
|
|
|
|
const candidate =
|
|
headers.get("x-forwarded-for")?.split(",")[0]?.trim() ||
|
|
headers.get("x-real-ip")?.trim() ||
|
|
null;
|
|
|
|
return candidate && isIP(candidate) ? candidate : null;
|
|
}
|