1.5.0 update
This commit is contained in:
@@ -0,0 +1,551 @@
|
||||
import { describe, expect, test, beforeEach } from "bun:test";
|
||||
import { encodeMessage, decodeMessage, type Hello, type EnrollmentChallenge, type EnrollmentStatusMsg } from "./protocol";
|
||||
|
||||
|
||||
async function generateKeypair() {
|
||||
const kp = await crypto.subtle.generateKey("Ed25519", true, ["sign", "verify"]);
|
||||
const pubRaw = await crypto.subtle.exportKey("raw", kp.publicKey);
|
||||
const privRaw = await crypto.subtle.exportKey("pkcs8", kp.privateKey);
|
||||
return {
|
||||
publicKey: kp.publicKey,
|
||||
privateKey: kp.privateKey,
|
||||
publicKeyBase64: Buffer.from(pubRaw).toString("base64"),
|
||||
publicKeyBytes: new Uint8Array(pubRaw),
|
||||
};
|
||||
}
|
||||
|
||||
async function sign(privateKey: CryptoKey, data: Uint8Array): Promise<string> {
|
||||
const sig = await crypto.subtle.sign("Ed25519", privateKey, data);
|
||||
return Buffer.from(sig).toString("base64");
|
||||
}
|
||||
|
||||
async function verifyEd25519(publicKeyBase64: string, signatureBase64: string, nonceBase64: string): Promise<boolean> {
|
||||
try {
|
||||
const pubKeyBytes = Buffer.from(publicKeyBase64, "base64");
|
||||
const sigBytes = Buffer.from(signatureBase64, "base64");
|
||||
const nonceBytes = Buffer.from(nonceBase64, "base64");
|
||||
if (pubKeyBytes.length !== 32 || sigBytes.length !== 64) return false;
|
||||
const key = await crypto.subtle.importKey("raw", pubKeyBytes, { name: "Ed25519" }, false, ["verify"]);
|
||||
return await crypto.subtle.verify("Ed25519", key, sigBytes, nonceBytes);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function computeKeyFingerprint(publicKeyBase64: string): string {
|
||||
const bytes = Buffer.from(publicKeyBase64, "base64");
|
||||
return new Bun.CryptoHasher("sha256").update(bytes).digest("hex");
|
||||
}
|
||||
|
||||
function createMockWs(overrides: Partial<{ clientId: string; role: string; ip: string; enrollmentNonce: string }> = {}) {
|
||||
const sent: Uint8Array[] = [];
|
||||
let closedCode: number | null = null;
|
||||
let closedReason: string | null = null;
|
||||
return {
|
||||
data: {
|
||||
clientId: overrides.clientId ?? "test-client-1",
|
||||
role: overrides.role ?? "client",
|
||||
ip: overrides.ip ?? "127.0.0.1",
|
||||
enrollmentNonce: overrides.enrollmentNonce ?? undefined,
|
||||
sessionId: "sess-1",
|
||||
},
|
||||
sent,
|
||||
closedCode,
|
||||
closedReason,
|
||||
send(msg: Uint8Array) { sent.push(msg); },
|
||||
close(code: number, reason: string) { this.closedCode = code; this.closedReason = reason; },
|
||||
decodeSent(index: number) { return decodeMessage(sent[index]) as any; },
|
||||
};
|
||||
}
|
||||
|
||||
// tests (AI)
|
||||
|
||||
describe("purgatory — Ed25519 signature verification", () => {
|
||||
test("valid signature from matching keypair passes", async () => {
|
||||
const kp = await generateKeypair();
|
||||
const nonce = new Uint8Array(32);
|
||||
crypto.getRandomValues(nonce);
|
||||
const nonceB64 = Buffer.from(nonce).toString("base64");
|
||||
const sigB64 = await sign(kp.privateKey, nonce);
|
||||
|
||||
expect(await verifyEd25519(kp.publicKeyBase64, sigB64, nonceB64)).toBe(true);
|
||||
});
|
||||
|
||||
test("signature from wrong keypair is rejected", async () => {
|
||||
const kp1 = await generateKeypair();
|
||||
const kp2 = await generateKeypair();
|
||||
const nonce = new Uint8Array(32);
|
||||
crypto.getRandomValues(nonce);
|
||||
const nonceB64 = Buffer.from(nonce).toString("base64");
|
||||
// Sign with kp1 but verify with kp2's public key
|
||||
const sigB64 = await sign(kp1.privateKey, nonce);
|
||||
|
||||
expect(await verifyEd25519(kp2.publicKeyBase64, sigB64, nonceB64)).toBe(false);
|
||||
});
|
||||
|
||||
test("signature over wrong nonce is rejected", async () => {
|
||||
const kp = await generateKeypair();
|
||||
const nonce1 = new Uint8Array(32);
|
||||
const nonce2 = new Uint8Array(32);
|
||||
crypto.getRandomValues(nonce1);
|
||||
crypto.getRandomValues(nonce2);
|
||||
// Sign nonce1 but try to verify against nonce2
|
||||
const sigB64 = await sign(kp.privateKey, nonce1);
|
||||
|
||||
expect(await verifyEd25519(kp.publicKeyBase64, sigB64, Buffer.from(nonce2).toString("base64"))).toBe(false);
|
||||
});
|
||||
|
||||
test("empty public key is rejected", async () => {
|
||||
expect(await verifyEd25519("", "AAAA", "AAAA")).toBe(false);
|
||||
});
|
||||
|
||||
test("empty signature is rejected", async () => {
|
||||
const kp = await generateKeypair();
|
||||
expect(await verifyEd25519(kp.publicKeyBase64, "", "AAAA")).toBe(false);
|
||||
});
|
||||
|
||||
test("malformed public key (wrong length) is rejected", async () => {
|
||||
const nonce = new Uint8Array(32);
|
||||
crypto.getRandomValues(nonce);
|
||||
const nonceB64 = Buffer.from(nonce).toString("base64");
|
||||
// 16-byte key instead of 32
|
||||
const badKey = Buffer.from(new Uint8Array(16)).toString("base64");
|
||||
const fakeSig = Buffer.from(new Uint8Array(64)).toString("base64");
|
||||
|
||||
expect(await verifyEd25519(badKey, fakeSig, nonceB64)).toBe(false);
|
||||
});
|
||||
|
||||
test("malformed signature (wrong length) is rejected", async () => {
|
||||
const kp = await generateKeypair();
|
||||
const nonce = new Uint8Array(32);
|
||||
crypto.getRandomValues(nonce);
|
||||
const nonceB64 = Buffer.from(nonce).toString("base64");
|
||||
// 32-byte signature instead of 64
|
||||
const badSig = Buffer.from(new Uint8Array(32)).toString("base64");
|
||||
|
||||
expect(await verifyEd25519(kp.publicKeyBase64, badSig, nonceB64)).toBe(false);
|
||||
});
|
||||
|
||||
test("garbage base64 does not crash — returns false", async () => {
|
||||
expect(await verifyEd25519("not!!valid!!base64", "also!!bad", "nonce!!bad")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("purgatory — key fingerprint", () => {
|
||||
test("fingerprint is a 64-char hex SHA-256 of the raw public key bytes", async () => {
|
||||
const kp = await generateKeypair();
|
||||
const fp = computeKeyFingerprint(kp.publicKeyBase64);
|
||||
expect(fp).toMatch(/^[0-9a-f]{64}$/);
|
||||
});
|
||||
|
||||
test("different keys produce different fingerprints", async () => {
|
||||
const kp1 = await generateKeypair();
|
||||
const kp2 = await generateKeypair();
|
||||
expect(computeKeyFingerprint(kp1.publicKeyBase64)).not.toBe(computeKeyFingerprint(kp2.publicKeyBase64));
|
||||
});
|
||||
|
||||
test("same key always produces the same fingerprint", async () => {
|
||||
const kp = await generateKeypair();
|
||||
expect(computeKeyFingerprint(kp.publicKeyBase64)).toBe(computeKeyFingerprint(kp.publicKeyBase64));
|
||||
});
|
||||
});
|
||||
|
||||
describe("purgatory — enrollment challenge protocol", () => {
|
||||
test("enrollment_challenge message encodes/decodes with nonce", () => {
|
||||
const nonce = Buffer.from(new Uint8Array(32)).toString("base64");
|
||||
const msg = encodeMessage({ type: "enrollment_challenge", nonce });
|
||||
const decoded = decodeMessage(msg) as EnrollmentChallenge;
|
||||
expect(decoded.type).toBe("enrollment_challenge");
|
||||
expect(decoded.nonce).toBe(nonce);
|
||||
});
|
||||
|
||||
test("enrollment_status message encodes/decodes with status", () => {
|
||||
for (const status of ["pending", "approved", "denied"] as const) {
|
||||
const msg = encodeMessage({ type: "enrollment_status", status });
|
||||
const decoded = decodeMessage(msg) as EnrollmentStatusMsg;
|
||||
expect(decoded.type).toBe("enrollment_status");
|
||||
expect(decoded.status).toBe(status);
|
||||
}
|
||||
});
|
||||
|
||||
test("hello message with publicKey and signature round-trips", async () => {
|
||||
const kp = await generateKeypair();
|
||||
const hello: Hello = {
|
||||
type: "hello",
|
||||
id: "client-abc",
|
||||
host: "DESKTOP-1",
|
||||
os: "windows",
|
||||
arch: "amd64",
|
||||
version: "1.0.0",
|
||||
user: "testuser",
|
||||
monitors: 2,
|
||||
country: "US",
|
||||
publicKey: kp.publicKeyBase64,
|
||||
signature: "fakesig==",
|
||||
};
|
||||
const encoded = encodeMessage(hello);
|
||||
const decoded = decodeMessage(encoded) as Hello;
|
||||
expect(decoded.publicKey).toBe(kp.publicKeyBase64);
|
||||
expect(decoded.signature).toBe("fakesig==");
|
||||
});
|
||||
});
|
||||
|
||||
describe("purgatory — client rejection scenarios", () => {
|
||||
test("client with no publicKey is rejected (close 4002)", () => {
|
||||
const ws = createMockWs({ enrollmentNonce: Buffer.from(new Uint8Array(32)).toString("base64") });
|
||||
const payload: any = {
|
||||
type: "hello",
|
||||
id: "bad-client",
|
||||
host: "evil",
|
||||
os: "windows",
|
||||
arch: "amd64",
|
||||
version: "1.0",
|
||||
user: "hacker",
|
||||
monitors: 1,
|
||||
publicKey: "",
|
||||
signature: "something",
|
||||
};
|
||||
// Simulate the server's check: missing publicKey/signature/nonce
|
||||
const publicKey = typeof payload.publicKey === "string" ? payload.publicKey : "";
|
||||
const signature = typeof payload.signature === "string" ? payload.signature : "";
|
||||
const nonce = ws.data.enrollmentNonce || "";
|
||||
|
||||
if (!publicKey || !signature || !nonce) {
|
||||
ws.close(4002, "invalid_signature");
|
||||
}
|
||||
|
||||
expect(ws.closedCode).toBe(4002);
|
||||
expect(ws.closedReason).toBe("invalid_signature");
|
||||
});
|
||||
|
||||
test("client with no signature is rejected (close 4002)", () => {
|
||||
const ws = createMockWs({ enrollmentNonce: Buffer.from(new Uint8Array(32)).toString("base64") });
|
||||
const publicKey = "validkeyhere";
|
||||
const signature = "";
|
||||
const nonce = ws.data.enrollmentNonce || "";
|
||||
|
||||
if (!publicKey || !signature || !nonce) {
|
||||
ws.close(4002, "invalid_signature");
|
||||
}
|
||||
|
||||
expect(ws.closedCode).toBe(4002);
|
||||
});
|
||||
|
||||
test("client with no nonce stored on ws is rejected (close 4002)", () => {
|
||||
const ws = createMockWs({ enrollmentNonce: undefined });
|
||||
const publicKey = "somekey";
|
||||
const signature = "somesig";
|
||||
const nonce = ws.data.enrollmentNonce || "";
|
||||
|
||||
if (!publicKey || !signature || !nonce) {
|
||||
ws.close(4002, "invalid_signature");
|
||||
}
|
||||
|
||||
expect(ws.closedCode).toBe(4002);
|
||||
});
|
||||
|
||||
test("client with invalid Ed25519 signature is rejected", async () => {
|
||||
const kp = await generateKeypair();
|
||||
const nonce = new Uint8Array(32);
|
||||
crypto.getRandomValues(nonce);
|
||||
const nonceB64 = Buffer.from(nonce).toString("base64");
|
||||
// Construct a bogus signature (not signed by this key)
|
||||
const badSig = Buffer.from(new Uint8Array(64).fill(0xAB)).toString("base64");
|
||||
|
||||
const valid = await verifyEd25519(kp.publicKeyBase64, badSig, nonceB64);
|
||||
expect(valid).toBe(false);
|
||||
// Server would close with 4002
|
||||
});
|
||||
|
||||
test("replay attack: reusing a nonce with a valid signature on a different nonce fails", async () => {
|
||||
const kp = await generateKeypair();
|
||||
const nonce1 = new Uint8Array(32);
|
||||
const nonce2 = new Uint8Array(32);
|
||||
crypto.getRandomValues(nonce1);
|
||||
crypto.getRandomValues(nonce2);
|
||||
|
||||
// Client signed nonce1
|
||||
const sigB64 = await sign(kp.privateKey, nonce1);
|
||||
// Server presents nonce2 — verify against nonce2 should fail
|
||||
const valid = await verifyEd25519(kp.publicKeyBase64, sigB64, Buffer.from(nonce2).toString("base64"));
|
||||
expect(valid).toBe(false);
|
||||
});
|
||||
|
||||
test("nonce is cleared after use — cannot be reused", () => {
|
||||
const ws = createMockWs({ enrollmentNonce: "someNonce==" });
|
||||
// Server clears nonce after successful verification
|
||||
ws.data.enrollmentNonce = undefined;
|
||||
expect(ws.data.enrollmentNonce).toBeUndefined();
|
||||
// Second attempt with no nonce → rejected
|
||||
const nonce = ws.data.enrollmentNonce || "";
|
||||
expect(nonce).toBe("");
|
||||
});
|
||||
|
||||
test("denied client gets close code 4003", () => {
|
||||
const ws = createMockWs();
|
||||
// Simulate denied enrollment status
|
||||
const enrollmentStatus = "denied";
|
||||
if (enrollmentStatus === "denied") {
|
||||
ws.send(encodeMessage({ type: "enrollment_status", status: "denied" }));
|
||||
ws.close(4003, "denied");
|
||||
}
|
||||
expect(ws.closedCode).toBe(4003);
|
||||
expect(ws.closedReason).toBe("denied");
|
||||
const msg = ws.decodeSent(0);
|
||||
expect(msg.type).toBe("enrollment_status");
|
||||
expect(msg.status).toBe("denied");
|
||||
});
|
||||
|
||||
test("pending client gets close code 4001", () => {
|
||||
const ws = createMockWs();
|
||||
const enrollmentStatus = "pending";
|
||||
if (enrollmentStatus === "pending") {
|
||||
ws.send(encodeMessage({ type: "enrollment_status", status: "pending" }));
|
||||
ws.close(4001, "pending");
|
||||
}
|
||||
expect(ws.closedCode).toBe(4001);
|
||||
expect(ws.closedReason).toBe("pending");
|
||||
const msg = ws.decodeSent(0);
|
||||
expect(msg.type).toBe("enrollment_status");
|
||||
expect(msg.status).toBe("pending");
|
||||
});
|
||||
|
||||
test("approved client gets hello_ack (not closed)", async () => {
|
||||
const ws = createMockWs();
|
||||
const enrollmentStatus = "approved";
|
||||
if (enrollmentStatus === "approved") {
|
||||
ws.send(encodeMessage({
|
||||
type: "hello_ack",
|
||||
id: ws.data.clientId,
|
||||
notification: { keywords: [], minIntervalMs: 8000, clipboardEnabled: false },
|
||||
}));
|
||||
}
|
||||
expect(ws.closedCode).toBeNull();
|
||||
const msg = ws.decodeSent(0);
|
||||
expect(msg.type).toBe("hello_ack");
|
||||
expect(msg.id).toBe("test-client-1");
|
||||
});
|
||||
});
|
||||
|
||||
describe("purgatory — enrollment timeout", () => {
|
||||
test("timeout constant is 30 seconds", () => {
|
||||
// This just documents the expected timeout value
|
||||
const ENROLLMENT_TIMEOUT_MS = 30_000;
|
||||
expect(ENROLLMENT_TIMEOUT_MS).toBe(30_000);
|
||||
});
|
||||
|
||||
test("client that never sends hello would be closed with 4002 on timeout", () => {
|
||||
const ws = createMockWs();
|
||||
// Simulate timeout firing
|
||||
ws.close(4002, "enrollment_timeout");
|
||||
expect(ws.closedCode).toBe(4002);
|
||||
expect(ws.closedReason).toBe("enrollment_timeout");
|
||||
});
|
||||
});
|
||||
|
||||
describe("purgatory — nonce generation", () => {
|
||||
test("nonce is 32 bytes of randomness", () => {
|
||||
const nonce = new Uint8Array(32);
|
||||
crypto.getRandomValues(nonce);
|
||||
expect(nonce.length).toBe(32);
|
||||
// Extremely unlikely all zeros
|
||||
expect(nonce.some((b) => b !== 0)).toBe(true);
|
||||
});
|
||||
|
||||
test("sequential nonces are unique", () => {
|
||||
const nonces = new Set<string>();
|
||||
for (let i = 0; i < 100; i++) {
|
||||
const n = new Uint8Array(32);
|
||||
crypto.getRandomValues(n);
|
||||
nonces.add(Buffer.from(n).toString("base64"));
|
||||
}
|
||||
expect(nonces.size).toBe(100);
|
||||
});
|
||||
});
|
||||
|
||||
describe("purgatory — end-to-end challenge-response", () => {
|
||||
test("legitimate client completes full handshake", async () => {
|
||||
// 1. Server generates nonce
|
||||
const nonceBytes = new Uint8Array(32);
|
||||
crypto.getRandomValues(nonceBytes);
|
||||
const nonceB64 = Buffer.from(nonceBytes).toString("base64");
|
||||
|
||||
// 2. Client receives challenge and signs
|
||||
const kp = await generateKeypair();
|
||||
const sigB64 = await sign(kp.privateKey, nonceBytes);
|
||||
|
||||
// 3. Server verifies
|
||||
const valid = await verifyEd25519(kp.publicKeyBase64, sigB64, nonceB64);
|
||||
expect(valid).toBe(true);
|
||||
|
||||
// 4. Server computes fingerprint
|
||||
const fp = computeKeyFingerprint(kp.publicKeyBase64);
|
||||
expect(fp).toMatch(/^[0-9a-f]{64}$/);
|
||||
});
|
||||
|
||||
test("attacker who intercepts nonce cannot sign without private key", async () => {
|
||||
// 1. Server generates nonce
|
||||
const nonceBytes = new Uint8Array(32);
|
||||
crypto.getRandomValues(nonceBytes);
|
||||
const nonceB64 = Buffer.from(nonceBytes).toString("base64");
|
||||
|
||||
// 2. Attacker has the real client's public key but not private key
|
||||
const realClient = await generateKeypair();
|
||||
const attacker = await generateKeypair();
|
||||
|
||||
// 3. Attacker tries to sign with their own key but present real client's pubkey
|
||||
const attackerSig = await sign(attacker.privateKey, nonceBytes);
|
||||
const valid = await verifyEd25519(realClient.publicKeyBase64, attackerSig, nonceB64);
|
||||
expect(valid).toBe(false);
|
||||
});
|
||||
|
||||
test("attacker with own keypair gets new identity — not existing client", async () => {
|
||||
// Attacker generates valid keypair and signs correctly
|
||||
const attacker = await generateKeypair();
|
||||
const nonceBytes = new Uint8Array(32);
|
||||
crypto.getRandomValues(nonceBytes);
|
||||
const nonceB64 = Buffer.from(nonceBytes).toString("base64");
|
||||
const sigB64 = await sign(attacker.privateKey, nonceBytes);
|
||||
|
||||
// Signature is valid...
|
||||
const valid = await verifyEd25519(attacker.publicKeyBase64, sigB64, nonceB64);
|
||||
expect(valid).toBe(true);
|
||||
|
||||
// ...but fingerprint won't match any existing approved client
|
||||
const realClient = await generateKeypair();
|
||||
expect(computeKeyFingerprint(attacker.publicKeyBase64)).not.toBe(computeKeyFingerprint(realClient.publicKeyBase64));
|
||||
});
|
||||
|
||||
test("all-zero signature is rejected", async () => {
|
||||
const kp = await generateKeypair();
|
||||
const nonceBytes = new Uint8Array(32);
|
||||
crypto.getRandomValues(nonceBytes);
|
||||
const nonceB64 = Buffer.from(nonceBytes).toString("base64");
|
||||
const zeroSig = Buffer.from(new Uint8Array(64)).toString("base64");
|
||||
|
||||
expect(await verifyEd25519(kp.publicKeyBase64, zeroSig, nonceB64)).toBe(false);
|
||||
});
|
||||
|
||||
test("random 32-byte value that is not a valid Ed25519 point is rejected", async () => {
|
||||
const nonceBytes = new Uint8Array(32);
|
||||
crypto.getRandomValues(nonceBytes);
|
||||
const nonceB64 = Buffer.from(nonceBytes).toString("base64");
|
||||
// 0xFF-filled is not a valid Ed25519 public key point
|
||||
const badKey = Buffer.from(new Uint8Array(32).fill(0xFF)).toString("base64");
|
||||
const fakeSig = Buffer.from(new Uint8Array(64).fill(0x42)).toString("base64");
|
||||
|
||||
expect(await verifyEd25519(badKey, fakeSig, nonceB64)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("purgatory — duplicate connection prevention", () => {
|
||||
test("superseded socket gets close code 4004", () => {
|
||||
// Simulate: old socket is active, then new socket arrives for same clientId
|
||||
const oldWs = createMockWs({ clientId: "dup-client" });
|
||||
const newWs = createMockWs({ clientId: "dup-client" });
|
||||
|
||||
// Simulated client manager (Map<string, { ws: any }>)
|
||||
const clients = new Map<string, { ws: any }>();
|
||||
clients.set("dup-client", { ws: oldWs });
|
||||
|
||||
// New connection arrives — kick old
|
||||
const existing = clients.get("dup-client");
|
||||
if (existing && existing.ws !== newWs) {
|
||||
existing.ws.close(4004, "superseded");
|
||||
clients.delete("dup-client");
|
||||
}
|
||||
clients.set("dup-client", { ws: newWs });
|
||||
|
||||
expect(oldWs.closedCode).toBe(4004);
|
||||
expect(oldWs.closedReason).toBe("superseded");
|
||||
expect(newWs.closedCode).toBeNull();
|
||||
expect(clients.get("dup-client")!.ws).toBe(newWs);
|
||||
});
|
||||
|
||||
test("superseded socket close handler skips cleanup", () => {
|
||||
// Simulate: old socket fires close, but manager already has the new socket
|
||||
const oldWs = createMockWs({ clientId: "dup-client" });
|
||||
const newWs = createMockWs({ clientId: "dup-client" });
|
||||
|
||||
const clients = new Map<string, { ws: any }>();
|
||||
clients.set("dup-client", { ws: newWs }); // new one already registered
|
||||
|
||||
// Old socket close handler fires
|
||||
const current = clients.get("dup-client");
|
||||
const shouldSkipCleanup = current && current.ws !== oldWs;
|
||||
|
||||
expect(shouldSkipCleanup).toBe(true);
|
||||
// New socket should stay in place
|
||||
expect(clients.get("dup-client")!.ws).toBe(newWs);
|
||||
});
|
||||
|
||||
test("normal close handler cleans up when socket matches", () => {
|
||||
const ws = createMockWs({ clientId: "normal-client" });
|
||||
const clients = new Map<string, { ws: any }>();
|
||||
clients.set("normal-client", { ws });
|
||||
|
||||
// Close handler — socket matches current
|
||||
const current = clients.get("normal-client");
|
||||
const shouldSkipCleanup = current && current.ws !== ws;
|
||||
|
||||
expect(shouldSkipCleanup).toBe(false);
|
||||
// Should proceed with cleanup
|
||||
clients.delete("normal-client");
|
||||
expect(clients.has("normal-client")).toBe(false);
|
||||
});
|
||||
|
||||
test("same socket reconnecting is not kicked (identity check)", () => {
|
||||
const ws = createMockWs({ clientId: "same-client" });
|
||||
const clients = new Map<string, { ws: any }>();
|
||||
clients.set("same-client", { ws });
|
||||
|
||||
// Same ws object reconnects — should NOT kick itself
|
||||
const existing = clients.get("same-client");
|
||||
if (existing && existing.ws !== ws) {
|
||||
existing.ws.close(4004, "superseded");
|
||||
}
|
||||
|
||||
// Should not have been closed
|
||||
expect(ws.closedCode).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("purgatory — IP banning", () => {
|
||||
test("IP validation regex accepts valid IPv4", () => {
|
||||
const ipRegex = /^[0-9a-fA-F:.]{3,64}$/;
|
||||
expect(ipRegex.test("192.168.1.1")).toBe(true);
|
||||
expect(ipRegex.test("10.0.0.1")).toBe(true);
|
||||
expect(ipRegex.test("255.255.255.255")).toBe(true);
|
||||
});
|
||||
|
||||
test("IP validation regex accepts valid IPv6", () => {
|
||||
const ipRegex = /^[0-9a-fA-F:.]{3,64}$/;
|
||||
expect(ipRegex.test("::1")).toBe(true);
|
||||
expect(ipRegex.test("fe80::1")).toBe(true);
|
||||
expect(ipRegex.test("2001:db8::1")).toBe(true);
|
||||
});
|
||||
|
||||
test("IP validation regex rejects dangerous input", () => {
|
||||
const ipRegex = /^[0-9a-fA-F:.]{3,64}$/;
|
||||
expect(ipRegex.test("")).toBe(false);
|
||||
expect(ipRegex.test("a")).toBe(false); // too short
|
||||
expect(ipRegex.test("'; DROP TABLE--")).toBe(false);
|
||||
expect(ipRegex.test("192.168.1.1; rm -rf /")).toBe(false);
|
||||
expect(ipRegex.test("<script>alert(1)</script>")).toBe(false);
|
||||
});
|
||||
|
||||
test("banned client gets close code 4003", () => {
|
||||
const ws = createMockWs({ ip: "1.2.3.4" });
|
||||
// Simulate banning: deny enrollment + close socket
|
||||
ws.close(4003, "banned");
|
||||
expect(ws.closedCode).toBe(4003);
|
||||
expect(ws.closedReason).toBe("banned");
|
||||
});
|
||||
|
||||
test("ban reason is truncated to 200 chars", () => {
|
||||
const longReason = "A".repeat(500);
|
||||
const truncated = longReason.slice(0, 200);
|
||||
expect(truncated.length).toBe(200);
|
||||
expect(truncated).toBe("A".repeat(200));
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user