feat(admission): add group VPN exceptions
This commit is contained in:
@@ -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");
|
||||
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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(),
|
||||
});
|
||||
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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" }));
|
||||
|
||||
Reference in New Issue
Block a user