Skip to content

Commit 65cfeb2

Browse files
fix(settings): state that rate-limit periods are milliseconds
Every shipped limit counts in milliseconds - 1000, 60000, 900000, 3600000 - but nothing said so: RateLimit.period is a bare number, so period: 60 for a minute became a 60 ms window, a thousand times tighter than intended, honoured without complaint. The unit is now stated on RateLimit and on each rateLimits block in resources/default-settings.yaml, and the validator rejects what cannot be a count of milliseconds: a fraction, a string, a non-number, or below 1.
1 parent b6634b0 commit 65cfeb2

5 files changed

Lines changed: 67 additions & 2 deletions

File tree

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
---
2+
"nostream": patch
3+
---
4+
5+
fix(settings): state that rate-limit periods are milliseconds and reject a period that is not one
6+
7+
Every shipped limit counts in milliseconds — `1000`, `60000`, `900000`, `3600000` — but nothing said so. `RateLimit.period` is a bare `number`, so an operator writing `period: 60` for a minute got a 60 ms window: a limit a thousand times tighter than intended, honoured without complaint, and nothing in review caught it.
8+
9+
The unit is now stated on `RateLimit` and on every `rateLimits` block in `resources/default-settings.yaml`, and `validateRateLimitPeriods` rejects what cannot be a count of milliseconds: a fraction, a string, a non-number, or anything below one millisecond. A whole number is not enough to tell 60 from 60000, which is why the unit is documented where it is set.

‎resources/default-settings.yaml‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -216,10 +216,14 @@ eventStore:
216216
dvm:
217217
workers: []
218218
limits:
219+
# Every `period` below is in milliseconds. A window written in seconds -
220+
# period: 60 for a minute - is 1000x tighter and is not rejected, because
221+
# 60 is a valid number of milliseconds. See RateLimit in @types/settings.
219222
# strategy selection configuration for rate limiting:
220223
rateLimiter:
221224
strategy: ewma
222225
invoice:
226+
# period is milliseconds (60000 = one minute), never seconds
223227
rateLimits:
224228
- period: 60000
225229
rate: 12
@@ -232,11 +236,13 @@ limits:
232236
invite:
233237
# Per-pubkey limit on kind 28935 invite requests. Each mint writes a row to
234238
# invite_codes, so this is the main defence against a code flood.
239+
# period is milliseconds (60000 = one minute), never seconds
235240
rateLimits:
236241
- description: 5 invite requests per hour per pubkey
237242
period: 3600000
238243
rate: 5
239244
admissionCheck:
245+
# period is milliseconds (60000 = one minute), never seconds
240246
rateLimits:
241247
- description: 30 admission checks/min or 1 check every 2 seconds
242248
period: 60000
@@ -246,10 +252,12 @@ limits:
246252
- "10.10.10.1"
247253
- "::ffff:10.10.10.1"
248254
admin:
255+
# period is milliseconds (60000 = one minute), never seconds
249256
rateLimits:
250257
- description: 30 admin requests/min
251258
period: 60000
252259
rate: 30
260+
# period is milliseconds (60000 = one minute), never seconds
253261
loginRateLimits:
254262
- description: 10 login attempts per 15 minutes
255263
period: 900000
@@ -259,6 +267,7 @@ limits:
259267
- "10.10.10.1"
260268
- "::ffff:10.10.10.1"
261269
connection:
270+
# period is milliseconds (60000 = one minute), never seconds
262271
rateLimits:
263272
- period: 1000
264273
rate: 12
@@ -323,6 +332,7 @@ limits:
323332
- 39
324333
- - 50
325334
- 9007199254740991
335+
# period is milliseconds (60000 = one minute), never seconds
326336
rateLimits:
327337
- description: 6 events/min for event kinds 0, 3, 40 and 41
328338
kinds:
@@ -386,6 +396,7 @@ limits:
386396
maxLimit: 5000
387397
minPrefixLength: 4
388398
message:
399+
# period is milliseconds (60000 = one minute), never seconds
389400
rateLimits:
390401
- description: 240 raw messages/min
391402
period: 60000

