feat(network): add ProxyCheck IP intelligence

This commit is contained in:
dmg
2026-08-01 14:32:54 -04:00
parent 10554eeaff
commit 40abab7abc
18 changed files with 539 additions and 26 deletions
+5 -3
View File
@@ -19,6 +19,8 @@ DISCORD_INVITE_URL=https://discord.gg/your-invite
# Trust forwarding headers only when your reverse proxy overwrites them
TRUST_PROXY=false
# Optional VPN intelligence provider (deferred for v1)
IP_INTELLIGENCE_PROVIDER=none
IP_INTELLIGENCE_API_KEY=
# ProxyCheck.io geolocation and VPN/proxy detection
IP_INTELLIGENCE_PROVIDER=proxycheck
PROXYCHECK_API_KEY=
IP_INTELLIGENCE_CACHE_HOURS=48
BLOCK_HOSTING_IPS=false
+3 -1
View File
@@ -28,6 +28,8 @@ npm run dev
Set `DISCORD_GUILD_ID` and `DISCORD_INVITE_URL` in `.env.local` so unauthenticated visitors can reach the Discord server. The HTTPS invite is the most reliable way to open Discord or join; the landing page also offers a `discord://` app link.
Set `IP_INTELLIGENCE_PROVIDER=proxycheck`, add `PROXYCHECK_API_KEY`, and configure trusted proxy handling before allowing users to add accounts. Portal and game logins continue if lookup fails, but user account additions fail closed when an address is unknown, VPN, proxy, or Tor.
Open `http://localhost:3000`.
## Validation
@@ -68,6 +70,6 @@ The token is displayed once and stored only as a SHA-256 hash.
- discord.js bot with `/register` and `/account`
- Java Edition online-mode accounts only
- Velocity admission checks are fail closed
- VPN detection is represented in the schema but may remain disabled in the first release until a provider is selected
- ProxyCheck.io geolocation and VPN/proxy/Tor detection with a 48-hour PostgreSQL cache
See [`docs/architecture.md`](docs/architecture.md) for trust boundaries and service responsibilities, and [`docs/security-review.md`](docs/security-review.md) for implemented controls and production requirements.
+16
View File
@@ -7,6 +7,7 @@ import { redirect } from "next/navigation";
import { recordUserEvent } from "@/lib/audit";
import { db } from "@/lib/database";
import { requireCurrentUser } from "@/lib/auth/user-session";
import { checkAccountAdditionNetwork, toAuditIpData } from "@/lib/ip-intelligence";
const USERNAME_PATTERN = /^[A-Za-z0-9_]{3,16}$/;
@@ -27,6 +28,21 @@ export async function addMinecraftAccount(formData: FormData) {
const confirmed = formData.get("confirmUnverified") === "yes";
if (!USERNAME_PATTERN.test(requestedUsername)) redirect("/account?error=invalid-username");
const network = await checkAccountAdditionNetwork();
if (!network.allowed) {
await recordUserEvent(
user,
network.reason === "blocked"
? "games.minecraft.account-manager.network.vpn-blocked"
: "games.minecraft.account-manager.network.classification-unavailable",
{
attemptedUsername: requestedUsername,
ipIntelligence: network.intelligence ? toAuditIpData(network.intelligence) : null,
},
);
redirect(`/account?error=${network.reason === "blocked" ? "vpn-blocked" : "ip-check-unavailable"}`);
}
const profile = await lookupJavaProfile(requestedUsername);
if (!profile && !confirmed) redirect(`/account?unverified=${encodeURIComponent(requestedUsername)}`);
+17 -3
View File
@@ -1,9 +1,10 @@
import { formatDiscordNickname } from "@minecraft-account-manager/minecraft";
import { ipObservations, minecraftAccounts } from "@minecraft-account-manager/database";
import { ipIntelligence, ipObservations, minecraftAccounts } from "@minecraft-account-manager/database";
import { and, desc, eq, isNull } from "drizzle-orm";
import { logout } from "@/app/auth/actions";
import { db } from "@/lib/database";
import { requireCurrentUser } from "@/lib/auth/user-session";
import { intelligenceSummary } from "@/lib/event-ip-summary";
import {
addMinecraftAccount,
confirmDashboardNickname,
@@ -19,6 +20,8 @@ const errorMessages: Record<string, string> = {
"unknown-account": "That account is no longer available.",
"nickname-not-configured": "Discord nickname updates are not configured.",
"nickname-update-failed": "Discord rejected the nickname update. An admin may need to adjust bot permissions.",
"vpn-blocked": "Turn off your VPN, proxy, or Tor connection before adding a Minecraft account.",
"ip-check-unavailable": "We could not verify your network, so account addition is temporarily blocked.",
};
export default async function AccountPage({
@@ -35,8 +38,16 @@ export default async function AccountPage({
.where(and(eq(minecraftAccounts.userId, user.id), isNull(minecraftAccounts.deletedAt)))
.orderBy(desc(minecraftAccounts.isPrimary), minecraftAccounts.username),
db
.select()
.select({
id: ipObservations.id,
ipAddress: ipObservations.ipAddress,
source: ipObservations.source,
classification: ipObservations.classification,
observedAt: ipObservations.observedAt,
intelligence: ipIntelligence.rawResponse,
})
.from(ipObservations)
.leftJoin(ipIntelligence, eq(ipIntelligence.ipAddress, ipObservations.ipAddress))
.where(eq(ipObservations.userId, user.id))
.orderBy(desc(ipObservations.observedAt))
.limit(20),
@@ -126,7 +137,10 @@ export default async function AccountPage({
<h2 className="mt-2 border-b border-line pb-4 font-display text-3xl font-black uppercase">Access addresses</h2>
{observations.length ? (
<div className="divide-y divide-line font-mono text-xs">
{observations.map((observation) => <div className="grid grid-cols-[1fr_auto] gap-4 py-4" key={observation.id}><span>{observation.ipAddress}</span><span className="text-muted">{observation.source} · {observation.observedAt.toISOString()}</span></div>)}
{observations.map((observation) => {
const summary = intelligenceSummary(observation.intelligence);
return <div className="grid grid-cols-[1fr_auto] gap-4 py-4" key={observation.id}><div><p>{observation.ipAddress}</p><p className="mt-1 text-[10px] text-muted">{summary.location ?? "Location unavailable"} · {summary.classification ?? observation.classification}</p></div><span className="text-muted">{observation.source} · {observation.observedAt.toISOString()}</span></div>;
})}
</div>
) : <p className="py-6 text-sm text-muted">No web or game access addresses have been recorded yet.</p>}
</section>
@@ -1,6 +1,7 @@
import { events } from "@minecraft-account-manager/database";
import { desc } from "drizzle-orm";
import { db } from "@/lib/database";
import { eventIpSummary } from "@/lib/event-ip-summary";
export default async function EventsPage() {
const recentEvents = await db.select().from(events).orderBy(desc(events.time)).limit(100);
@@ -12,18 +13,22 @@ export default async function EventsPage() {
<div className="mt-10 overflow-x-auto border border-line bg-panel shadow-[8px_8px_0_var(--color-shadow)]">
<table className="w-full min-w-[760px] border-collapse text-left">
<thead className="border-b border-line font-mono text-[10px] uppercase tracking-widest text-muted">
<tr><th className="p-4">Time</th><th className="p-4">Type</th><th className="p-4">Subject</th><th className="p-4">IP</th></tr>
<tr><th className="p-4">Time</th><th className="p-4">Type</th><th className="p-4">Subject</th><th className="p-4">IP</th><th className="p-4">Network</th></tr>
</thead>
<tbody className="divide-y divide-line text-xs">
{recentEvents.map((event) => (
{recentEvents.map((event) => {
const ip = eventIpSummary(event.data);
return (
<tr key={event.id}>
<td className="whitespace-nowrap p-4 font-mono text-muted">{event.time.toISOString()}</td>
<td className="p-4 font-mono font-bold">{event.type}</td>
<td className="p-4 font-mono text-muted">{event.subject ?? "—"}</td>
<td className="p-4 font-mono text-muted">{event.ipAddress ?? "—"}</td>
<td className="p-4"><div className="font-mono text-[10px] font-bold uppercase">{ip.classification ?? "—"}</div><div className="mt-1 text-xs text-muted">{ip.location ?? "Location unavailable"}</div></td>
</tr>
))}
{!recentEvents.length && <tr><td className="p-8 text-muted" colSpan={4}>No events have been recorded.</td></tr>}
);
})}
{!recentEvents.length && <tr><td className="p-8 text-muted" colSpan={5}>No events have been recorded.</td></tr>}
</tbody>
</table>
</div>
@@ -4,6 +4,7 @@ import { and, desc, eq, isNull, or } from "drizzle-orm";
import Link from "next/link";
import { notFound } from "next/navigation";
import { db } from "@/lib/database";
import { eventIpSummary } from "@/lib/event-ip-summary";
import {
addUserMinecraftAccount,
removeUserMinecraftAccount,
@@ -140,7 +141,10 @@ export default async function AdminUserPage({
<p className="font-mono text-[9px] font-bold uppercase tracking-widest text-muted">Audit trail</p>
<h2 className="mt-2 border-b border-line pb-4 font-display text-3xl font-black uppercase">Recent events</h2>
<div className="divide-y divide-line">
{recentEvents.map((event) => <div className="grid gap-2 py-4 sm:grid-cols-[1fr_auto]" key={event.id}><span className="font-mono text-xs font-bold">{event.type}</span><span className="font-mono text-[9px] text-muted">{event.time.toISOString()}</span></div>)}
{recentEvents.map((event) => {
const ip = eventIpSummary(event.data);
return <div className="grid gap-2 py-4 sm:grid-cols-[1fr_auto]" key={event.id}><div><p className="font-mono text-xs font-bold">{event.type}</p>{(ip.location || ip.classification) && <p className="mt-1 text-xs text-muted">{ip.location ?? "Location unavailable"} · {ip.classification ?? "unknown"}</p>}</div><span className="font-mono text-[9px] text-muted">{event.time.toISOString()}</span></div>;
})}
{!recentEvents.length && <p className="py-6 text-sm text-muted">No events recorded for this user.</p>}
</div>
</section>
+38 -3
View File
@@ -12,6 +12,7 @@ import {
import { and, eq, isNull, lt, sql } from "drizzle-orm";
import { NextResponse } from "next/server";
import { db } from "@/lib/database";
import { getIpIntelligence, toAuditIpData } from "@/lib/ip-intelligence";
const MAX_CLOCK_SKEW_MS = 45_000;
const DEFAULT_DENIAL_MESSAGE = "Please register your Minecraft account before joining.";
@@ -45,6 +46,35 @@ export async function POST(request: Request) {
const [settings] = await db.select().from(appSettings).where(eq(appSettings.id, "default")).limit(1);
const denialMessage = settings?.registrationMessage ?? DEFAULT_DENIAL_MESSAGE;
let [knownAccount] = await db
.select({ id: minecraftAccounts.id })
.from(minecraftAccounts)
.where(
and(
eq(minecraftAccounts.minecraftUuid, input.minecraftUuid),
isNull(minecraftAccounts.deletedAt),
),
)
.limit(1);
if (!knownAccount) {
[knownAccount] = await db
.select({ id: minecraftAccounts.id })
.from(minecraftAccounts)
.where(
and(
isNull(minecraftAccounts.minecraftUuid),
sql`lower(${minecraftAccounts.username}) = lower(${input.username})`,
isNull(minecraftAccounts.deletedAt),
),
)
.limit(1);
}
const intelligence = knownAccount
? await getIpIntelligence(input.ipAddress)
: { classification: "unknown" as const, provider: null };
const auditIpData = toAuditIpData(intelligence);
try {
const decision = await db.transaction(async (tx) => {
await tx.delete(pluginRequests).where(lt(pluginRequests.expiresAt, new Date()));
@@ -97,7 +127,11 @@ export async function POST(request: Request) {
type: "games.minecraft.account-manager.game.login.denied",
subject: `minecraft-account/${input.minecraftUuid}`,
time: occurredAt,
data: { username: input.username, reason: "not_registered" },
data: {
username: input.username,
reason: "not_registered",
ipIntelligence: auditIpData,
},
ipAddress: input.ipAddress,
correlationId: input.requestId,
});
@@ -106,7 +140,7 @@ export async function POST(request: Request) {
ipAddress: input.ipAddress,
minecraftUuid: input.minecraftUuid,
username: input.username,
classification: "unknown",
classification: intelligence.classification,
observedAt: occurredAt,
});
return { allowed: false as const, message: denialMessage };
@@ -145,7 +179,7 @@ export async function POST(request: Request) {
ipAddress: input.ipAddress,
minecraftUuid: input.minecraftUuid,
username: input.username,
classification: "unknown",
classification: intelligence.classification,
observedAt: occurredAt,
});
await tx.insert(events).values({
@@ -160,6 +194,7 @@ export async function POST(request: Request) {
minecraftUuid: input.minecraftUuid,
previousUsername: account.username === input.username ? null : account.username,
uuidBackfilled: account.minecraftUuid === null,
ipIntelligence: auditIpData,
},
ipAddress: input.ipAddress,
correlationId: input.requestId,
+7 -2
View File
@@ -4,6 +4,7 @@ import { getClientIp } from "@minecraft-account-manager/network";
import type { NextRequest } from "next/server";
import { NextResponse } from "next/server";
import { db } from "@/lib/database";
import { getIpIntelligence, toAuditIpData } from "@/lib/ip-intelligence";
export async function GET(request: NextRequest) {
const code = request.nextUrl.searchParams.get("code") ?? "";
@@ -13,6 +14,7 @@ export async function GET(request: NextRequest) {
repository: createAuthRepository(db),
});
const ipAddress = getClientIp(request.headers, process.env.TRUST_PROXY === "true");
const intelligence = ipAddress ? await getIpIntelligence(ipAddress) : null;
await Promise.all([
recordEvent(db, {
type: "games.minecraft.account-manager.auth.magic-link.consumed",
@@ -20,14 +22,17 @@ export async function GET(request: NextRequest) {
subject: `user/${result.user.id}`,
actorUserId: result.user.id,
ipAddress: ipAddress ?? undefined,
data: { isNewUser: result.isNewUser },
data: {
isNewUser: result.isNewUser,
ipIntelligence: intelligence ? toAuditIpData(intelligence) : null,
},
}),
ipAddress
? db.insert(ipObservations).values({
userId: result.user.id,
source: "web",
ipAddress,
classification: "unknown",
classification: intelligence?.classification ?? "unknown",
})
: Promise.resolve(),
]);
+16
View File
@@ -7,6 +7,7 @@ import { redirect } from "next/navigation";
import { recordUserEvent } from "@/lib/audit";
import { db } from "@/lib/database";
import { requireCurrentUser } from "@/lib/auth/user-session";
import { checkAccountAdditionNetwork, toAuditIpData } from "@/lib/ip-intelligence";
const USERNAME_PATTERN = /^[A-Za-z0-9_]{3,16}$/;
@@ -32,6 +33,21 @@ export async function addFirstMinecraftAccount(formData: FormData) {
redirect("/welcome/minecraft?error=invalid-format");
}
const network = await checkAccountAdditionNetwork();
if (!network.allowed) {
await recordUserEvent(
user,
network.reason === "blocked"
? "games.minecraft.account-manager.network.vpn-blocked"
: "games.minecraft.account-manager.network.classification-unavailable",
{
attemptedUsername: requestedUsername,
ipIntelligence: network.intelligence ? toAuditIpData(network.intelligence) : null,
},
);
redirect(`/welcome/minecraft?error=${network.reason === "blocked" ? "vpn-blocked" : "ip-check-unavailable"}`);
}
const profile = await lookupJavaProfile(requestedUsername);
if (!profile && !confirmed) {
redirect(`/welcome/minecraft?unverified=${encodeURIComponent(requestedUsername)}`);
@@ -5,6 +5,8 @@ import { addFirstMinecraftAccount } from "../actions";
const errors: Record<string, string> = {
"invalid-format": "Java usernames use 316 letters, numbers, or underscores.",
"already-registered": "That Minecraft account is already registered.",
"vpn-blocked": "Turn off your VPN, proxy, or Tor connection before adding a Minecraft account.",
"ip-check-unavailable": "We could not verify your network. Try again without a VPN or contact an administrator.",
};
export default async function MinecraftStepPage({
+27
View File
@@ -0,0 +1,27 @@
type UnknownMap = Record<string, unknown>;
function objectValue(value: unknown): UnknownMap | null {
return value && typeof value === "object" && !Array.isArray(value)
? value as UnknownMap
: null;
}
export function intelligenceSummary(value: unknown) {
const intelligence = objectValue(value);
const location = objectValue(intelligence?.location);
const locationLabel = [location?.city, location?.region, location?.countryCode ?? location?.country]
.filter((value): value is string => typeof value === "string" && value.length > 0)
.join(", ");
const classification = typeof intelligence?.classification === "string"
? intelligence.classification
: null;
return {
location: locationLabel || null,
classification,
};
}
export function eventIpSummary(data: UnknownMap) {
return intelligenceSummary(data.ipIntelligence);
}
+158
View File
@@ -0,0 +1,158 @@
import {
getClientIp,
isPublicIp,
NoopIpIntelligenceProvider,
ProxyCheckProvider,
shouldBlockIpClassification,
type IpClassification,
type IpIntelligenceProvider,
type IpIntelligenceResult,
} from "@minecraft-account-manager/network";
import { ipIntelligence } from "@minecraft-account-manager/database";
import { and, eq, gt } from "drizzle-orm";
import { headers } from "next/headers";
import { db } from "@/lib/database";
const classifications = new Set<IpClassification>([
"unknown",
"clear",
"vpn",
"proxy",
"hosting",
"tor",
]);
function configuredProvider(): IpIntelligenceProvider {
if (process.env.IP_INTELLIGENCE_PROVIDER?.trim().toLowerCase() === "proxycheck") {
return new ProxyCheckProvider({ apiKey: process.env.PROXYCHECK_API_KEY?.trim() ?? "" });
}
return new NoopIpIntelligenceProvider();
}
function cacheHours() {
const configured = Number(process.env.IP_INTELLIGENCE_CACHE_HOURS ?? "48");
return Number.isFinite(configured) && configured > 0 ? Math.min(configured, 720) : 48;
}
function serialize(result: IpIntelligenceResult) {
return JSON.parse(JSON.stringify(result)) as Record<string, unknown>;
}
function deserialize(value: Record<string, unknown> | null): IpIntelligenceResult | null {
if (!value || !classifications.has(value.classification as IpClassification)) return null;
return value as unknown as IpIntelligenceResult;
}
async function cacheResult(
ipAddress: string,
result: IpIntelligenceResult,
now: Date,
ttlMilliseconds: number,
) {
await db
.insert(ipIntelligence)
.values({
ipAddress,
classification: result.classification,
provider: result.provider,
rawResponse: serialize(result),
checkedAt: now,
expiresAt: new Date(now.getTime() + ttlMilliseconds),
})
.onConflictDoUpdate({
target: ipIntelligence.ipAddress,
set: {
classification: result.classification,
provider: result.provider,
rawResponse: serialize(result),
checkedAt: now,
expiresAt: new Date(now.getTime() + ttlMilliseconds),
updatedAt: now,
},
});
}
export async function getIpIntelligence(
ipAddress: string,
options: { now?: Date; forceRefresh?: boolean } = {},
): Promise<IpIntelligenceResult> {
if (!isPublicIp(ipAddress)) {
return { classification: "unknown", provider: null };
}
const now = options.now ?? new Date();
if (!options.forceRefresh) {
const [cached] = await db
.select({ rawResponse: ipIntelligence.rawResponse })
.from(ipIntelligence)
.where(andAddressIsFresh(ipAddress, now))
.limit(1);
const cachedResult = deserialize(cached?.rawResponse ?? null);
if (cachedResult) return cachedResult;
}
const provider = configuredProvider();
try {
const result = await provider.classify(ipAddress);
await cacheResult(ipAddress, result, now, cacheHours() * 60 * 60_000);
return result;
} catch {
const result: IpIntelligenceResult = {
classification: "unknown",
provider: process.env.IP_INTELLIGENCE_PROVIDER?.trim().toLowerCase() || null,
lookupError: true,
};
await cacheResult(ipAddress, result, now, 5 * 60_000).catch(() => undefined);
console.error("IP intelligence lookup failed");
return result;
}
}
function andAddressIsFresh(ipAddress: string, now: Date) {
return and(eq(ipIntelligence.ipAddress, ipAddress), gt(ipIntelligence.expiresAt, now));
}
export function toAuditIpData(result: IpIntelligenceResult) {
return {
classification: result.classification,
provider: result.provider,
riskScore: result.riskScore ?? null,
location: result.location ?? null,
network: result.network ?? null,
lookupError: result.lookupError ?? false,
};
}
export function shouldBlockAccountAddition(result: IpIntelligenceResult) {
return shouldBlockIpClassification(
result.classification,
process.env.BLOCK_HOSTING_IPS === "true",
);
}
export async function checkAccountAdditionNetwork() {
const requestHeaders = await headers();
const ipAddress = getClientIp(requestHeaders, process.env.TRUST_PROXY === "true");
if (!ipAddress) {
return {
allowed: false as const,
reason: "unavailable" as const,
ipAddress: null,
intelligence: null,
};
}
const intelligence = await getIpIntelligence(ipAddress);
if (shouldBlockAccountAddition(intelligence)) {
return {
allowed: false as const,
reason: intelligence.classification === "unknown"
? "unavailable" as const
: "blocked" as const,
ipAddress,
intelligence,
};
}
return { allowed: true as const, reason: null, ipAddress, intelligence };
}
+1 -1
View File
@@ -38,7 +38,7 @@ The decision is fail closed. Unknown players, invalid responses, expired request
## IP intelligence
IP observations and cached classifications are modeled independently from any provider. Until a provider is configured, addresses remain `unknown`; the application must not claim that VPN checks occurred. When enabled, account creation can require a `clear` classification and record denied attempts as events.
ProxyCheck.io supplies approximate city/region/country, coordinates, timezone, ASN, provider, risk, and VPN/proxy/Tor classification. Results are cached in PostgreSQL for 48 hours by default. Portal and game login events are enriched when data is available; lookup failures do not deny login. User Minecraft-account additions fail closed for unknown, VPN, proxy, or Tor classifications and record denied attempts. Hosting-provider blocking is optional through `BLOCK_HOSTING_IPS=true`. Private and reserved addresses are never sent to ProxyCheck.
## Event naming
+4 -2
View File
@@ -27,12 +27,14 @@ Next.js portal and APIs, Discord bot, PostgreSQL persistence, Keycloak admin aut
- ORM-parameterized queries are used throughout.
- CSP, clickjacking, MIME-sniffing, referrer, and browser-permission headers are configured.
- Forwarded IP headers are ignored unless `TRUST_PROXY=true` is explicitly configured.
- Private and reserved addresses are not sent to ProxyCheck.io; lookup results are cached to reduce disclosure and API usage.
- Portal and game login events include approximate network location and VPN/proxy classification when available.
- Secrets are excluded from logs and repository configuration.
## Outstanding production requirements
- Select and implement a VPN/proxy intelligence provider before enabling VPN-based account-addition blocking. The current classification is explicitly `unknown`.
- Define and automate retention for exact IP addresses and audit events.
- Monitor ProxyCheck.io usage, detection quality, and false positives. User account additions fail closed when classification is unavailable; hosting-provider blocking remains optional.
- Define and automate retention for exact IP addresses, cached provider responses, approximate location, and audit events.
- Add monitoring and alerts for repeated login denials, plugin authentication failures, and Discord API failures.
- Use HTTPS for the public application and Velocity API URL. Protect the Velocity configuration file because it contains the one-time-displayed API token.
- Restrict database credentials so normal application roles cannot update or delete historical event rows outside approved application paths.
+12
View File
@@ -5696,6 +5696,15 @@
"node": ">= 0.4"
}
},
"node_modules/ipaddr.js": {
"version": "2.4.0",
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-2.4.0.tgz",
"integrity": "sha512-9VGk3HGanVE6JoZXHiCpnGy5X0jYDnN4EA4lntFPj+1vIWlFhIylq2CrrCOJH9EAhc5CYhq18F2Av2tgoAPsYQ==",
"license": "MIT",
"engines": {
"node": ">= 10"
}
},
"node_modules/is-array-buffer": {
"version": "3.0.5",
"resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz",
@@ -9216,6 +9225,9 @@
"packages/network": {
"name": "@minecraft-account-manager/network",
"version": "0.1.0",
"dependencies": {
"ipaddr.js": "^2.2.0"
},
"devDependencies": {
"@types/node": "^25.0.3",
"typescript": "^5.9.3",
+1
View File
@@ -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" }
}
+119
View File
@@ -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;
+93
View File
@@ -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);
});
});