feat(admission): add group VPN exceptions
CI / validate (push) Successful in 5m45s
Release / release (push) Successful in 7m21s

This commit is contained in:
dmg
2026-08-02 10:16:16 -04:00
parent 24808b0f8c
commit 71856bb869
34 changed files with 1820 additions and 138 deletions
+18
View File
@@ -115,6 +115,24 @@ export function resolveEffectiveGroup<T>(explicitGroup: T | null, defaultGroup:
return explicitGroup ?? defaultGroup;
}
export function isGameNetworkAllowed(
classification: "unknown" | "clear" | "vpn" | "proxy" | "hosting" | "tor",
anonymizedNetworksAllowed: boolean,
) {
return anonymizedNetworksAllowed || !["vpn", "proxy", "tor"].includes(classification);
}
export function gameAdmissionDenialReason(
group: { accessEnabled: boolean; anonymizedNetworksAllowed: boolean } | null,
classification: "unknown" | "clear" | "vpn" | "proxy" | "hosting" | "tor",
) {
if (!group?.accessEnabled) return "group_access_disabled" as const;
if (!isGameNetworkAllowed(classification, group.anonymizedNetworksAllowed)) {
return "anonymized_network_disallowed" as const;
}
return null;
}
export function verifyHashedToken(providedToken: string, expectedHash: string) {
const provided = Buffer.from(hashToken(providedToken), "utf8");
const expected = Buffer.from(expectedHash, "utf8");
+23 -1
View File
@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
import { resolveEffectiveGroup } from "../src/index";
import { gameAdmissionDenialReason, isGameNetworkAllowed, resolveEffectiveGroup } from "../src/index";
describe("group-based admission", () => {
const everyone = { name: "everyone", accessEnabled: false };
@@ -17,4 +17,26 @@ describe("group-based admission", () => {
const limited = { name: "limited", accessEnabled: false };
expect(resolveEffectiveGroup(limited, enabledDefault)?.accessEnabled).toBe(false);
});
it("denies confirmed anonymized game networks unless the effective group allows them", () => {
for (const classification of ["vpn", "proxy", "tor"] as const) {
expect(isGameNetworkAllowed(classification, false)).toBe(false);
expect(isGameNetworkAllowed(classification, true)).toBe(true);
}
});
it("does not apply the group exception policy to clear, hosting, or unavailable intelligence", () => {
for (const classification of ["clear", "hosting", "unknown"] as const) {
expect(isGameNetworkAllowed(classification, false)).toBe(true);
}
});
it("prioritizes disabled group access before the network exception policy", () => {
expect(gameAdmissionDenialReason({ accessEnabled: false, anonymizedNetworksAllowed: false }, "vpn"))
.toBe("group_access_disabled");
expect(gameAdmissionDenialReason({ accessEnabled: true, anonymizedNetworksAllowed: false }, "vpn"))
.toBe("anonymized_network_disallowed");
expect(gameAdmissionDenialReason({ accessEnabled: true, anonymizedNetworksAllowed: true }, "vpn"))
.toBeNull();
});
});
@@ -0,0 +1,3 @@
ALTER TABLE "app_settings" ADD COLUMN "group_access_denied_message" text DEFAULT 'Your account group does not currently have server access. Contact a host if you believe this is a mistake.' NOT NULL;--> statement-breakpoint
ALTER TABLE "app_settings" ADD COLUMN "vpn_denied_message" text DEFAULT 'VPN, proxy, and Tor connections are not allowed. Contact a host to request an exception.' NOT NULL;--> statement-breakpoint
ALTER TABLE "groups" ADD COLUMN "anonymized_networks_allowed" boolean DEFAULT false NOT NULL;
File diff suppressed because it is too large Load Diff
@@ -29,6 +29,13 @@
"when": 1785625186545,
"tag": "0003_smiling_silver_samurai",
"breakpoints": true
},
{
"idx": 4,
"version": "7",
"when": 1785678753029,
"tag": "0004_zippy_silver_centurion",
"breakpoints": true
}
]
}
+7
View File
@@ -70,6 +70,7 @@ export const groups = pgTable(
slug: varchar("slug", { length: 50 }).notNull(),
description: text("description"),
accessEnabled: boolean("access_enabled").notNull().default(false),
anonymizedNetworksAllowed: boolean("anonymized_networks_allowed").notNull().default(false),
isDefault: boolean("is_default").notNull().default(false),
...timestamps(),
},
@@ -171,6 +172,12 @@ export const appSettings = pgTable("app_settings", {
registrationMessage: text("registration_message")
.notNull()
.default("Please register your Minecraft account before joining."),
groupAccessDeniedMessage: text("group_access_denied_message")
.notNull()
.default("Your account group does not currently have server access. Contact a host if you believe this is a mistake."),
vpnDeniedMessage: text("vpn_denied_message")
.notNull()
.default("VPN, proxy, and Tor connections are not allowed. Contact a host to request an exception."),
...timestamps(),
});
+3 -1
View File
@@ -51,7 +51,9 @@ function numberValue(value: unknown) {
}
function proxyClassification(proxy: unknown, type: unknown): IpClassification {
if (String(proxy).toLowerCase() !== "yes") return "clear";
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";
+8
View File
@@ -50,6 +50,14 @@ describe("ProxyCheck.io intelligence", () => {
);
});
it("treats missing or malformed proxy signals as unknown", async () => {
for (const proxy of [undefined, null, "maybe"] as const) {
const request = vi.fn<typeof fetch>().mockResolvedValue(response({ proxy, type: "Residential" }));
await expect(new ProxyCheckProvider({ apiKey: "secret", request }).classify(ipAddress))
.resolves.toMatchObject({ classification: "unknown", network: { proxy: null } });
}
});
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" }));