From 44dfd15502218f171c79a4f5d3698e68cc47a554 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Thu, 20 Jun 2024 16:15:31 -0500 Subject: [PATCH 1/7] streamingController: rate-limit with ttl-cache --- src/controllers/api/streaming.ts | 25 ++++++++++++++++++++++++- 1 file changed, 24 insertions(+), 1 deletion(-) diff --git a/src/controllers/api/streaming.ts b/src/controllers/api/streaming.ts index 552ea3bd..b653ab2c 100644 --- a/src/controllers/api/streaming.ts +++ b/src/controllers/api/streaming.ts @@ -9,9 +9,10 @@ import { MuteListPolicy } from '@/policies/MuteListPolicy.ts'; import { getFeedPubkeys } from '@/queries.ts'; import { hydrateEvents } from '@/storages/hydrate.ts'; import { Storages } from '@/storages.ts'; -import { bech32ToPubkey } from '@/utils.ts'; +import { bech32ToPubkey, Time } from '@/utils.ts'; import { renderReblog, renderStatus } from '@/views/mastodon/statuses.ts'; import { renderNotification } from '@/views/mastodon/notifications.ts'; +import TTLCache from '@isaacs/ttlcache'; const debug = Debug('ditto:streaming'); @@ -36,6 +37,11 @@ const streamSchema = z.enum([ type Stream = z.infer; +const LIMITER_WINDOW = Time.minutes(5); +const LIMITER_LIMIT = 100; + +const limiter = new TTLCache(); + const streamingController: AppController = async (c) => { const upgrade = c.req.header('upgrade'); const token = c.req.header('sec-websocket-protocol'); @@ -56,6 +62,7 @@ const streamingController: AppController = async (c) => { const store = await Storages.db(); const pubsub = await Storages.pubsub(); + const ip = c.req.header('x-real-ip'); const policy = pubkey ? new MuteListPolicy(pubkey, await Storages.admin()) : undefined; function send(name: string, payload: object) { @@ -119,6 +126,22 @@ const streamingController: AppController = async (c) => { } }; + socket.onmessage = (e) => { + if (ip) { + const count = limiter.get(ip) ?? 0; + limiter.set(ip, count + 1, { ttl: LIMITER_WINDOW }); + + if (count > LIMITER_LIMIT) { + socket.close(1008, 'Rate limit exceeded'); + return; + } + } + + if (typeof e.data !== 'string') { + socket.close(1003, 'Invalid message'); + } + }; + socket.onclose = () => { controller.abort(); }; From 53e7e856c1e3ba0dccac1d9d664f363cfeb84dea Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Thu, 20 Jun 2024 16:20:18 -0500 Subject: [PATCH 2/7] streamingController: bail early if limited --- src/controllers/api/streaming.ts | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/src/controllers/api/streaming.ts b/src/controllers/api/streaming.ts index b653ab2c..e3ead8c0 100644 --- a/src/controllers/api/streaming.ts +++ b/src/controllers/api/streaming.ts @@ -57,12 +57,19 @@ const streamingController: AppController = async (c) => { return c.json({ error: 'Invalid access token' }, 401); } + const ip = c.req.header('x-real-ip'); + if (ip) { + const count = limiter.get(ip) ?? 0; + if (count > LIMITER_LIMIT) { + return c.json({ error: 'Rate limit exceeded' }, 429); + } + } + const { socket, response } = Deno.upgradeWebSocket(c.req.raw, { protocol: token, idleTimeout: 30 }); const store = await Storages.db(); const pubsub = await Storages.pubsub(); - const ip = c.req.header('x-real-ip'); const policy = pubkey ? new MuteListPolicy(pubkey, await Storages.admin()) : undefined; function send(name: string, payload: object) { @@ -139,6 +146,7 @@ const streamingController: AppController = async (c) => { if (typeof e.data !== 'string') { socket.close(1003, 'Invalid message'); + return; } }; From 216710657724a7fe052c6d199a89d571eb915119 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Sat, 6 Jul 2024 20:35:12 +0100 Subject: [PATCH 3/7] Fix not being able to post --- src/storages/EventsDB.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/storages/EventsDB.ts b/src/storages/EventsDB.ts index f640cc45..d66a65b7 100644 --- a/src/storages/EventsDB.ts +++ b/src/storages/EventsDB.ts @@ -250,6 +250,8 @@ class EventsDB implements NStore { /** Converts filters to more performant, simpler filters that are better for SQLite. */ async expandFilters(filters: NostrFilter[]): Promise { + filters = structuredClone(filters); + for (const filter of filters) { if (filter.search) { const tokens = NIP50.parseInput(filter.search); From 842adfd72b15f22230803b23eb6e0c8f2662f127 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Sat, 6 Jul 2024 22:59:18 +0100 Subject: [PATCH 4/7] Improve signer timeout errors --- src/controllers/error.ts | 5 +++ src/signers/ConnectSigner.ts | 59 ++++++++++++++++++++++++++++++++--- src/signers/ReadOnlySigner.ts | 2 +- 3 files changed, 60 insertions(+), 6 deletions(-) diff --git a/src/controllers/error.ts b/src/controllers/error.ts index fa5e4d32..8c07fc93 100644 --- a/src/controllers/error.ts +++ b/src/controllers/error.ts @@ -1,6 +1,11 @@ import { ErrorHandler } from '@hono/hono'; +import { HTTPException } from '@hono/hono/http-exception'; export const errorHandler: ErrorHandler = (err, c) => { + if (err instanceof HTTPException) { + return c.json({ error: err.message }, err.status); + } + console.error(err); if (err.message === 'canceling statement due to statement timeout') { diff --git a/src/signers/ConnectSigner.ts b/src/signers/ConnectSigner.ts index d4cf6032..6501bb8b 100644 --- a/src/signers/ConnectSigner.ts +++ b/src/signers/ConnectSigner.ts @@ -1,4 +1,5 @@ // deno-lint-ignore-file require-await +import { HTTPException } from '@hono/hono/http-exception'; import { NConnectSigner, NostrEvent, NostrSigner } from '@nostrify/nostrify'; import { Storages } from '@/storages.ts'; @@ -27,30 +28,78 @@ export class ConnectSigner implements NostrSigner { async signEvent(event: Omit): Promise { const signer = await this.signer; - return signer.signEvent(event); + try { + return await signer.signEvent(event); + } catch (e) { + if (e.name === 'AbortError') { + throw new HTTPException(408, { message: 'The event was not signed quickly enough' }); + } else { + throw e; + } + } } readonly nip04 = { encrypt: async (pubkey: string, plaintext: string): Promise => { const signer = await this.signer; - return signer.nip04.encrypt(pubkey, plaintext); + try { + return await signer.nip04.encrypt(pubkey, plaintext); + } catch (e) { + if (e.name === 'AbortError') { + throw new HTTPException(408, { + message: 'Text was not encrypted quickly enough', + }); + } else { + throw e; + } + } }, decrypt: async (pubkey: string, ciphertext: string): Promise => { const signer = await this.signer; - return signer.nip04.decrypt(pubkey, ciphertext); + try { + return await signer.nip04.decrypt(pubkey, ciphertext); + } catch (e) { + if (e.name === 'AbortError') { + throw new HTTPException(408, { + message: 'Text was not decrypted quickly enough', + }); + } else { + throw e; + } + } }, }; readonly nip44 = { encrypt: async (pubkey: string, plaintext: string): Promise => { const signer = await this.signer; - return signer.nip44.encrypt(pubkey, plaintext); + try { + return await signer.nip44.encrypt(pubkey, plaintext); + } catch (e) { + if (e.name === 'AbortError') { + throw new HTTPException(408, { + message: 'Text was not encrypted quickly enough', + }); + } else { + throw e; + } + } }, decrypt: async (pubkey: string, ciphertext: string): Promise => { const signer = await this.signer; - return signer.nip44.decrypt(pubkey, ciphertext); + try { + return await signer.nip44.decrypt(pubkey, ciphertext); + } catch (e) { + if (e.name === 'AbortError') { + throw new HTTPException(408, { + message: 'Text was not decrypted quickly enough', + }); + } else { + throw e; + } + } }, }; diff --git a/src/signers/ReadOnlySigner.ts b/src/signers/ReadOnlySigner.ts index 56c32c45..54449fab 100644 --- a/src/signers/ReadOnlySigner.ts +++ b/src/signers/ReadOnlySigner.ts @@ -7,7 +7,7 @@ export class ReadOnlySigner implements NostrSigner { async signEvent(): Promise { throw new HTTPException(401, { - message: 'Log out and back in', + message: 'Log in with Nostr Connect to sign events', }); } From fa53dd7f8df4737df6c9aa238ff81ed64d4cb76a Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Sun, 7 Jul 2024 00:23:00 +0100 Subject: [PATCH 5/7] createStatusController: add relay hints Fixes https://github.com/nostrability/nostrability/issues/52 --- src/controllers/api/statuses.ts | 27 +++++++++++++++------------ 1 file changed, 15 insertions(+), 12 deletions(-) diff --git a/src/controllers/api/statuses.ts b/src/controllers/api/statuses.ts index 30550bbb..4604981f 100644 --- a/src/controllers/api/statuses.ts +++ b/src/controllers/api/statuses.ts @@ -97,8 +97,8 @@ const createStatusController: AppController = async (c) => { const root = ancestor.tags.find((tag) => tag[0] === 'e' && tag[3] === 'root')?.[1] ?? ancestor.id; - tags.push(['e', root, 'root']); - tags.push(['e', data.in_reply_to_id, 'reply']); + tags.push(['e', root, Conf.relay, 'root']); + tags.push(['e', data.in_reply_to_id, Conf.relay, 'reply']); } if (data.quote_id) { @@ -202,7 +202,7 @@ const deleteStatusController: AppController = async (c) => { if (event.pubkey === pubkey) { await createEvent({ kind: 5, - tags: [['e', id]], + tags: [['e', id, Conf.relay]], }, c); const author = await getAuthor(event.pubkey); @@ -260,8 +260,8 @@ const favouriteController: AppController = async (c) => { kind: 7, content: '+', tags: [ - ['e', target.id], - ['p', target.pubkey], + ['e', target.id, Conf.relay], + ['p', target.pubkey, Conf.relay], ], }, c); @@ -302,7 +302,10 @@ const reblogStatusController: AppController = async (c) => { const reblogEvent = await createEvent({ kind: 6, - tags: [['e', event.id], ['p', event.pubkey]], + tags: [ + ['e', event.id, Conf.relay], + ['p', event.pubkey, Conf.relay], + ], }, c); await hydrateEvents({ @@ -337,7 +340,7 @@ const unreblogStatusController: AppController = async (c) => { await createEvent({ kind: 5, - tags: [['e', repostEvent.id]], + tags: [['e', repostEvent.id, Conf.relay]], }, c); return c.json(await renderStatus(event, { viewerPubkey: pubkey })); @@ -389,7 +392,7 @@ const bookmarkController: AppController = async (c) => { if (event) { await updateListEvent( { kinds: [10003], authors: [pubkey], limit: 1 }, - (tags) => addTag(tags, ['e', eventId]), + (tags) => addTag(tags, ['e', eventId, Conf.relay]), c, ); @@ -416,7 +419,7 @@ const unbookmarkController: AppController = async (c) => { if (event) { await updateListEvent( { kinds: [10003], authors: [pubkey], limit: 1 }, - (tags) => deleteTag(tags, ['e', eventId]), + (tags) => deleteTag(tags, ['e', eventId, Conf.relay]), c, ); @@ -443,7 +446,7 @@ const pinController: AppController = async (c) => { if (event) { await updateListEvent( { kinds: [10001], authors: [pubkey], limit: 1 }, - (tags) => addTag(tags, ['e', eventId]), + (tags) => addTag(tags, ['e', eventId, Conf.relay]), c, ); @@ -472,7 +475,7 @@ const unpinController: AppController = async (c) => { if (event) { await updateListEvent( { kinds: [10001], authors: [pubkey], limit: 1 }, - (tags) => deleteTag(tags, ['e', eventId]), + (tags) => deleteTag(tags, ['e', eventId, Conf.relay]), c, ); @@ -516,7 +519,7 @@ const zapController: AppController = async (c) => { lnurl = getLnurl(meta); if (target && lnurl) { tags.push( - ['e', target.id], + ['e', target.id, Conf.relay], ['p', target.pubkey], ['amount', amount.toString()], ['relays', Conf.relay], From 6245200e215cc2db241a3250774e971d34701ed0 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Thu, 11 Jul 2024 16:55:02 -0500 Subject: [PATCH 6/7] Upgrade Deno to v1.45.0 --- .gitlab-ci.yml | 2 +- .tool-versions | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 20650d2e..dc1e8456 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -1,4 +1,4 @@ -image: denoland/deno:1.44.2 +image: denoland/deno:1.45.0 default: interruptible: true diff --git a/.tool-versions b/.tool-versions index e85b11d4..512c172e 100644 --- a/.tool-versions +++ b/.tool-versions @@ -1 +1 @@ -deno 1.44.2 \ No newline at end of file +deno 1.45.0 \ No newline at end of file From 5c6479b3fea4d3dcae7fa159c1b40fa5c5a6b2ab Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Thu, 11 Jul 2024 17:10:05 -0500 Subject: [PATCH 7/7] Rate-limit messages to the relay --- src/controllers/nostr/relay.ts | 34 ++++++++++++++++++++++++++++++++-- 1 file changed, 32 insertions(+), 2 deletions(-) diff --git a/src/controllers/nostr/relay.ts b/src/controllers/nostr/relay.ts index 4d8ab2cb..f124360e 100644 --- a/src/controllers/nostr/relay.ts +++ b/src/controllers/nostr/relay.ts @@ -1,3 +1,4 @@ +import TTLCache from '@isaacs/ttlcache'; import { NostrClientCLOSE, NostrClientCOUNT, @@ -14,12 +15,18 @@ import { relayConnectionsGauge, relayEventCounter, relayMessageCounter } from '@ import * as pipeline from '@/pipeline.ts'; import { RelayError } from '@/RelayError.ts'; import { Storages } from '@/storages.ts'; +import { Time } from '@/utils/time.ts'; /** Limit of initial events returned for a subscription. */ const FILTER_LIMIT = 100; +const LIMITER_WINDOW = Time.minutes(1); +const LIMITER_LIMIT = 300; + +const limiter = new TTLCache(); + /** Set up the Websocket connection. */ -function connectStream(socket: WebSocket) { +function connectStream(socket: WebSocket, ip: string | undefined) { const controllers = new Map(); socket.onopen = () => { @@ -27,6 +34,21 @@ function connectStream(socket: WebSocket) { }; socket.onmessage = (e) => { + if (ip) { + const count = limiter.get(ip) ?? 0; + limiter.set(ip, count + 1, { ttl: LIMITER_WINDOW }); + + if (count > LIMITER_LIMIT) { + socket.close(1008, 'Rate limit exceeded'); + return; + } + } + + if (typeof e.data !== 'string') { + socket.close(1003, 'Invalid message'); + return; + } + const result = n.json().pipe(n.clientMsg()).safeParse(e.data); if (result.success) { relayMessageCounter.inc({ verb: result.data[0] }); @@ -152,8 +174,16 @@ const relayController: AppController = (c, next) => { return c.text('Please use a Nostr client to connect.', 400); } + const ip = c.req.header('x-real-ip'); + if (ip) { + const count = limiter.get(ip) ?? 0; + if (count > LIMITER_LIMIT) { + return c.json({ error: 'Rate limit exceeded' }, 429); + } + } + const { socket, response } = Deno.upgradeWebSocket(c.req.raw, { idleTimeout: 30 }); - connectStream(socket); + connectStream(socket, ip); return response; };