feat(network): add ProxyCheck IP intelligence
This commit is contained in:
@@ -5,5 +5,6 @@
|
||||
"type": "module",
|
||||
"exports": { ".": "./src/index.ts" },
|
||||
"scripts": { "test": "vitest run", "typecheck": "tsc --noEmit" },
|
||||
"dependencies": { "ipaddr.js": "^2.2.0" },
|
||||
"devDependencies": { "@types/node": "^25.0.3", "typescript": "^5.9.3", "vitest": "^4.1.0" }
|
||||
}
|
||||
|
||||
@@ -1,11 +1,31 @@
|
||||
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;
|
||||
}
|
||||
|
||||
export interface IpIntelligenceResult {
|
||||
classification: IpClassification;
|
||||
provider: string | null;
|
||||
riskScore?: number | null;
|
||||
location?: IpLocation;
|
||||
network?: IpNetwork;
|
||||
rawResponse?: Record<string, unknown>;
|
||||
lookupError?: boolean;
|
||||
}
|
||||
|
||||
export interface IpIntelligenceProvider {
|
||||
@@ -18,6 +38,105 @@ export class NoopIpIntelligenceProvider implements IpIntelligenceProvider {
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
if (String(proxy).toLowerCase() !== "yes") return "clear";
|
||||
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),
|
||||
},
|
||||
rawResponse: root,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { isPublicIp, ProxyCheckProvider, shouldBlockIpClassification } from "../src/index";
|
||||
|
||||
const ipAddress = "8.8.8.8";
|
||||
|
||||
function response(details: Record<string, unknown>) {
|
||||
return new Response(JSON.stringify({ status: "ok", [ipAddress]: details }), {
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
describe("ProxyCheck.io intelligence", () => {
|
||||
it("maps geolocation and a clear network response", async () => {
|
||||
const request = vi.fn<typeof fetch>().mockResolvedValue(response({
|
||||
proxy: "no",
|
||||
type: "Business",
|
||||
risk: 2,
|
||||
city: "Mountain View",
|
||||
region: "California",
|
||||
country: "United States",
|
||||
isocode: "US",
|
||||
latitude: 37.4056,
|
||||
longitude: -122.0775,
|
||||
timezone: "America/Los_Angeles",
|
||||
asn: "AS15169",
|
||||
provider: "Google LLC",
|
||||
}));
|
||||
|
||||
const result = await new ProxyCheckProvider({ apiKey: "secret", request }).classify(ipAddress);
|
||||
|
||||
expect(result).toMatchObject({
|
||||
classification: "clear",
|
||||
provider: "proxycheck",
|
||||
riskScore: 2,
|
||||
location: {
|
||||
city: "Mountain View",
|
||||
region: "California",
|
||||
country: "United States",
|
||||
countryCode: "US",
|
||||
latitude: 37.4056,
|
||||
longitude: -122.0775,
|
||||
timezone: "America/Los_Angeles",
|
||||
},
|
||||
network: { asn: "AS15169", provider: "Google LLC" },
|
||||
});
|
||||
expect(request).toHaveBeenCalledWith(
|
||||
expect.stringContaining("https://proxycheck.io/v2/8.8.8.8"),
|
||||
expect.objectContaining({ signal: expect.any(AbortSignal) }),
|
||||
);
|
||||
});
|
||||
|
||||
it("maps VPN and Tor responses to explicit classifications", async () => {
|
||||
const vpnRequest = vi.fn<typeof fetch>().mockResolvedValue(response({ proxy: "yes", type: "VPN" }));
|
||||
const torRequest = vi.fn<typeof fetch>().mockResolvedValue(response({ proxy: "yes", type: "TOR" }));
|
||||
|
||||
await expect(new ProxyCheckProvider({ apiKey: "secret", request: vpnRequest }).classify(ipAddress))
|
||||
.resolves.toMatchObject({ classification: "vpn" });
|
||||
await expect(new ProxyCheckProvider({ apiKey: "secret", request: torRequest }).classify(ipAddress))
|
||||
.resolves.toMatchObject({ classification: "tor" });
|
||||
});
|
||||
|
||||
it("fails explicitly when ProxyCheck does not return usable data", async () => {
|
||||
const request = vi.fn<typeof fetch>().mockResolvedValue(
|
||||
new Response(JSON.stringify({ status: "denied", message: "Invalid API key" }), { status: 200 }),
|
||||
);
|
||||
|
||||
await expect(new ProxyCheckProvider({ apiKey: "secret", request }).classify(ipAddress))
|
||||
.rejects.toThrow("ProxyCheck lookup failed");
|
||||
});
|
||||
});
|
||||
|
||||
describe("account-addition policy", () => {
|
||||
it("fails closed and blocks anonymizing networks", () => {
|
||||
expect(shouldBlockIpClassification("unknown", false)).toBe(true);
|
||||
expect(shouldBlockIpClassification("vpn", false)).toBe(true);
|
||||
expect(shouldBlockIpClassification("proxy", false)).toBe(true);
|
||||
expect(shouldBlockIpClassification("tor", false)).toBe(true);
|
||||
expect(shouldBlockIpClassification("clear", true)).toBe(false);
|
||||
expect(shouldBlockIpClassification("hosting", false)).toBe(false);
|
||||
expect(shouldBlockIpClassification("hosting", true)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("public IP filtering", () => {
|
||||
it("does not send private, loopback, documentation, or mapped-private addresses", () => {
|
||||
expect(isPublicIp("8.8.8.8")).toBe(true);
|
||||
expect(isPublicIp("10.0.0.1")).toBe(false);
|
||||
expect(isPublicIp("127.0.0.1")).toBe(false);
|
||||
expect(isPublicIp("203.0.113.10")).toBe(false);
|
||||
expect(isPublicIp("::ffff:192.168.1.1")).toBe(false);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user