‎src/@types/settings.ts‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,14 @@ export interface Network {
2424

2525
export interface RateLimit {
2626
description?: string
27+
/**
28+
* Length of the window in **milliseconds**, never seconds.
29+
*
30+
* Every shipped limit uses this unit (`1000`, `60000`, `900000`, `3600000`),
31+
* so `period: 60` is not "one minute" — it is a 60 ms window, a thousand
32+
* times tighter than intended, and the relay honours it without complaint.
33+
* The validator rejects anything that is not a positive whole number.
34+
*/
2735
period: number
2836
rate: number
2937
}

‎src/utils/settings-config.ts‎

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -595,8 +595,17 @@ const validateRateLimitPeriods = (
595595
issues.push({ path: `${pathPrefix}[${index}]`, message: 'rateLimits entry must be an object' })
596596
return
597597
}
598-
if (!(entry.period > 0)) {
599-
issues.push({ path: `${pathPrefix}[${index}].period`, message: 'period must be greater than 0' })
598+
// Milliseconds, never seconds: every shipped limit counts in ms, and a
599+
// window written in seconds (60 for a minute) is a 1000x tighter limit the
600+
// relay would simply honour. A whole number is the only thing this can
601+
// check — 60 is as valid as 60000 — so the unit is stated in the type and
602+
// in resources/default-settings.yaml as well.
603+
if (typeof entry.period !== 'number' || !Number.isFinite(entry.period)) {
604+
issues.push({ path: `${pathPrefix}[${index}].period`, message: 'period must be a number of milliseconds' })
605+
} else if (!Number.isInteger(entry.period)) {
606+
issues.push({ path: `${pathPrefix}[${index}].period`, message: 'period must be a whole number of milliseconds' })
607+
} else if (entry.period < 1) {
608+
issues.push({ path: `${pathPrefix}[${index}].period`, message: 'period must be at least 1 millisecond' })
600609
}
601610
})
602611
}

‎test/unit/utils/settings-config.spec.ts‎

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -387,6 +387,34 @@ describe('settings-config', () => {
387387
expect(issues.some((issue) => issue.path.startsWith('limits.event.rateLimits'))).to.equal(false)
388388
})
389389

390+
// The window is milliseconds everywhere, but 60 is as plausible a number as
391+
// 60000, so the unit cannot be inferred. What can be refused is a value that
392+
// is not a whole count of milliseconds at all: a fraction (0.5 ms), a string
393+
// from a hand-edited YAML, and a value that is not a number.
394+
const invalidPeriodCases: { label: string; period: unknown }[] = [
395+
{ label: 'a fractional period', period: 0.5 },
396+
{ label: 'a string period', period: '60000' },
397+
{ label: 'a NaN period', period: Number.NaN },
398+
]
399+
400+
for (const { label, period } of invalidPeriodCases) {
401+
it(`rejects ${label} on limits.event.rateLimits`, () => {
402+
const settings = baseSettings()
403+
settings.limits = { event: { rateLimits: [{ period, rate: 10 }] } }
404+
405+
const issues = validateSettings(settings)
406+
expect(issues.some((issue) => issue.path === 'limits.event.rateLimits[0].period')).to.equal(true)
407+
})
408+
}
409+
410+
it('names milliseconds in the period message, so a rejected value is not read as seconds', () => {
411+
const settings = baseSettings()
412+
settings.limits = { event: { rateLimits: [{ period: 0.5, rate: 10 }] } }
413+
414+
const message = validateSettings(settings).find((issue) => issue.path === 'limits.event.rateLimits[0].period')?.message
415+
expect(message).to.contain('milliseconds')
416+
})
417+
390418
it('rejects a null entry in limits.event.rateLimits without throwing', () => {
391419
const settings = baseSettings()
392420
settings.limits = { event: { rateLimits: [null] } }

0 commit comments

Comments
 (0)