clone

nostr relays on demand at <name>.bind.ws: claim one with your key, pay in zaps past the free allowance

main
bindws / test / object / policy.test.ts
policy.test.ts 20.2K
1// The rules an owner sets and the relay enforces at the socket: who may2// write and read, proof of work, kinds, guests past a members-only rule, the3// web of trust, blocked words, message size, address blocks, and rate limits4// per connection and per address.5import { SELF } from "cloudflare:test";6import { describe, it, expect } from "vitest";7import { generateSecretKey, getPublicKey } from "nostr-tools/pure";8import { difficulty } from "../../src/event.ts";9import { now, ev, rpc, pk, info, post, sleep } from "../helpers/relay.ts";10import { WS } from "../helpers/ws.ts";11import { upload } from "../helpers/media.ts";1213describe("writes, reads, pow, bans and kinds", () => {14 it("enforces writes, reads, pow, bans, and kind rules set by the owner", async () => {15 const host = "gamma.bind.ws";16 const owner = generateSecretKey();17 const member = generateSecretKey();18 const stranger = generateSecretKey();19 await rpc(host, owner, "claim");20 const c = await WS.connect(host);2122 // owner-only writes23 expect((await rpc(host, owner, "setpolicy", { writes: "owner" })).result.writes).toBe("owner");24 expect((await c.ok(ev(stranger, 1, "x"))).msg).toMatch(/^restricted:/);25 expect((await c.ok(ev(owner, 1, "x"))).ok).toBe(true);2627 // allowlist28 await rpc(host, owner, "setpolicy", { writes: "allowlist" });29 expect((await c.ok(ev(member, 1, "y"))).msg).toMatch(/^restricted:/);30 await rpc(host, owner, "allowpubkey", getPublicKey(member), "friend");31 expect((await c.ok(ev(member, 1, "y"))).ok).toBe(true);32 expect((await rpc(host, owner, "listallowedpubkeys")).result).toEqual([{ pubkey: getPublicKey(member), reason: "friend" }]);33 expect((await rpc(host, owner, "listmembers")).result.members.map((m: any) => m.role)).toEqual(["owner", "member"]);34 await rpc(host, owner, "setpolicy", { writes: "open" });3536 // bans win over everything, and the owner cannot ban themselves37 await rpc(host, owner, "banpubkey", getPublicKey(stranger), "spam");38 expect((await c.ok(ev(stranger, 1, "z"))).msg).toMatch(/^blocked:/);39 expect((await rpc(host, owner, "banpubkey", getPublicKey(owner))).status).toBe(400);40 await rpc(host, owner, "unrulepubkey", getPublicKey(stranger));41 expect((await c.ok(ev(stranger, 1, "z"))).ok).toBe(true);4243 // ban an event: deleted now and refused later44 const e = ev(stranger, 1, "offensive");45 expect((await c.ok(e)).ok).toBe(true);46 await rpc(host, owner, "banevent", e.id, "nope");47 // Left open on purpose: the read rule below has to close it.48 expect((await c.open("q", { ids: [e.id] })).events).toEqual([]);49 expect((await c.ok(e)).msg).toMatch(/^blocked:/);50 expect((await rpc(host, owner, "listbannedevents")).result).toEqual([{ id: e.id, reason: "nope" }]);5152 // kind rules53 await rpc(host, owner, "disallowkind", 7);54 expect((await c.ok(ev(stranger, 7, "+"))).msg).toMatch(/kind 7/);55 await rpc(host, owner, "allowkind", 1);56 expect((await c.ok(ev(stranger, 30023, "a", [["d", "x"]]))).msg).toMatch(/kind 30023/);57 expect((await c.ok(ev(stranger, 1, "still fine"))).ok).toBe(true);58 expect((await rpc(host, owner, "listallowedkinds")).result).toEqual([1]);59 await rpc(host, owner, "unrulekind", 1);60 await rpc(host, owner, "unrulekind", 7);6162 // proof of work63 await rpc(host, owner, "setpolicy", { minPow: 4 });64 // One random id in sixteen starts with a zero nibble and would pass; pick one that does not.65 let weak = ev(stranger, 1, "weak");66 while (difficulty(weak).difficulty >= 4) weak = ev(stranger, 1, "weak " + Math.random());67 expect((await c.ok(weak)).msg).toMatch(/^pow:/);68 await rpc(host, owner, "setpolicy", { minPow: 0 });6970 // reads need auth: tightening the rule closes the subscription still open71 await rpc(host, owner, "setpolicy", { reads: "auth" });72 expect((await c.expect("CLOSED"))[2]).toMatch(/^auth-required:/);73 expect((await c.query({ kinds: [1] })).closed).toMatch(/^auth-required:/);74 await c.auth(stranger, host);75 expect((await c.req({ kinds: [1] })).length).toBeGreaterThan(0);7677 // NIP-11 reflects it all78 await rpc(host, owner, "changerelayname", "Gamma Club");79 await rpc(host, owner, "changerelaydescription", "members only");80 const info = await (await SELF.fetch(`http://${host}/`, { headers: { accept: "application/nostr+json" } })).json<any>();81 expect(info.name).toBe("Gamma Club");82 expect(info.limitation.auth_required).toBe(true);83 expect(info.limitation.restricted_writes).toBe(false);84 });8586 it("only the owner may manage", async () => {87 const host = "delta.bind.ws";88 const owner = generateSecretKey();89 await rpc(host, owner, "claim");90 const r = await rpc(host, generateSecretKey(), "setpolicy", { writes: "owner" });91 expect(r.status).toBe(403);92 expect(r.error).toMatch(/^restricted:/);93 expect((await rpc(host, generateSecretKey(), "supportedmethods")).result).toContain("claim");94 });95});9697describe("guests", () => {98 it("lets a stranger through a members-only rule for open kinds and for replies to members", async () => {99 const host = "guests.bind.ws";100 const owner = generateSecretKey();101 const member = generateSecretKey();102 const stranger = generateSecretKey();103 await rpc(host, owner, "claim");104 await rpc(host, owner, "setmember", pk(member), {});105 let r = await rpc(host, owner, "setpolicy", { writes: "allowlist", openKinds: [7, 7, 70000, -1], guestReplies: true });106 expect(r.result.openKinds).toEqual([7]);107 expect(r.result.guestReplies).toBe(true);108109 const c = await WS.connect(host);110 const note = ev(member, 1, "a member's note");111 expect((await c.ok(note))).toEqual({ ok: true, msg: "" });112 const outsiderNote = ev(stranger, 1, "hello?");113 expect((await c.ok(outsiderNote)).msg).toMatch(/only accepts events from its members/);114 // An open kind passes the rule; a reply to a member passes; a reply to nothing here does not.115 expect((await c.ok(ev(stranger, 7, "+", [["e", note.id]]))).ok).toBe(true);116 expect((await c.ok(ev(stranger, 1, "nice", [["e", note.id, "", "reply"]]))).ok).toBe(true);117 expect((await c.ok(ev(stranger, 1111, "a comment", [["E", note.id]]))).ok).toBe(true);118 expect((await c.ok(ev(stranger, 1, "into the void", [["e", "ab".repeat(32)]]))).ok).toBe(false);119 // A reply to another stranger's event is not a reply to a member.120 const guestReply = (await c.req({ kinds: [1], authors: [pk(stranger)] }))[0];121 expect((await c.ok(ev(generateSecretKey(), 1, "reply to a guest", [["e", guestReply.id]]))).ok).toBe(false);122 // Bans still win, and so do kind rules.123 await rpc(host, owner, "disallowkind", 7);124 expect((await c.ok(ev(stranger, 7, "-", [["e", note.id]]))).msg).toMatch(/does not accept kind 7/);125 await rpc(host, owner, "setpolicy", { guestReplies: false });126 expect((await c.ok(ev(stranger, 1, "again", [["e", note.id]]))).ok).toBe(false);127 // Only me plus open kinds: the owner's rule, guests' kinds.128 await rpc(host, owner, "unrulekind", 7);129 await rpc(host, owner, "setpolicy", { writes: "owner" });130 expect((await c.ok(ev(member, 1, "member note"))).msg).toMatch(/only the relay owner/);131 expect((await c.ok(ev(member, 7, "+", [["e", note.id]]))).ok).toBe(true);132 });133});134135describe("web of trust", () => {136 it("admits the people members follow, and follows the lists as they change", async () => {137 const host = "wot.bind.ws";138 const owner = generateSecretKey();139 const member = generateSecretKey();140 const friend = generateSecretKey();141 const friendOfMember = generateSecretKey();142 const stranger = generateSecretKey();143 await rpc(host, owner, "claim");144 const c = await WS.connect(host);145 expect((await c.ok(ev(owner, 3, "", [["p", pk(friend)]]))).ok).toBe(true);146 const r = await rpc(host, owner, "setpolicy", { writes: "wot" });147 expect(r.result.writes).toBe("wot");148 expect((await c.ok(ev(friend, 1, "a friend of the owner"))).ok).toBe(true);149 expect((await c.ok(ev(stranger, 1, "nobody follows me"))).msg).toMatch(/members and the people they follow/);150 expect((await upload(host, stranger, "a stranger's file")).status).toBe(403);151 expect((await upload(host, friend, "a friend's file")).status).toBe(200);152153 // A member's list arriving extends the web; the member leaving shrinks it.154 await rpc(host, owner, "setmember", pk(member), {});155 expect((await c.ok(ev(member, 3, "", [["p", pk(friendOfMember)]]))).ok).toBe(true);156 expect((await c.ok(ev(friendOfMember, 1, "followed by a member"))).ok).toBe(true);157 await rpc(host, owner, "removemember", pk(member));158 expect((await c.ok(ev(friendOfMember, 1, "the member left"))).ok).toBe(false);159 // The owner's newer list without the friend drops them.160 expect((await c.ok(ev(owner, 3, "", [["p", pk(stranger)]], now() + 1))).ok).toBe(true);161 expect((await c.ok(ev(friend, 1, "unfollowed"))).ok).toBe(false);162 expect((await c.ok(ev(stranger, 1, "now followed"))).ok).toBe(true);163 // The group flags read the rule as restricted and closed.164 const info: any = await (await SELF.fetch(`http://${host}/`, { headers: { accept: "application/nostr+json" } })).json();165 expect(info.limitation.restricted_writes).toBe(true);166 const meta = (await c.req({ kinds: [39000] }))[0];167 expect(meta.tags.some((t: string[]) => t[0] === "restricted")).toBe(true);168 expect(meta.tags.some((t: string[]) => t[0] === "closed")).toBe(true);169 });170});171172describe("blocked words", () => {173 it("refuses content with a blocked word from anyone but the owner and moderators", async () => {174 const host = "words.bind.ws";175 const owner = generateSecretKey();176 const mod = generateSecretKey();177 const member = generateSecretKey();178 await rpc(host, owner, "claim");179 await rpc(host, owner, "setmember", pk(mod), { role: "moderator" });180 await rpc(host, owner, "setmember", pk(member), {});181 let r = await rpc(host, owner, "setblockedwords", [" Casino ", "free money", "x", "a".repeat(65), "casino"]);182 expect(r.result).toEqual(["casino", "free money"]);183 expect((await rpc(host, owner, "getpolicy")).result.blockedWords).toEqual(["casino", "free money"]);184 // A moderator may set the list too.185 r = await rpc(host, mod, "setblockedwords", ["casino"]);186 expect(r.status).toBe(200);187 expect((await rpc(host, member, "setblockedwords", ["casino"])).status).toBe(403);188189 const c = await WS.connect(host);190 expect((await c.ok(ev(member, 1, "Big CASINO night"))).msg).toBe("blocked: content contains a blocked word");191 expect((await c.ok(ev(generateSecretKey(), 1, "casino"))).ok).toBe(false);192 expect((await c.ok(ev(member, 1, "a quiet evening"))).ok).toBe(true);193 expect((await c.ok(ev(owner, 1, "the casino is closed"))).ok).toBe(true);194 expect((await c.ok(ev(mod, 1, "no casino talk please"))).ok).toBe(true);195 // The list travels with the configuration.196 const cfg = (await rpc(host, owner, "exportconfig")).result;197 expect(cfg.policy.blockedWords).toEqual(["casino"]);198 });199});200201describe("blocked words in tags and as patterns", () => {202 it("takes /patterns/, refuses a bad one with the reason, and reaches into tags on request", async () => {203 const host = "patterns.bind.ws";204 const owner = generateSecretKey();205 const guest = generateSecretKey();206 await rpc(host, owner, "claim");207208 // A pattern that does not compile fails the call; nothing is saved.209 const bad = await rpc(host, owner, "setblockedwords", ["spam", "/(unclosed/"]);210 expect(bad.status).toBe(400);211 expect(bad.error).toMatch(/^invalid: /);212 expect((await rpc(host, owner, "getpolicy")).result.blockedWords).toEqual([]);213 const long = await rpc(host, owner, "setblockedwords", ["/" + "a".repeat(200) + "/"]);214 expect(long.status).toBe(400);215 expect(long.error).toMatch(/longer than 200/);216217 // Plain words are lowercased; a pattern keeps its case and is compiled case-insensitive.218 const kept = (await rpc(host, owner, "setblockedwords", ["Casino", "/free\\s+money/", "/\\bwin\\d{3,}/"])).result;219 expect(kept).toEqual(["casino", "/free\\s+money/", "/\\bwin\\d{3,}/"]);220221 const ws = await WS.connect(host);222 expect((await ws.ok(ev(guest, 1, "FREE MONEY here"))).msg).toBe("blocked: content contains a blocked word");223 expect((await ws.ok(ev(guest, 1, "you Win1234 now"))).msg).toBe("blocked: content contains a blocked word");224 expect((await ws.ok(ev(guest, 1, "winner takes all"))).ok).toBe(true);225 expect((await ws.ok(ev(guest, 1, "a night at the CASINO"))).ok).toBe(false);226 // The owner says what they like.227 expect((await ws.ok(ev(owner, 1, "free money"))).ok).toBe(true);228229 // Tags are not searched until the switch is on.230 expect((await ws.ok(ev(guest, 1, "look", [["t", "casino"]]))).ok).toBe(true);231 expect((await rpc(host, owner, "setpolicy", { blockedWordsInTags: true })).result.blockedWordsInTags).toBe(true);232 const tagged = await ws.ok(ev(guest, 1, "look again", [["t", "Casino"]]));233 expect(tagged.ok).toBe(false);234 expect(tagged.msg).toBe("blocked: a tag contains a blocked word");235 expect((await ws.ok(ev(guest, 1, "pattern in a tag", [["r", "https://x.example/free money"]]))).ok).toBe(false);236 // The tag name itself is not a value.237 expect((await ws.ok(ev(guest, 1, "fine", [["casino", "x"]]))).ok).toBe(true);238 await rpc(host, owner, "setpolicy", { blockedWordsInTags: false });239 expect((await ws.ok(ev(guest, 1, "look once more", [["t", "casino"]]))).ok).toBe(true);240 });241});242243describe("message size", () => {244 it("is the owner's rule, clamped, and NIP-11 says so", async () => {245 const host = "msgsize.bind.ws";246 const owner = generateSecretKey();247 await rpc(host, owner, "claim");248 expect((await info(host)).limitation.max_message_length).toBe(128 * 1024);249250 // Below the floor and above the ceiling both clamp.251 expect((await rpc(host, owner, "setpolicy", { maxMessageKB: 1 })).result.maxMessageKB).toBe(16);252 expect((await rpc(host, owner, "setpolicy", { maxMessageKB: 5000 })).result.maxMessageKB).toBe(1024);253 expect((await rpc(host, owner, "setpolicy", { maxMessageKB: 16 })).result.maxMessageKB).toBe(16);254 expect((await info(host)).limitation.max_message_length).toBe(16 * 1024);255256 const ws = await WS.connect(host);257 // A note of 20 KB is over the 16 KB cap: refused with a NOTICE, not stored.258 const big = ev(owner, 1, "x".repeat(20 * 1024));259 ws.raw(JSON.stringify(["EVENT", big]));260 const notice = await ws.expect("NOTICE");261 expect(notice[1]).toBe("error: message too large");262 // A small one still lands.263 expect((await ws.ok(ev(owner, 1, "small"))).ok).toBe(true);264265 // Raising the cap lets the same note through.266 await rpc(host, owner, "setpolicy", { maxMessageKB: 64 });267 expect((await ws.ok(big)).ok).toBe(true);268 });269});270271describe("rate limits", () => {272 it("throttles a chatty connection with a retry hint", async () => {273 const host = "limits.bind.ws";274 const owner = generateSecretKey();275 await rpc(host, owner, "claim");276 await rpc(host, owner, "setpolicy", { eventsPerMinute: 2, reqsPerMinute: 2 });277 const c = await WS.connect(host);278 expect((await c.ok(ev(owner, 1, "a"))).ok).toBe(true);279 expect((await c.ok(ev(owner, 1, "b"))).ok).toBe(true);280 const third = await c.ok(ev(owner, 1, "c"));281 expect(third.ok).toBe(false);282 expect(third.msg).toMatch(/^rate-limited: quota exceeded; retry in \d+s$/);283 await c.open("r1", { kinds: [1] });284 await c.open("r2", { kinds: [1] });285 expect((await c.open("r3", { kinds: [1] })).closed).toMatch(/^rate-limited:/);286 });287});288289describe("per-address rate limit", () => {290 it("caps all sockets from one address at four times a connection's allowance", async () => {291 const host = "swarm.bind.ws";292 const owner = generateSecretKey();293 await rpc(host, owner, "claim");294 await rpc(host, owner, "setpolicy", { eventsPerMinute: 5 });295 const ip = "198.51.100.7";296 const socks: WS[] = [];297 for (let i = 0; i < 6; i++) socks.push((await WS.tryConnect(host, ip))!);298 // Four per socket stays under the per-connection five; the address299 // allowance is twenty, so the twenty-first event across them is refused.300 let accepted = 0;301 let refused = "";302 for (const c of socks) {303 for (let j = 0; j < 4 && !refused; j++) {304 const r = await c.ok(ev(owner, 1, `n${accepted}`));305 if (r.ok) accepted++;306 else refused = r.msg;307 }308 }309 expect(accepted).toBe(20);310 expect(refused).toMatch(/^rate-limited:/);311 // A lone socket from elsewhere is only bound by its own bucket.312 const alone = (await WS.tryConnect(host, "198.51.100.8"))!;313 for (let j = 0; j < 4; j++) expect((await alone.ok(ev(owner, 1, `alone${j}`))).ok).toBe(true);314 });315316 it("is the bridge's rate limit too", async () => {317 const host = "bridgelimit.bind.ws";318 const owner = generateSecretKey();319 await rpc(host, owner, "claim");320 await rpc(host, owner, "setpolicy", { reqsPerMinute: 1 });321 const ip = "198.51.100.9";322 for (let i = 0; i < 4; i++) expect((await post(host, owner, "/count", [{ kinds: [1] }], { "cf-connecting-ip": ip })).status).toBe(200);323 const fifth = await post(host, owner, "/count", [{ kinds: [1] }], { "cf-connecting-ip": ip });324 expect(fifth.status).toBe(429);325 expect(fifth.body.error).toMatch(/^rate-limited:/);326 expect((await post(host, owner, "/count", [{ kinds: [1] }], { "cf-connecting-ip": "198.51.100.10" })).status).toBe(200);327 });328});329330describe("address blocks", () => {331 it("refuses the socket and the doors, drops open sockets, and can be undone", async () => {332 const host = "blocks.bind.ws";333 const owner = generateSecretKey();334 const mod = generateSecretKey();335 const member = generateSecretKey();336 const bad = "203.0.113.5";337 await rpc(host, owner, "claim");338 await rpc(host, owner, "setmember", pk(mod), { role: "moderator" });339 await rpc(host, owner, "setmember", pk(member), {});340341 const open = (await WS.tryConnect(host, bad))!;342 expect((await open.ok(ev(member, 1, "before"))).ok).toBe(true);343 expect((await rpc(host, member, "blockip", bad, "scraper")).status).toBe(403);344 expect((await rpc(host, mod, "blockip", "not an address", "x")).status).toBe(400);345 expect((await rpc(host, mod, "blockip", bad, "scraper")).result).toBe(true);346 expect((await rpc(host, owner, "listblockedips")).result).toEqual([{ ip: bad, reason: "scraper" }]);347348 for (let i = 0; i < 40 && !open.closed; i++) await sleep(25);349 expect(open.closed?.code).toBe(4403);350 expect(await WS.tryConnect(host, bad)).toBeNull();351 const refused = await SELF.fetch(`http://${host}/`, { headers: { upgrade: "websocket", "cf-connecting-ip": bad } });352 expect(refused.status).toBe(403);353 const door = await post(host, member, "/events", ev(member, 1, "still here?"), { "cf-connecting-ip": bad });354 expect(door.status).toBe(403);355 expect(door.body.error).toMatch(/^blocked: this address/);356 expect((await post(host, member, "/count", [{ kinds: [1] }], { "cf-connecting-ip": bad })).status).toBe(403);357 // Another address is unaffected, and so is management from the blocked one.358 expect((await post(host, member, "/events", ev(member, 1, "elsewhere"), { "cf-connecting-ip": "203.0.113.6" })).status).toBe(200);359 expect(await WS.tryConnect(host, "203.0.113.6")).not.toBeNull();360 expect((await rpc(host, owner, "stats")).status).toBe(200);361 // Blocks are part of the portable configuration.362 expect((await rpc(host, owner, "exportconfig")).result.addresses).toEqual([{ ip: bad, reason: "scraper" }]);363364 expect((await rpc(host, owner, "unblockip", bad)).result).toBe(true);365 expect((await rpc(host, owner, "listblockedips")).result).toEqual([]);366 const again = await WS.tryConnect(host, bad);367 expect(again).not.toBeNull();368 expect((await again!.ok(ev(member, 1, "back"))).ok).toBe(true);369 });370371 it("accepts IPv6 and rejects ranges and names", async () => {372 const host = "blocks6.bind.ws";373 const owner = generateSecretKey();374 await rpc(host, owner, "claim");375 expect((await rpc(host, owner, "blockip", "2001:DB8::1", "")).result).toBe(true);376 expect((await rpc(host, owner, "listblockedips")).result).toEqual([{ ip: "2001:db8::1", reason: "" }]);377 expect(await WS.tryConnect(host, "2001:db8::1")).toBeNull();378 for (const bad of ["10.0.0.0/8", "relay.example", "1.2.3", "::::", "999.1.1.1"]) expect((await rpc(host, owner, "blockip", bad, "")).status).toBe(400);379 });380});