From 40abab7abcdf716432c0731098c3a11a9b89708e Mon Sep 17 00:00:00 2001 From: Dylan Garvis Date: Sat, 1 Aug 2026 14:32:54 -0400 Subject: [PATCH] feat(network): add ProxyCheck IP intelligence --- .env.example | 8 +- README.md | 4 +- apps/web/src/app/account/actions.ts | 16 ++ apps/web/src/app/account/page.tsx | 20 ++- .../src/app/admin/(console)/events/page.tsx | 25 +-- .../admin/(console)/users/[userId]/page.tsx | 6 +- apps/web/src/app/api/velocity/access/route.ts | 41 ++++- apps/web/src/app/auth/discord/route.ts | 9 +- apps/web/src/app/welcome/actions.ts | 16 ++ apps/web/src/app/welcome/minecraft/page.tsx | 2 + apps/web/src/lib/event-ip-summary.ts | 27 +++ apps/web/src/lib/ip-intelligence.ts | 158 ++++++++++++++++++ docs/architecture.md | 2 +- docs/security-review.md | 6 +- package-lock.json | 12 ++ packages/network/package.json | 1 + packages/network/src/index.ts | 119 +++++++++++++ packages/network/test/proxycheck.test.ts | 93 +++++++++++ 18 files changed, 539 insertions(+), 26 deletions(-) create mode 100644 apps/web/src/lib/event-ip-summary.ts create mode 100644 apps/web/src/lib/ip-intelligence.ts create mode 100644 packages/network/test/proxycheck.test.ts diff --git a/.env.example b/.env.example index 6967961..ab01b9c 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/README.md b/README.md index 43d5088..4d199bb 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/apps/web/src/app/account/actions.ts b/apps/web/src/app/account/actions.ts index 71c56a3..758935e 100644 --- a/apps/web/src/app/account/actions.ts +++ b/apps/web/src/app/account/actions.ts @@ -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)}`); diff --git a/apps/web/src/app/account/page.tsx b/apps/web/src/app/account/page.tsx index eaa7ae2..488eedb 100644 --- a/apps/web/src/app/account/page.tsx +++ b/apps/web/src/app/account/page.tsx @@ -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 = { "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({

Access addresses

{observations.length ? (
- {observations.map((observation) =>
{observation.ipAddress}{observation.source} · {observation.observedAt.toISOString()}
)} + {observations.map((observation) => { + const summary = intelligenceSummary(observation.intelligence); + return

{observation.ipAddress}

{summary.location ?? "Location unavailable"} · {summary.classification ?? observation.classification}

{observation.source} · {observation.observedAt.toISOString()}
; + })}
) :

No web or game access addresses have been recorded yet.

} diff --git a/apps/web/src/app/admin/(console)/events/page.tsx b/apps/web/src/app/admin/(console)/events/page.tsx index f2d06ba..a04f415 100644 --- a/apps/web/src/app/admin/(console)/events/page.tsx +++ b/apps/web/src/app/admin/(console)/events/page.tsx @@ -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() {
- + - {recentEvents.map((event) => ( - - - - - - - ))} - {!recentEvents.length && } + {recentEvents.map((event) => { + const ip = eventIpSummary(event.data); + return ( + + + + + + + + ); + })} + {!recentEvents.length && }
TimeTypeSubjectIP
TimeTypeSubjectIPNetwork
{event.time.toISOString()}{event.type}{event.subject ?? "—"}{event.ipAddress ?? "—"}
No events have been recorded.
{event.time.toISOString()}{event.type}{event.subject ?? "—"}{event.ipAddress ?? "—"}
{ip.classification ?? "—"}
{ip.location ?? "Location unavailable"}
No events have been recorded.
diff --git a/apps/web/src/app/admin/(console)/users/[userId]/page.tsx b/apps/web/src/app/admin/(console)/users/[userId]/page.tsx index 6fd324c..c5ebebd 100644 --- a/apps/web/src/app/admin/(console)/users/[userId]/page.tsx +++ b/apps/web/src/app/admin/(console)/users/[userId]/page.tsx @@ -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({

Audit trail

Recent events

- {recentEvents.map((event) =>
{event.type}{event.time.toISOString()}
)} + {recentEvents.map((event) => { + const ip = eventIpSummary(event.data); + return

{event.type}

{(ip.location || ip.classification) &&

{ip.location ?? "Location unavailable"} · {ip.classification ?? "unknown"}

}
{event.time.toISOString()}
; + })} {!recentEvents.length &&

No events recorded for this user.

}
diff --git a/apps/web/src/app/api/velocity/access/route.ts b/apps/web/src/app/api/velocity/access/route.ts index 1f5e3b6..7b211f5 100644 --- a/apps/web/src/app/api/velocity/access/route.ts +++ b/apps/web/src/app/api/velocity/access/route.ts @@ -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, diff --git a/apps/web/src/app/auth/discord/route.ts b/apps/web/src/app/auth/discord/route.ts index 0b5d119..f84204e 100644 --- a/apps/web/src/app/auth/discord/route.ts +++ b/apps/web/src/app/auth/discord/route.ts @@ -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(), ]); diff --git a/apps/web/src/app/welcome/actions.ts b/apps/web/src/app/welcome/actions.ts index a91b79c..65470fb 100644 --- a/apps/web/src/app/welcome/actions.ts +++ b/apps/web/src/app/welcome/actions.ts @@ -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)}`); diff --git a/apps/web/src/app/welcome/minecraft/page.tsx b/apps/web/src/app/welcome/minecraft/page.tsx index e4db316..49afa80 100644 --- a/apps/web/src/app/welcome/minecraft/page.tsx +++ b/apps/web/src/app/welcome/minecraft/page.tsx @@ -5,6 +5,8 @@ import { addFirstMinecraftAccount } from "../actions"; const errors: Record = { "invalid-format": "Java usernames use 3–16 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({ diff --git a/apps/web/src/lib/event-ip-summary.ts b/apps/web/src/lib/event-ip-summary.ts new file mode 100644 index 0000000..92b5fc1 --- /dev/null +++ b/apps/web/src/lib/event-ip-summary.ts @@ -0,0 +1,27 @@ +type UnknownMap = Record; + +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); +} diff --git a/apps/web/src/lib/ip-intelligence.ts b/apps/web/src/lib/ip-intelligence.ts new file mode 100644 index 0000000..3e9ca67 --- /dev/null +++ b/apps/web/src/lib/ip-intelligence.ts @@ -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([ + "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; +} + +function deserialize(value: Record | 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 { + 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 }; +} diff --git a/docs/architecture.md b/docs/architecture.md index 124182a..be024bb 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -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 diff --git a/docs/security-review.md b/docs/security-review.md index b363542..c111f23 100644 --- a/docs/security-review.md +++ b/docs/security-review.md @@ -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. diff --git a/package-lock.json b/package-lock.json index 106c77c..cda6953 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/packages/network/package.json b/packages/network/package.json index f51a721..1f632c6 100644 --- a/packages/network/package.json +++ b/packages/network/package.json @@ -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" } } diff --git a/packages/network/src/index.ts b/packages/network/src/index.ts index 0331341..5a382bb 100644 --- a/packages/network/src/index.ts +++ b/packages/network/src/index.ts @@ -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; + 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 { + 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; + 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; + 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; diff --git a/packages/network/test/proxycheck.test.ts b/packages/network/test/proxycheck.test.ts new file mode 100644 index 0000000..e308578 --- /dev/null +++ b/packages/network/test/proxycheck.test.ts @@ -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) { + 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().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().mockResolvedValue(response({ proxy: "yes", type: "VPN" })); + const torRequest = vi.fn().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().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); + }); +});