- commit
- d6bbcaba5a3de3bc81e3cdb952864a6c7f9b8453
- parent
- 6d800c1fcf5835e76aa7528d60d4dac0f3234d26
- author
- Dami <dami.etoile@gmail.com>
| M | src/ | 3 | +- |
| A | src/ | 87 | +++++++++++++++++++++++++++ |
| M | src/ | 95 | +++--------------------------- |
@@ -11,6 +11,7 @@ import { validate, type Event } from "./event.ts";
1111 import { parseFilter, type Filter } from "./filter.ts";1212 import { hllOffset } from "./hll.ts";1313 import { verifyNIP98 } from "./manage.ts";14+import { readGate } from "./gates.ts";1415 import type { Relay } from "./relay.ts";1516 1617 export async function bridge(relay: Relay, req: Request): Promise<Response> {@@ -53,7 +54,7 @@ export async function bridge(relay: Relay, req: Request): Promise<Response> {
5354 if (typeof f === "string") return json({ error: "invalid: bad filter: " + f }, 400);5455 filters.push(f);5556 }56- const gate = relay.allowFilters(conn, filters);57+ const gate = readGate(relay, conn, filters);5758 if (gate.reason) return json({ error: gate.reason }, gate.reason.startsWith("auth-required") || gate.reason.startsWith("restricted") ? 403 : 400);5859 const who = { pubkeys: conn.authed };5960 const p = relay.settings.policy;@@ -0,0 +1,87 @@
1+// The two gates every client passes. The write gate is what a published2+// event must clear whoever sends it: shape, bans, blocked words, the3+// relay's state, fuel, the one-group rule. The read gate is what a4+// subscription's filters must clear for the socket that opened it: the5+// read rule, and the private kinds only their parties may see. Both answer6+// "" to let through, or the reason in NIP-01's prefix: form.7+import type { Relay, ConnState } from "./relay.ts";8+import { expiration, hasTag, isPrivate, tagValues, type Event } from "./event.ts";9+import type { Filter } from "./filter.ts";10+import { KIND_AUTH, KIND_NOSTR_CONNECT } from "./kinds.ts";11+import { isGroupState } from "./groups.ts";12+13+// writeGate is what every write must pass, whoever sends it: shape, bans, the14+// relay's state, fuel, and the one-group rule. "" lets it through.15+export function writeGate(relay: Relay, e: Event, conn: ConnState | null, t: number): string {16+ const p = relay.settings.policy;17+ if (e.kind === KIND_AUTH) return "blocked: kind 22242 is only accepted inside an AUTH message";18+ if (p.maxFuture > 0 && e.created_at > t + p.maxFuture) return "invalid: event creation date is too far off from the current time";19+ const exp = expiration(e);20+ if (exp > 0 && exp <= t) return "invalid: event has already expired";21+ if (relay.settings.isBanned(e.pubkey)) return "blocked: this pubkey is banned from this relay";22+ if (relay.settings.isEventBanned(e.id)) return "blocked: this event is banned from this relay";23+ // Blocked words: the owner and moderators may say anything, nobody else may say these.24+ const where = relay.settings.hasBlockedWord(e.content, e.tags);25+ if (where) {26+ const role = relay.settings.roleOf(e.pubkey);27+ if (role !== "owner" && role !== "moderator") return where === "tags" ? "blocked: a tag contains a blocked word" : "blocked: content contains a blocked word";28+ }29+ // NIP-46 traffic passes the ownership, fuel and write gates: it is30+ // ephemeral, never stored, and readable only by its two parties, and31+ // letting it through means this relay can carry a remote signer's32+ // session, even for the person about to claim it from a phone. Bans33+ // and the per-connection rate limit still apply.34+ if (e.kind === KIND_NOSTR_CONNECT) return "";35+ const h = tagValues(e, "h")[0];36+ if (h !== undefined && h !== relay.slug) return "blocked: this relay hosts one group: " + relay.slug;37+ if (conn) {38+ if (isGroupState(e.kind)) return "blocked: group metadata is written by the relay";39+ if (relay.settings.isUnclaimed()) return "restricted: this relay is unclaimed; open https://" + conn.host + "/ to claim it";40+ if (relay.settings.leaseExpired(t)) return "restricted: this temporary relay has expired";41+ const f = relay.fuelStatus();42+ if (f.outOfFuel) {43+ return f.enabled ? "restricted: this relay is out of fuel; zap it at https://" + conn.host + "/ to top up" : "restricted: this relay has reached its storage or traffic limit";44+ }45+ if (hasTag(e, "-") && !conn.authed.includes(e.pubkey)) return "auth-required: this event may only be published by its author";46+ }47+ return "";48+}49+50+// guestPass lets a stranger through a limited write rule: a kind the owner51+// opened to anyone, or, when replies are open, a note or comment that52+// answers something a member or the owner wrote here.53+export function guestPass(relay: Relay, e: Event): boolean {54+ const p = relay.settings.policy;55+ if (p.openKinds.includes(e.kind)) return true;56+ if (!p.guestReplies || (e.kind !== 1 && e.kind !== 1111)) return false;57+ const parents = [...tagValues(e, "e"), ...tagValues(e, "E")].filter((id) => /^[0-9a-f]{64}$/.test(id)).slice(0, 5);58+ for (const id of parents) {59+ const row = relay.sql.exec<{ pubkey: string }>(`SELECT pubkey FROM events WHERE id=?`, id).toArray()[0];60+ if (row && relay.settings.isAllowed(row.pubkey)) return true;61+ }62+ return false;63+}64+65+// readGate returns a CLOSED reason, or "" plus whether an EOSE "auth"66+// hint applies because private kinds were silently filtered out.67+export function readGate(relay: Relay, s: ConnState, filters: Filter[]): { reason: string; authHint: boolean } {68+ // A subscription to NIP-46 traffic alone may be opened under any read69+ // policy, with or without AUTH; the traffic itself is a private kind,70+ // delivered only to its parties (canSee).71+ if (filters.length > 0 && filters.every((f) => f.kinds?.length === 1 && f.kinds[0] === KIND_NOSTR_CONNECT)) return { reason: "", authHint: false };72+ const authed = s.authed.length > 0;73+ const gate = relay.settings.mayRead(s.authed);74+ if (gate) return { reason: gate, authHint: false };75+ if (authed) return { reason: "", authHint: false };76+ let authHint = false;77+ for (const f of filters) {78+ if (!f.kinds || f.kinds.length === 0) {79+ authHint = true;80+ continue;81+ }82+ const priv = f.kinds.filter(isPrivate).length;83+ if (priv === f.kinds.length) return { reason: "auth-required: private kinds are only served to their recipients", authHint: false };84+ if (priv > 0) authHint = true;85+ }86+ return { reason: "", authHint };87+}@@ -12,6 +12,7 @@ import { Audit } from "./audit.ts";
1212 import { ERR_DUPLICATE, ERR_TOO_BIG, Store, type Access } from "./store.ts";1313 import { Settings, isReplaceable, isProtected, SUCCESSION_WARN_DAYS } from "./settings.ts";1414 import { manage } from "./manage.ts";15+import { guestPass, readGate, writeGate } from "./gates.ts";1516 import { dashboard } from "./dashboard.ts";1617 import { Fuel, fuelConfig, fetchLnurl, requestInvoice, type Fetcher, type LnurlParams } from "./fuel.ts";1718 import { Identity } from "./identity.ts";@@ -30,7 +31,7 @@ import type { PullFilter, PullJob, PullResult } from "./pull.ts";
3031 import { leaseDays, leaseNames, validName } from "./names.ts";3132 import { MAX_JOBS, MAX_STANDING, checkJob, finishRun, newJobID, pruneFinished, pullView, runRound, startRun, type Job, type JobSpec } from "./jobs.ts";3233 import { Hostnames, forgetDomains } from "./domains.ts";33-import { groupFacts, handleGroupEvent, isGroupManagement, isGroupState, isNIP43Request } from "./groups.ts";34+import { groupFacts, handleGroupEvent, isGroupManagement, isNIP43Request } from "./groups.ts";3435 import { SIGNER_JS } from "./gen/signer.ts";3536 import { isPagePath, pages } from "./pages.ts";3637 import { notify, fuelLow, fuelText } from "./notify.ts";@@ -92,7 +93,7 @@ const LEASE_RETENTION_DAYS = 14;
9293 const FORK_INTERVAL = 3600;9394 9495 // ConnState is everything about a websocket that must survive hibernation.95-interface ConnState {96+export interface ConnState {9697 challenge: string;9798 host: string; // Host header, for NIP-42 relay-tag checks9899 ip: string; // the client's address as the worker saw it; "unknown" outside Cloudflare@@ -474,7 +475,7 @@ export class Relay extends DurableObject<Env> {
474475 const s = this.state(ws);475476 let changed = false;476477 for (const [id, filters] of Object.entries(s.subs)) {477- const { reason } = this.allowFilters(s, filters);478+ const { reason } = readGate(this, s, filters);478479 if (!reason) continue;479480 this.send(ws, "CLOSED", id, reason);480481 delete s.subs[id];@@ -1327,7 +1328,7 @@ export class Relay extends DurableObject<Env> {
13271328 // then role checks instead of the write policy, then stored like any event.13281329 private async acceptGroup(e: Event, conn: ConnState): Promise<{ ok: boolean; msg: string; stored: boolean }> {13291330 const t = now();1330- const reason = this.gate(e, conn, t);1331+ const reason = writeGate(this, e, conn, t);13311332 if (reason) return { ok: false, msg: reason, stored: false };13321333 if (this.sql.exec(`SELECT 1 FROM events WHERE id=?`, e.id).toArray().length) return { ok: true, msg: ERR_DUPLICATE, stored: false };13331334 const r = await handleGroupEvent(this, e);@@ -1337,43 +1338,6 @@ export class Relay extends DurableObject<Env> {
13371338 return { ok: true, msg: "", stored: true };13381339 }13391340 1340- // gate is what every write must pass, whoever sends it: shape, bans, the1341- // relay's state, fuel, and the one-group rule. "" lets it through.1342- private gate(e: Event, conn: ConnState | null, t: number): string {1343- const p = this.settings.policy;1344- if (e.kind === KIND_AUTH) return "blocked: kind 22242 is only accepted inside an AUTH message";1345- if (p.maxFuture > 0 && e.created_at > t + p.maxFuture) return "invalid: event creation date is too far off from the current time";1346- const exp = expiration(e);1347- if (exp > 0 && exp <= t) return "invalid: event has already expired";1348- if (this.settings.isBanned(e.pubkey)) return "blocked: this pubkey is banned from this relay";1349- if (this.settings.isEventBanned(e.id)) return "blocked: this event is banned from this relay";1350- // Blocked words: the owner and moderators may say anything, nobody else may say these.1351- const where = this.settings.hasBlockedWord(e.content, e.tags);1352- if (where) {1353- const role = this.settings.roleOf(e.pubkey);1354- if (role !== "owner" && role !== "moderator") return where === "tags" ? "blocked: a tag contains a blocked word" : "blocked: content contains a blocked word";1355- }1356- // NIP-46 traffic passes the ownership, fuel and write gates: it is1357- // ephemeral, never stored, and readable only by its two parties, and1358- // letting it through means this relay can carry a remote signer's1359- // session, even for the person about to claim it from a phone. Bans1360- // and the per-connection rate limit still apply.1361- if (e.kind === KIND_NOSTR_CONNECT) return "";1362- const h = tagValues(e, "h")[0];1363- if (h !== undefined && h !== this.slug) return "blocked: this relay hosts one group: " + this.slug;1364- if (conn) {1365- if (isGroupState(e.kind)) return "blocked: group metadata is written by the relay";1366- if (this.settings.isUnclaimed()) return "restricted: this relay is unclaimed; open https://" + conn.host + "/ to claim it";1367- if (this.settings.leaseExpired(t)) return "restricted: this temporary relay has expired";1368- const f = this.fuelStatus();1369- if (f.outOfFuel) {1370- return f.enabled ? "restricted: this relay is out of fuel; zap it at https://" + conn.host + "/ to top up" : "restricted: this relay has reached its storage or traffic limit";1371- }1372- if (hasTag(e, "-") && !conn.authed.includes(e.pubkey)) return "auth-required: this event may only be published by its author";1373- }1374- return "";1375- }1376-13771341 // acceptReport files a NIP-56 report in the moderation queue. It is never13781342 // stored as an event or served: reports are for the owner, not the feed.13791343 private acceptReport(e: Event, host = ""): { ok: boolean; msg: string; stored: boolean } {@@ -1400,28 +1364,13 @@ export class Relay extends DurableObject<Env> {
14001364 return { ok: true, msg: "info: report received", stored: false };14011365 }14021366 1403- // guestPass lets a stranger through a limited write rule: a kind the owner1404- // opened to anyone, or, when replies are open, a note or comment that1405- // answers something a member or the owner wrote here.1406- private guestPass(e: Event): boolean {1407- const p = this.settings.policy;1408- if (p.openKinds.includes(e.kind)) return true;1409- if (!p.guestReplies || (e.kind !== 1 && e.kind !== 1111)) return false;1410- const parents = [...tagValues(e, "e"), ...tagValues(e, "E")].filter((id) => /^[0-9a-f]{64}$/.test(id)).slice(0, 5);1411- for (const id of parents) {1412- const row = this.sql.exec<{ pubkey: string }>(`SELECT pubkey FROM events WHERE id=?`, id).toArray()[0];1413- if (row && this.settings.isAllowed(row.pubkey)) return true;1414- }1415- return false;1416- }1417-14181367 // accept runs the write-side rules and stores the event. conn is null for14191368 // host-side publishes (the dashboard), which skip client policy.14201369 accept(e: Event, conn: ConnState | null): { ok: boolean; msg: string; stored: boolean } {14211370 const p = this.settings.policy;14221371 const t = now();14231372 const no = (msg: string) => ({ ok: false, msg, stored: false });1424- const gate = this.gate(e, conn, t);1373+ const gate = writeGate(this, e, conn, t);14251374 if (gate) return no(gate);14261375 if (e.kind === KIND_NOSTR_CONNECT) return { ok: true, msg: "", stored: true }; // see gate14271376 const exp = expiration(e);@@ -1440,7 +1389,7 @@ export class Relay extends DurableObject<Env> {
14401389 if (lim.cap > 0 && this.store.authorBytes(e.pubkey) + canonical(e).length > lim.cap) return no(`restricted: you have reached your storage cap of ${Math.max(1, Math.round(lim.cap / 1024))} KB on this relay`);14411390 }14421391 const why = this.settings.mayWrite(e.pubkey);1443- if (why && !this.guestPass(e)) return no(why);1392+ if (why && !guestPass(this, e)) return no(why);14441393 if (p.minPow > 0) {14451394 const d = difficulty(e);14461395 if (d.difficulty < p.minPow) return no(`pow: difficulty ${d.difficulty} is less than ${p.minPow}`);@@ -1593,36 +1542,12 @@ export class Relay extends DurableObject<Env> {
15931542 return filters;15941543 }15951544 1596- // allowFilters returns a CLOSED reason, or "" plus whether an EOSE "auth"1597- // hint applies because private kinds were silently filtered out.1598- allowFilters(s: ConnState, filters: Filter[]): { reason: string; authHint: boolean } {1599- // A subscription to NIP-46 traffic alone may be opened under any read1600- // policy, with or without AUTH; the traffic itself is a private kind,1601- // delivered only to its parties (canSee).1602- if (filters.length > 0 && filters.every((f) => f.kinds?.length === 1 && f.kinds[0] === KIND_NOSTR_CONNECT)) return { reason: "", authHint: false };1603- const authed = s.authed.length > 0;1604- const gate = this.settings.mayRead(s.authed);1605- if (gate) return { reason: gate, authHint: false };1606- if (authed) return { reason: "", authHint: false };1607- let authHint = false;1608- for (const f of filters) {1609- if (!f.kinds || f.kinds.length === 0) {1610- authHint = true;1611- continue;1612- }1613- const priv = f.kinds.filter(isPrivate).length;1614- if (priv === f.kinds.length) return { reason: "auth-required: private kinds are only served to their recipients", authHint: false };1615- if (priv > 0) authHint = true;1616- }1617- return { reason: "", authHint };1618- }1619-16201545 private handleReq(ws: WebSocket, s: ConnState, verb: string, rawID: unknown, rawFilters: unknown[]) {16211546 if (typeof rawID !== "string" || rawID === "" || rawID.length > 64) return this.send(ws, "NOTICE", "error: bad subscription id");16221547 const id = rawID;16231548 const filters = this.parseFilters(rawFilters);16241549 if (typeof filters === "string") return this.send(ws, "CLOSED", id, filters);1625- const { reason, authHint } = this.allowFilters(s, filters);1550+ const { reason, authHint } = readGate(this, s, filters);16261551 if (reason) return this.send(ws, "CLOSED", id, reason);16271552 const who: Access = { pubkeys: s.authed };16281553 const t = now();@@ -1720,7 +1645,7 @@ export class Relay extends DurableObject<Env> {
17201645 if (rest.length < 2) return this.send(ws, "NEG-ERR", id, "error: NEG-OPEN needs a filter and a message");17211646 const f = parseFilter(rest[0]);17221647 if (typeof f === "string") return this.send(ws, "NEG-ERR", id, "invalid: bad filter: " + f);1723- const { reason } = this.allowFilters(s, [f]);1648+ const { reason } = readGate(this, s, [f]);17241649 if (reason) return this.send(ws, "NEG-ERR", id, reason);17251650 const open = new Set([...sessions.keys(), ...Object.keys(s.syncs ?? {})]);17261651 if (!open.has(id) && open.size >= this.settings.policy.maxSubs) return this.send(ws, "NEG-ERR", id, "blocked: too many open syncs");@@ -1736,7 +1661,7 @@ export class Relay extends DurableObject<Env> {
17361661 // A wake since NEG-OPEN. The read rule is asked again with what the17371662 // socket has proved by now, and the items are read afresh, so the17381663 // session carries on from the store as it is.1739- const { reason } = this.allowFilters(s, [f]);1664+ const { reason } = readGate(this, s, [f]);17401665 if (reason) {17411666 remember(null);17421667 return this.send(ws, "NEG-ERR", id, reason);