@evanp/activitypub-bot 0.39.6 → 0.40.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -17,6 +17,7 @@ const { values } = parseArgs({
17
17
  fanout: { type: 'string' },
18
18
  intake: { type: 'string' },
19
19
  'index-file': { type: 'string' },
20
+ 'allow-private': { type: 'boolean' },
20
21
  help: { type: 'boolean', short: 'h' }
21
22
  },
22
23
  allowPositionals: false
@@ -37,6 +38,7 @@ Options:
37
38
  --intake <number> Number of background intake workers
38
39
  --index-file <path> HTML page to show at root path
39
40
  --profile-file <path> HTML page to show for bot profiles
41
+ --allow-private flag to allow private network requests
40
42
  -h, --help Show this help
41
43
  `)
42
44
  process.exit(0)
@@ -48,6 +50,13 @@ const parseNumber = (value) => {
48
50
  const parsed = Number.parseInt(value, 10)
49
51
  return Number.isNaN(parsed) ? undefined : parsed
50
52
  }
53
+ function parseBoolean (value) {
54
+ if (value == null || value === '') return undefined
55
+ const normalized = value.trim().toLowerCase()
56
+ if (['1', 'true', 'yes', 'on'].includes(normalized)) return true
57
+ if (['0', 'false', 'no', 'off'].includes(normalized)) return false
58
+ return undefined
59
+ }
51
60
 
52
61
  const baseDir = dirname(fileURLToPath(import.meta.url))
53
62
  const DEFAULT_BOTS_CONFIG_FILE = resolve(baseDir, '..', 'bots', 'index.js')
@@ -69,6 +78,10 @@ const FANOUT = parseNumber(values.fanout) || parseNumber(process.env.FANOUT) ||
69
78
  const INTAKE = parseNumber(values.intake) || parseNumber(process.env.INTAKE) || 2
70
79
  const INDEX_FILE = values['index-file'] || process.env.INDEX_FILE || DEFAULT_INDEX_FILE
71
80
  const PROFILE_FILE = values['profile-file'] || process.env.PROFILE_FILE || DEFAULT_PROFILE_FILE
81
+ const ALLOW_PRIVATE = values['allow-private'] ||
82
+ ('ALLOW_PRIVATE' in process.env)
83
+ ? parseBoolean(process.env.ALLOW_PRIVATE)
84
+ : false
72
85
 
73
86
  const bots = (await import(BOTS_CONFIG_FILE)).default
74
87
 
@@ -82,7 +95,8 @@ const app = await makeApp({
82
95
  fanoutWorkerCount: FANOUT,
83
96
  intakeWorkerCount: INTAKE,
84
97
  indexFileName: INDEX_FILE,
85
- profileFileName: PROFILE_FILE
98
+ profileFileName: PROFILE_FILE,
99
+ allowPrivateNetworkRequests: ALLOW_PRIVATE
86
100
  })
87
101
 
88
102
  const server = app.listen(parseInt(PORT), () => {
@@ -2,8 +2,10 @@ import assert from 'node:assert'
2
2
  import fs from 'node:fs'
3
3
  import path from 'node:path'
4
4
  import { fileURLToPath } from 'node:url'
5
+ import dns from 'node:dns/promises'
5
6
 
6
7
  import fetch from 'node-fetch'
8
+ import ipaddr from 'ipaddr.js'
7
9
 
8
10
  import as2 from './activitystreams.js'
9
11
  import { SignaturePolicyStorage } from './signaturepolicystorage.js'
@@ -48,6 +50,14 @@ export class ActivityPubClientError extends Error {
48
50
  }
49
51
  }
50
52
 
53
+ export class ProtocolError extends Error {
54
+ constructor (url) {
55
+ super(`URL ${url} uses a disallowed protocol`)
56
+ this.name = 'ProtocolError'
57
+ this.url = url
58
+ }
59
+ }
60
+
51
61
  export class ActivityPubClient {
52
62
  static #githubUrl = 'https://github.com/evanp/activitypub-bot'
53
63
  static #userAgent = `activitypub.bot/${version} (${ActivityPubClient.#githubUrl})`
@@ -61,18 +71,19 @@ export class ActivityPubClient {
61
71
  #signer = null
62
72
  #digester = null
63
73
  #logger = null
64
- #limiter
74
+ #throttler
65
75
  #cache
66
76
  #messageSigner
67
77
  #policyStorage
78
+ #agent
68
79
 
69
- constructor (keyStorage, urlFormatter, signer, digester, logger, limiter, cache, messageSigner, policyStorage) {
80
+ constructor (keyStorage, urlFormatter, signer, digester, logger, throttler, cache, messageSigner, policyStorage, agent = null) {
70
81
  assert.strictEqual(typeof keyStorage, 'object')
71
82
  assert.strictEqual(typeof urlFormatter, 'object')
72
83
  assert.strictEqual(typeof signer, 'object')
73
84
  assert.strictEqual(typeof digester, 'object')
74
85
  assert.strictEqual(typeof logger, 'object')
75
- assert.strictEqual(typeof limiter, 'object')
86
+ assert.strictEqual(typeof throttler, 'object')
76
87
  assert.strictEqual(typeof cache, 'object')
77
88
  assert.strictEqual(typeof messageSigner, 'object')
78
89
  assert.strictEqual(typeof policyStorage, 'object')
@@ -81,10 +92,15 @@ export class ActivityPubClient {
81
92
  this.#signer = signer
82
93
  this.#digester = digester
83
94
  this.#logger = logger.child({ class: this.constructor.name })
84
- this.#limiter = limiter
95
+ this.#throttler = throttler
85
96
  this.#cache = cache
86
97
  this.#messageSigner = messageSigner
87
98
  this.#policyStorage = policyStorage
99
+ this.#agent = agent
100
+ }
101
+
102
+ get allowPrivateNetworkRequests () {
103
+ return this.#agent == null
88
104
  }
89
105
 
90
106
  async get (url, username = this.#urlFormatter.hostname) {
@@ -179,10 +195,10 @@ export class ActivityPubClient {
179
195
  }
180
196
  }
181
197
  const hostname = parsed.hostname
182
- this.#logger.debug({ url: baseUrl, hostname }, 'Waiting for rate limiter')
183
- await this.#limiter.limit(hostname)
198
+ this.#logger.debug({ url: baseUrl, hostname }, 'Waiting for throttler')
199
+ await this.#throttler.throttle(hostname)
184
200
  this.#logger.debug({ url: baseUrl }, 'Fetching with GET')
185
- let res = await fetch(baseUrl,
201
+ let res = await this.#fetch(baseUrl,
186
202
  {
187
203
  method,
188
204
  headers
@@ -200,7 +216,7 @@ export class ActivityPubClient {
200
216
  delete headers['signature-input']
201
217
  headers.signature =
202
218
  await this.#sign({ username, url: baseUrl, method, headers })
203
- res = await fetch(baseUrl,
219
+ res = await this.#fetch(baseUrl,
204
220
  {
205
221
  method,
206
222
  headers
@@ -208,7 +224,7 @@ export class ActivityPubClient {
208
224
  )
209
225
  }
210
226
 
211
- await this.#limiter.update(hostname, res.headers)
227
+ await this.#throttler.update(hostname, res.headers)
212
228
  this.#logger.debug({ url }, 'Finished GET')
213
229
  if (useCache && res.status === 304) {
214
230
  this.#logger.debug({ baseUrl }, '304 Not Modified, returning cached object')
@@ -316,10 +332,10 @@ export class ActivityPubClient {
316
332
  throw new Error(`Unexpected signature policy ${storedPolicy}`)
317
333
  }
318
334
  const hostname = parsed.hostname
319
- this.#logger.debug({ url, hostname }, 'Waiting for rate limiter')
320
- await this.#limiter.limit(hostname)
335
+ this.#logger.debug({ url, hostname }, 'Waiting for throttler')
336
+ await this.#throttler.throttle(hostname)
321
337
  this.#logger.debug({ url }, 'Fetching POST')
322
- let res = await fetch(url,
338
+ let res = await this.#fetch(url,
323
339
  {
324
340
  method,
325
341
  headers,
@@ -339,7 +355,7 @@ export class ActivityPubClient {
339
355
  }
340
356
  headers.signature =
341
357
  await this.#sign({ username, url, method, headers })
342
- res = await fetch(url,
358
+ res = await this.#fetch(url,
343
359
  {
344
360
  method,
345
361
  headers,
@@ -347,8 +363,8 @@ export class ActivityPubClient {
347
363
  }
348
364
  )
349
365
  }
350
- this.#logger.debug({ hostname }, 'updating limiter')
351
- await this.#limiter.update(hostname, res.headers)
366
+ this.#logger.debug({ hostname }, 'updating throttler')
367
+ await this.#throttler.update(hostname, res.headers)
352
368
  this.#logger.debug({ url }, 'Done fetching POST')
353
369
  if (res.status < 200 || res.status > 299) {
354
370
  const body = await res.text()
@@ -503,4 +519,37 @@ export class ActivityPubClient {
503
519
  }
504
520
  return results
505
521
  }
522
+
523
+ async #fetch (url, options) {
524
+ if (!(await this.#checkProtocol(url))) {
525
+ throw new ProtocolError(url)
526
+ }
527
+ const fullOptions = {
528
+ ...options,
529
+ agent: this.#agent ?? undefined,
530
+ timeout: 10000,
531
+ size: 1024 * 1024,
532
+ follow: 10
533
+ }
534
+ return await fetch(url, fullOptions)
535
+ }
536
+
537
+ async #checkProtocol (url) {
538
+ const parsed = (new URL(url))
539
+ switch (parsed.protocol) {
540
+ case 'https:':
541
+ return true
542
+ case 'http:': {
543
+ if (this.#agent) {
544
+ return false
545
+ }
546
+ const { address } = await dns.lookup(parsed.hostname)
547
+ const addr = ipaddr.parse(address)
548
+ const range = addr.range()
549
+ return range !== 'unicast'
550
+ }
551
+ default:
552
+ return false
553
+ }
554
+ }
506
555
  }
package/lib/app.js CHANGED
@@ -7,6 +7,8 @@ import { Sequelize } from 'sequelize'
7
7
  import express from 'express'
8
8
  import Logger from 'pino'
9
9
  import HTTPLogger from 'pino-http'
10
+ import { RedisStore } from 'rate-limit-redis'
11
+ import { createClient } from 'redis'
10
12
 
11
13
  import { ActivityDistributor } from './activitydistributor.js'
12
14
  import { ActivityPubClient, ActivityPubClientError } from './activitypubclient.js'
@@ -40,13 +42,15 @@ import { JobQueue } from './jobqueue.js'
40
42
  import { JobReaper } from './jobreaper.js'
41
43
  import { DeliveryWorker } from './deliveryworker.js'
42
44
  import { DistributionWorker } from './distributionworker.js'
43
- import { RateLimiter } from '../lib/ratelimiter.js'
45
+ import { RequestThrottler } from '../lib/requestthrottler.js'
44
46
  import DoNothingBot from './bots/donothing.js'
45
47
  import { FanoutWorker } from './fanoutworker.js'
46
48
  import { IntakeWorker } from './intakeworker.js'
47
49
  import { RemoteObjectCache } from './remoteobjectcache.js'
48
50
  import { HTTPMessageSignature } from './httpmessagesignature.js'
49
51
  import { SignaturePolicyStorage } from './signaturepolicystorage.js'
52
+ import { SafeAgent } from './safeagent.js'
53
+ import { rateLimit } from 'express-rate-limit'
50
54
 
51
55
  const currentDir = dirname(fileURLToPath(import.meta.url))
52
56
  const DEFAULT_INDEX_FILENAME = resolve(currentDir, '..', 'web', 'index.html')
@@ -59,7 +63,7 @@ function createWorkers (logger, count, WorkerClass, ...args) {
59
63
  return { workers, runs }
60
64
  }
61
65
 
62
- export async function makeApp ({ databaseUrl, origin, bots, logLevel = 'silent', deliveryWorkerCount = 2, distributionWorkerCount = 8, fanoutWorkerCount = 4, intakeWorkerCount = 2, indexFileName = DEFAULT_INDEX_FILENAME, profileFileName = DEFAULT_PROFILE_FILENAME }) {
66
+ export async function makeApp ({ databaseUrl, origin, bots, logLevel = 'silent', deliveryWorkerCount = 2, distributionWorkerCount = 8, fanoutWorkerCount = 4, intakeWorkerCount = 2, indexFileName = DEFAULT_INDEX_FILENAME, profileFileName = DEFAULT_PROFILE_FILENAME, allowPrivateNetworkRequests = false, redisUrl }) {
63
67
  const logger = Logger({
64
68
  level: logLevel
65
69
  })
@@ -76,10 +80,13 @@ export async function makeApp ({ databaseUrl, origin, bots, logLevel = 'silent',
76
80
  const botDataStorage = new BotDataStorage(connection)
77
81
  const keyStorage = new KeyStorage(connection, logger)
78
82
  const objectStorage = new ObjectStorage(connection)
79
- const limiter = new RateLimiter(connection, logger)
83
+ const throttler = new RequestThrottler(connection, logger)
80
84
  const remoteObjectCache = new RemoteObjectCache(connection, logger)
81
85
  const policyStore = new SignaturePolicyStorage(connection, logger)
82
- const client = new ActivityPubClient(keyStorage, formatter, signer, digester, logger, limiter, remoteObjectCache, messageSigner, policyStore)
86
+ const agent = (allowPrivateNetworkRequests)
87
+ ? null
88
+ : new SafeAgent()
89
+ const client = new ActivityPubClient(keyStorage, formatter, signer, digester, logger, throttler, remoteObjectCache, messageSigner, policyStore, agent)
83
90
  const remoteKeyStorage = new RemoteKeyStorage(client, connection, logger)
84
91
  const signature = new HTTPSignatureAuthenticator(remoteKeyStorage, signer, messageSigner, digester, logger)
85
92
  const jobQueue = new JobQueue(connection, logger)
@@ -117,6 +124,13 @@ export async function makeApp ({ databaseUrl, origin, bots, logLevel = 'silent',
117
124
  // TODO: Make an endpoint for tagged objects
118
125
  const transformer = new Transformer(origin + '/tag/', client)
119
126
 
127
+ let redisClient
128
+
129
+ if (redisUrl) {
130
+ redisClient = createClient({ url: redisUrl })
131
+ await redisClient.connect()
132
+ }
133
+
120
134
  await Promise.all(
121
135
  Object.entries(bots).map(([key, bot]) => bot.initialize(
122
136
  new BotContext(
@@ -188,6 +202,65 @@ export async function makeApp ({ databaseUrl, origin, bots, logLevel = 'silent',
188
202
 
189
203
  app.disable('x-powered-by')
190
204
 
205
+ const rateLimitHandler = (req, res) => {
206
+ res.status(429)
207
+ .set('Content-Type', 'application/problem+json')
208
+ .send({
209
+ type: 'about:blank',
210
+ status: 429,
211
+ title: 'Too Many Requests',
212
+ detail: 'You made too many requests.'
213
+ })
214
+ }
215
+
216
+ app.use(rateLimit({
217
+ identifier: 'steady-get',
218
+ windowMs: 60 * 1000,
219
+ limit: 2400,
220
+ standardHeaders: 'draft-8',
221
+ legacyHeaders: false,
222
+ skip: (req, res) => !['HEAD', 'GET'].includes(req.method),
223
+ handler: rateLimitHandler,
224
+ store: (redisClient)
225
+ ? new RedisStore({
226
+ prefix: `${origin}:steady-get:`,
227
+ sendCommand: (...args) => redisClient.sendCommand(args)
228
+ })
229
+ : undefined
230
+ }))
231
+
232
+ app.use(rateLimit({
233
+ identifier: 'burst-get',
234
+ windowMs: 10 * 1000,
235
+ limit: 1000,
236
+ standardHeaders: 'draft-8',
237
+ legacyHeaders: false,
238
+ skip: (req, res) => !['HEAD', 'GET'].includes(req.method),
239
+ handler: rateLimitHandler,
240
+ store: (redisClient)
241
+ ? new RedisStore({
242
+ prefix: `${origin}:burst-get:`,
243
+ sendCommand: (...args) => redisClient.sendCommand(args)
244
+ })
245
+ : undefined
246
+ }))
247
+
248
+ app.use(rateLimit({
249
+ identifier: 'post',
250
+ windowMs: 60 * 1000,
251
+ limit: 600,
252
+ standardHeaders: 'draft-8',
253
+ legacyHeaders: false,
254
+ skip: (req, res) => req.method !== 'POST',
255
+ handler: rateLimitHandler,
256
+ store: (redisClient)
257
+ ? new RedisStore({
258
+ prefix: `${origin}:post:`,
259
+ sendCommand: (...args) => redisClient.sendCommand(args)
260
+ })
261
+ : undefined
262
+ }))
263
+
191
264
  app.use(async (req, res, next) => {
192
265
  let id = req.get('x-request-id')
193
266
  if (!id || !id.match(UUID_REGEXP)) {
@@ -328,6 +401,10 @@ export async function makeApp ({ databaseUrl, origin, bots, logLevel = 'silent',
328
401
  await Promise.allSettled(distributionWorkerRuns)
329
402
  await Promise.allSettled(deliveryWorkerRuns)
330
403
  await jobReaperRun
404
+ if (redisClient) {
405
+ logger.info('Closing Redis connection')
406
+ await redisClient.disconnect()
407
+ }
331
408
  logger.info('Closing database connection')
332
409
  await connection.close()
333
410
  logger.info('Done')
@@ -0,0 +1,50 @@
1
+ export const id = '009-timestamp-to-timestampz'
2
+
3
+ const COLUMNS = [
4
+ ['actorcollection', 'createdat'],
5
+ ['actorcollection', 'updatedat'],
6
+ ['actorcollectionpage', 'createdat'],
7
+ ['objects', 'createdat'],
8
+ ['objects', 'updatedat'],
9
+ ['collections', 'createdat'],
10
+ ['collections', 'updatedat'],
11
+ ['pages', 'createdat'],
12
+ ['botdata', 'createdat'],
13
+ ['botdata', 'updatedat'],
14
+ ['new_remotekeys', 'createdat'],
15
+ ['new_remotekeys', 'updatedat'],
16
+ ['lastactivity', 'createdat'],
17
+ ['lastactivity', 'updatedat'],
18
+ ['job', 'claimed_at'],
19
+ ['job', 'retry_after'],
20
+ ['job', 'created_at'],
21
+ ['job', 'updated_at'],
22
+ ['rate_limit', 'reset'],
23
+ ['rate_limit', 'created_at'],
24
+ ['rate_limit', 'updated_at'],
25
+ ['failed_job', 'claimed_at'],
26
+ ['failed_job', 'retry_after'],
27
+ ['failed_job', 'created_at'],
28
+ ['failed_job', 'updated_at'],
29
+ ['remote_object_cache', 'last_modified'],
30
+ ['remote_object_cache', 'expiry'],
31
+ ['remote_object_cache', 'created_at'],
32
+ ['remote_object_cache', 'updated_at'],
33
+ ['signature_policy', 'expiry'],
34
+ ['signature_policy', 'created_at'],
35
+ ['signature_policy', 'updated_at']
36
+ ]
37
+
38
+ export async function up (connection, queryOptions = {}) {
39
+ if (connection.getDialect() !== 'postgres') {
40
+ return
41
+ }
42
+ for (const [table, column] of COLUMNS) {
43
+ await connection.query(
44
+ `ALTER TABLE "${table}"
45
+ ALTER COLUMN "${column}" TYPE TIMESTAMPTZ
46
+ USING "${column}" AT TIME ZONE 'UTC'`,
47
+ queryOptions
48
+ )
49
+ }
50
+ }
@@ -3,7 +3,7 @@ import assert from 'node:assert'
3
3
 
4
4
  const BETA = 0.75
5
5
 
6
- export class RateLimiter {
6
+ export class RequestThrottler {
7
7
  #connection
8
8
  #logger
9
9
 
@@ -14,7 +14,7 @@ export class RateLimiter {
14
14
  this.#logger = logger.child({ class: this.constructor.name })
15
15
  }
16
16
 
17
- async limit (host, maxWaitTime = 30000) {
17
+ async throttle (host, maxWaitTime = 30000) {
18
18
  assert.strictEqual(typeof host, 'string')
19
19
  assert.strictEqual(typeof maxWaitTime, 'number')
20
20
  const waitTime = await this.#getWaitTime(host, maxWaitTime)
@@ -86,6 +86,10 @@ router.get('/.well-known/webfinger', async (req, res, next) => {
86
86
  if (!resource) {
87
87
  return next(new ProblemDetailsError(400, 'resource parameter is required'))
88
88
  }
89
+ if (Array.isArray(resource)) {
90
+ assert.ok(resource.length > 1)
91
+ throw new ProblemDetailsError(400, '"resource" parameter required exactly once')
92
+ }
89
93
  const colon = resource.indexOf(':')
90
94
  const protocol = resource.slice(0, colon)
91
95
 
@@ -0,0 +1,33 @@
1
+ import https from 'node:https'
2
+ import dns from 'node:dns'
3
+
4
+ import ipaddr from 'ipaddr.js'
5
+
6
+ function isPrivateIP (address) {
7
+ if (!ipaddr.isValid(address)) return false
8
+ const addr = ipaddr.parse(address)
9
+ const range = addr.range()
10
+ return range !== 'unicast'
11
+ }
12
+
13
+ class PrivateNetworkError extends Error {
14
+ constructor (address) {
15
+ super(`Private network address ${address}`)
16
+ this.name = 'PrivateNetworkError'
17
+ this.address = address
18
+ }
19
+ }
20
+
21
+ export class SafeAgent extends https.Agent {
22
+ createConnection (options, callback) {
23
+ dns.lookup(options.hostname, (err, address) => {
24
+ if (err) {
25
+ return callback(err)
26
+ }
27
+ if (isPrivateIP(address)) {
28
+ return callback(new PrivateNetworkError(address))
29
+ }
30
+ super.createConnection({ ...options, hostname: address }, callback)
31
+ })
32
+ }
33
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@evanp/activitypub-bot",
3
- "version": "0.39.6",
3
+ "version": "0.40.1",
4
4
  "description": "server-side ActivityPub bot framework",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
@@ -9,6 +9,7 @@
9
9
  },
10
10
  "scripts": {
11
11
  "test": "NODE_ENV=test node --test",
12
+ "test:serial": "NODE_ENV=test node --test --test-concurrency=1",
12
13
  "start": "npx activitypub-bot",
13
14
  "lint": "eslint ."
14
15
  },
@@ -32,12 +33,16 @@
32
33
  "@isaacs/ttlcache": "^2.1.4",
33
34
  "activitystrea.ms": "^3.3.0",
34
35
  "express": "^5.2.1",
36
+ "express-rate-limit": "^8.3.2",
35
37
  "humanhash": "^1.0.4",
38
+ "ipaddr.js": "^2.3.0",
36
39
  "lru-cache": "^11.1.0",
37
40
  "nanoid": "^5.1.5",
38
41
  "node-fetch": "^3.3.2",
39
42
  "pino": "^10.1.1",
40
43
  "pino-http": "^11.0.0",
44
+ "rate-limit-redis": "^4.3.1",
45
+ "redis": "^5.11.0",
41
46
  "sequelize": "^6.37.7"
42
47
  },
43
48
  "devDependencies": {