Skip to content

Commit dd2fdc4

Browse files
fix(rate-limit): a closed set of message types, the step in the backoff, and the missed call site
Greptile's three P1s, all correct: - messageType accepted any word, so a client could rotate invented types and keep every bucket empty. Recognised NIP-01 types only; anything else shares one OTHER bucket. - the backoff ignored the step the next hit adds: the score must decay to rate - step, not rate. 24 against 6 with step 1 is 2.26 half-lives (~136s), not two, and with no headroom (rate <= step) the period is the honest answer. - get-subscription-status-controller still treated the result object as a boolean, which is always truthy: every request would answer 429.
1 parent e0842c5 commit dd2fdc4

9 files changed

Lines changed: 165 additions & 46 deletions

File tree

‎src/adapters/web-socket-adapter.ts‎

Lines changed: 19 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -37,18 +37,33 @@ import { SocketAddress } from 'net'
3737
const logger = createLogger('web-socket-adapter')
3838

3939
/**
40-
* The NIP-01 type of a client frame, or `unknown` before it can be parsed.
40+
* The NIP-01 type of a client frame, or `OTHER` when it is not one.
4141
*
4242
* The message limiter runs before validation — it has to, or a client could
4343
* spend the parse budget first — so it reads a bounded prefix instead of
4444
* decoding the frame. A frame that does not start with `["WORD"` shares the
45-
* `unknown` bucket; it is refused a moment later anyway.
45+
* `OTHER` bucket; it is refused a moment later anyway.
4646
*/
47+
const THROTTLED_MESSAGE_TYPES = new Set<string>([
48+
'REQ',
49+
'EVENT',
50+
'COUNT',
51+
'CLOSE',
52+
'AUTH',
53+
'NEG-OPEN',
54+
'NEG-MSG',
55+
'NEG-CLOSE',
56+
])
57+
4758
const messageType = (raw: Buffer): string => {
4859
const prefix = raw.toString('utf8', 0, Math.min(raw.length, 32))
49-
const match = /^\s*\[\s*"([A-Za-z]{1,8})"/.exec(prefix)
60+
const match = /^\s*\[\s*"([A-Za-z-]{1,8})"/.exec(prefix)
61+
const type = match ? match[1].toUpperCase() : ''
5062

51-
return match ? match[1].toUpperCase() : 'unknown'
63+
// A closed set, not whatever the frame claims. Bucketing by an arbitrary word
64+
// would let a client rotate invented types (`["AAAA"]`, `["BBBB"]`, …) and
65+
// keep every bucket empty; anything unrecognised shares one bucket.
66+
return THROTTLED_MESSAGE_TYPES.has(type) ? type : 'OTHER'
5267
}
5368
const debugHeartbeat = logger.extend('heartbeat')
5469

‎src/app/app.ts‎

Lines changed: 41 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import packageJson from '../../package.json'
1111
import { Serializable } from 'child_process'
1212
import { Settings } from '../@types/settings'
1313
import { SettingsStatic } from '../utils/settings'
14+
import { BANNER_WIDTH, centre, RELAY_BANNER_LINES } from '../utils/banner'
1415
import { shutdownMetricsTelemetry } from '../telemetry/metrics'
1516
import { OperatorNotificationEventType } from '../@types/operator-notifications'
1617
import { RedisRelayBroadcastFanout } from '../relay-broadcast/redis-relay-broadcast-fanout'
@@ -53,30 +54,27 @@ export class App implements IRunnable {
5354
public run(): void {
5455
const settings = this.settings()
5556
this.watchers = SettingsStatic.watchSettings()
56-
logger.info(`
57-
███▄ █ ▒█████ ██████ ▄▄▄█████▓ ██▀███ ▓█████ ▄▄▄ ███▄ ▄███▓
58-
██ ▀█ █ ▒██▒ ██▒▒██ ▒ ▓ ██▒ ▓▒▓██ ▒ ██▒▓█ ▀▒████▄ ▓██▒▀█▀ ██▒
59-
▓██ ▀█ ██▒▒██░ ██▒░ ▓██▄ ▒ ▓██░ ▒░▓██ ░▄█ ▒▒███ ▒██ ▀█▄ ▓██ ▓██░
60-
▓██▒ ▐▌██▒▒██ ██░ ▒ ██▒░ ▓██▓ ░ ▒██▀▀█▄ ▒▓█ ▄░██▄▄▄▄██ ▒██ ▒██
61-
▒██░ ▓██░░ ████▓▒░▒██████▒▒ ▒██▒ ░ ░██▓ ▒██▒░▒████▒▓█ ▓██▒▒██▒ ░██▒
62-
░ ▒░ ▒ ▒ ░ ▒░▒░▒░ ▒ ▒▓▒ ▒ ░ ▒ ░░ ░ ▒▓ ░▒▓░░░ ▒░ ░▒▒ ▓▒█░░ ▒░ ░ ░
63-
░ ░░ ░ ▒░ ░ ▒ ▒░ ░ ░▒ ░ ░ ░ ░▒ ░ ▒░ ░ ░ ░ ▒ ▒▒ ░░ ░ ░
64-
░ ░ ░ ░ ░ ░ ▒ ░ ░ ░ ░ ░░ ░ ░ ░ ▒ ░ ░
65-
░ ░ ░ ░ ░ ░ ░ ░ ░ ░`)
66-
const width = 74
57+
// Plain text on stdout, not through the logger: in JSON output every line of
58+
// the logo arrived as its own record with the newlines escaped. A banner is
59+
// read by a person; the structured line below it carries the same facts for
60+
// anything that parses the log.
61+
const logLine = (text: string) => {
62+
this.process.stdout.write(`${text}\n`)
63+
}
64+
65+
logLine(RELAY_BANNER_LINES.join('\n'))
66+
67+
const width = BANNER_WIDTH
6768
const torHiddenServicePort = process.env.HIDDEN_SERVICE_PORT ? Number(process.env.HIDDEN_SERVICE_PORT) : 80
6869
const port = process.env.RELAY_PORT ? Number(process.env.RELAY_PORT) : 8008
6970

70-
const logCentered = (input: string, width: number) => {
71-
const start = Math.max(0, (width - input.length) >> 1)
72-
logger.info(' '.repeat(start), input)
73-
}
74-
logCentered(`v${packageJson.version}`, width)
75-
logCentered(`NIPs implemented: ${packageJson.supportedNips}`, width)
71+
const logCentered = (input: string) => logLine(centre(input, width))
72+
logCentered(`v${packageJson.version}`)
73+
logCentered(`NIPs implemented: ${packageJson.supportedNips}`)
7674
const paymentsEnabled = pathEq(['payments', 'enabled'], true, settings)
77-
logCentered(`Pay-to-relay ${paymentsEnabled ? 'enabled' : 'disabled'}`, width)
75+
logCentered(`Pay-to-relay ${paymentsEnabled ? 'enabled' : 'disabled'}`)
7876
if (paymentsEnabled) {
79-
logCentered(`Payments provider: ${path(['payments', 'processor'], settings)}`, width)
77+
logCentered(`Payments provider: ${path(['payments', 'processor'], settings)}`)
8078
}
8179

8280
if (
@@ -98,7 +96,7 @@ export class App implements IRunnable {
9896
this.process.exit(1)
9997
return
10098
}
101-
logCentered(`Event store: ${eventStoreBackend}`, width)
99+
logCentered(`Event store: ${eventStoreBackend}`)
102100

103101
const workerCount = process.env.WORKER_COUNT
104102
? Number(process.env.WORKER_COUNT)
@@ -117,25 +115,40 @@ export class App implements IRunnable {
117115
WORKER_INDEX: i.toString(),
118116
})
119117
}
120-
logCentered(`${workerCount} client workers started`, width)
118+
logCentered(`${workerCount} client workers started`)
119+
120+
// The facts the banner shows, in one record, for anything that reads the log
121+
// rather than looks at it.
122+
logger.info(
123+
{
124+
version: packageJson.version,
125+
supportedNips: packageJson.supportedNips,
126+
payments: paymentsEnabled,
127+
provider: paymentsEnabled ? path(['payments', 'processor'], settings) : undefined,
128+
eventStore: eventStoreBackend,
129+
clientWorkers: workerCount,
130+
relayPort: port,
131+
},
132+
'relay started',
133+
)
121134

122135
createWorker({
123136
WORKER_TYPE: 'maintenance',
124137
})
125138

126-
logCentered('1 maintenance worker started', width)
139+
logCentered('1 maintenance worker started')
127140
if (Array.isArray(settings?.mirroring?.static) && settings.mirroring.static.length) {
128141
logger.warn(
129142
'mirroring.static is no longer supported and is ignored; sync with another relay over NIP-77 (negentropy) instead',
130143
)
131-
logCentered('mirroring.static is no longer supported (ignored)', width)
144+
logCentered('mirroring.static is no longer supported (ignored)')
132145
}
133146

134147
if (settings.nip66?.enabled) {
135148
createWorker({
136149
WORKER_TYPE: 'relay-monitor',
137150
})
138-
logCentered('1 relay-monitor worker started', width)
151+
logCentered('1 relay-monitor worker started')
139152
}
140153

141154
const dvmWorkers = settings?.dvm?.workers
@@ -147,7 +160,7 @@ export class App implements IRunnable {
147160
DVM_WORKER_INDEX: i.toString(),
148161
})
149162
}
150-
logCentered(`${dvmWorkers.length} dvm-orchestrator worker started`, width)
163+
logCentered(`${dvmWorkers.length} dvm-orchestrator worker started`)
151164
}
152165

153166
logger('settings: %O', settings)
@@ -159,7 +172,7 @@ export class App implements IRunnable {
159172
.start((message) => this.onRelayBroadcastFromPeer(message))
160173
.then(() => {
161174
setRelayBroadcastFanoutReady(true)
162-
logCentered('Relay broadcast fan-out enabled (Redis stream)', width)
175+
logCentered('Relay broadcast fan-out enabled (Redis stream)')
163176
})
164177
.catch((error) => {
165178
setRelayBroadcastFanoutReady(false)
@@ -177,10 +190,10 @@ export class App implements IRunnable {
177190
const host = `${hostname()}:${port}`
178191
addOnion(torHiddenServicePort, host).then(
179192
(value) => {
180-
logCentered(`Tor hidden service: ${value}:${torHiddenServicePort}`, width)
193+
logCentered(`Tor hidden service: ${value}:${torHiddenServicePort}`)
181194
},
182195
() => {
183-
logCentered('Tor hidden service: disabled', width)
196+
logCentered('Tor hidden service: disabled')
184197
},
185198
)
186199
}

‎src/controllers/subscriptions/get-subscription-status-controller.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -124,7 +124,7 @@ export class GetSubscriptionStatusController implements IController {
124124
if (Array.isArray(ipWhitelist) && !ipWhitelist.includes(remoteAddress)) {
125125
const rateLimiter = this.rateLimiter()
126126
for (const { rate, period } of rateLimits) {
127-
if (await rateLimiter.hit(`${remoteAddress}:subscription-status:${period}`, 1, { period, rate })) {
127+
if ((await rateLimiter.hit(`${remoteAddress}:subscription-status:${period}`, 1, { period, rate })).limited) {
128128
logger('rate limited %s: %d in %d milliseconds', remoteAddress, rate, period)
129129
limited = true
130130
}

‎src/utils/banner.ts‎

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
// The startup banner is read by a human in `docker compose logs`, so it is
2+
// composed here as text and written to stdout: through the logger it arrives as
3+
// one JSON object per line with the newlines escaped, which is not a logo, and
4+
// pino is for records rather than for artwork.
5+
//
6+
// Kept out of App so the shape can be tested without a database, a cluster or a
7+
// settings document.
8+
export const BANNER_WIDTH = 74
9+
10+
export const RELAY_BANNER_LINES: readonly string[] = [
11+
' ███▄ █ ▒█████ ██████ ▄▄▄█████▓ ██▀███ ▓█████ ▄▄▄ ███▄ ▄███▓',
12+
' ██ ▀█ █ ▒██▒ ██▒▒██ ▒ ▓ ██▒ ▓▒▓██ ▒ ██▒▓█ ▀▒████▄ ▓██▒▀█▀ ██▒',
13+
'▓██ ▀█ ██▒▒██░ ██▒░ ▓██▄ ▒ ▓██░ ▒░▓██ ░▄█ ▒▒███ ▒██ ▀█▄ ▓██ ▓██░',
14+
'▓██▒ ▐▌██▒▒██ ██░ ▒ ██▒░ ▓██▓ ░ ▒██▀▀█▄ ▒▓█ ▄░██▄▄▄▄██ ▒██ ▒██',
15+
'▒██░ ▓██░░ ████▓▒░▒██████▒▒ ▒██▒ ░ ░██▓ ▒██▒░▒████▒▓█ ▓██▒▒██▒ ░██▒',
16+
'░ ▒░ ▒ ▒ ░ ▒░▒░▒░ ▒ ▒▓▒ ▒ ░ ▒ ░░ ░ ▒▓ ░▒▓░░░ ▒░ ░▒▒ ▓▒█░░ ▒░ ░ ░',
17+
'░ ░░ ░ ▒░ ░ ▒ ▒░ ░ ░▒ ░ ░ ░ ░▒ ░ ▒░ ░ ░ ░ ▒ ▒▒ ░░ ░ ░',
18+
' ░ ░ ░ ░ ░ ░ ▒ ░ ░ ░ ░ ░░ ░ ░ ░ ▒ ░ ░',
19+
' ░ ░ ░ ░ ░ ░ ░ ░ ░ ░',
20+
]
21+
22+
/**
23+
* Centres one line of the banner block. A line longer than the width is returned
24+
* unpadded rather than truncated: half a logo is worse than a wide one.
25+
*/
26+
export const centre = (line: string, width: number = BANNER_WIDTH): string =>
27+
' '.repeat(Math.max(0, (width - line.length) >> 1)) + line

‎src/utils/ewma-rate-limiter.ts‎

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -91,12 +91,20 @@ export class EWMARateLimiter implements IRateLimiter {
9191
}
9292

9393
// `period` is the half-life, not the wait: the score decays as
94-
// `score * 2^(-t/period)`, so it takes `period * log2(score / rate)` for the
95-
// score to fall back to `rate`. Reporting `period` understates a four-times
96-
// overrun by half (rate 6, score 24, period 60 s: 120 s, not 60 s).
94+
// `score * 2^(-t/period)`, so it takes `period * log2(score / target)` to
95+
// fall back to `target`. Reporting `period` understates a four-times
96+
// overrun by half (rate 6, score 24, period 60 s: ~136 s, not 60 s).
97+
//
98+
// The target is `rate - step`, not `rate`: the script adds this hit's step
99+
// before comparing, so a score that has decayed to exactly `rate` still
100+
// exceeds it. With no headroom (`rate <= step`) no amount of decay admits
101+
// another hit, and the key's TTL is what eventually clears it.
102+
const headroom = rate - step
97103
return {
98104
limited: true,
99-
retryAfterMs: Math.max(1, Math.ceil(period * Math.log2(score / rate))),
105+
retryAfterMs: headroom > 0
106+
? Math.max(1, Math.ceil(period * Math.log2(score / headroom)))
107+
: Math.max(1, Math.ceil(period)),
100108
}
101109
}
102110

‎test/unit/adapters/web-socket-adapter.spec.ts‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -668,6 +668,19 @@ describe('WebSocketAdapter', () => {
668668
)
669669
})
670670

671+
// A closed set of types, or a client could rotate invented ones and keep
672+
// every bucket empty.
673+
it('buckets an invented message type with everything else unrecognised', async () => {
674+
const hitStub = sandbox.stub().resolves({ limited: false })
675+
slidingWindowRateLimiter.returns({ hit: hitStub })
676+
677+
await onMessage(Buffer.from(JSON.stringify(['AAAA'])))
678+
await onMessage(Buffer.from(JSON.stringify(['BBBB'])))
679+
680+
expect(hitStub.firstCall.args[0]).to.equal('127.0.0.1:message:OTHER:60000')
681+
expect(hitStub.secondCall.args[0]).to.equal('127.0.0.1:message:OTHER:60000')
682+
})
683+
671684
it('falls back to the notice when the frame is not valid JSON', async () => {
672685
await onMessage(Buffer.from('not-json'))
673686

‎test/unit/controllers/subscriptions/get-subscription-status-controller.spec.ts‎

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,7 @@ describe('GetSubscriptionStatusController', () => {
6565

6666
beforeEach(() => {
6767
repository = { findByPubkey: sinon.stub().resolves(subscription()) }
68-
rateLimiterHit = sinon.stub().resolves(false)
68+
rateLimiterHit = sinon.stub().resolves({ limited: false })
6969
currentSettings = baseSettings()
7070
})
7171

@@ -333,7 +333,7 @@ describe('GetSubscriptionStatusController', () => {
333333
currentSettings = baseSettings({
334334
limits: { invoice: { rateLimits: [{ rate: 2, period: 60000 }], ipWhitelist: [] } },
335335
})
336-
rateLimiterHit.resolves(true)
336+
rateLimiterHit.resolves({ limited: true })
337337
const res = makeRes()
338338

339339
await makeController().handleRequest(makeReq(), res)
@@ -349,7 +349,7 @@ describe('GetSubscriptionStatusController', () => {
349349

350350
it('returns 429 when the invoice limits are exceeded, without reading anything', async () => {
351351
currentSettings = limited()
352-
rateLimiterHit.resolves(true)
352+
rateLimiterHit.resolves({ limited: true })
353353
const res = makeRes()
354354

355355
await makeController().handleRequest(makeReq(), res)
@@ -373,7 +373,7 @@ describe('GetSubscriptionStatusController', () => {
373373
currentSettings = baseSettings({
374374
limits: { invoice: { rateLimits: [{ rate: 2, period: 60000 }], ipWhitelist: ['1.2.3.4'] } },
375375
})
376-
rateLimiterHit.resolves(true)
376+
rateLimiterHit.resolves({ limited: true })
377377
const res = makeRes()
378378

379379
await makeController().handleRequest(makeReq(), res)

‎test/unit/utils/banner.spec.ts‎

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
import { expect } from 'chai'
2+
3+
import { BANNER_WIDTH, centre, RELAY_BANNER_LINES } from '../../../src/utils/banner'
4+
5+
describe('startup banner', () => {
6+
// The banner is printed to stdout as text. A line wider than the block would
7+
// wrap in a terminal and turn the logo into noise.
8+
it('fits every line inside the banner width', () => {
9+
for (const line of RELAY_BANNER_LINES) {
10+
expect(line.length, line).to.be.at.most(BANNER_WIDTH)
11+
}
12+
})
13+
14+
it('centres a short line', () => {
15+
const padded = centre('v1.2.3')
16+
17+
expect(padded).to.equal(' '.repeat((BANNER_WIDTH - 6) >> 1) + 'v1.2.3')
18+
expect(padded.trimEnd()).to.equal(padded)
19+
})
20+
21+
// Half a logo is worse than a wide one, so an overlong line is left alone
22+
// rather than cut to fit.
23+
it('never truncates a line longer than the width', () => {
24+
const long = 'x'.repeat(BANNER_WIDTH + 10)
25+
26+
expect(centre(long)).to.equal(long)
27+
})
28+
29+
it('pads nothing when the line is exactly the width', () => {
30+
const exact = 'x'.repeat(BANNER_WIDTH)
31+
32+
expect(centre(exact)).to.equal(exact)
33+
})
34+
})

‎test/unit/utils/ewma-rate-limiter.spec.ts‎

Lines changed: 14 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -76,15 +76,24 @@ describe('EWMARateLimiter', () => {
7676
evalStub.resolves('6.4')
7777
const result = await rateLimiter.hit('key', 1, { period: 60000, rate: 6 })
7878
expect(result.limited).to.be.true
79-
expect(result.retryAfterMs).to.equal(Math.ceil(60000 * Math.log2(6.4 / 6)))
79+
expect(result.retryAfterMs).to.equal(Math.ceil(60000 * Math.log2(6.4 / 5)))
8080
})
8181

82-
// `period` is the half-life. Four times over the rate is two half-lives,
83-
// so the caller must wait 2 * period, not period.
84-
it('sends the half-life backoff, not the period', async () => {
82+
// `period` is the half-life, and the next hit adds its step before the
83+
// comparison, so the score has to fall to `rate - step`: 24 down to 5 at
84+
// half-life 60 s is 2.26 periods, ~136 s, not the 60 s we used to send.
85+
it('sends the half-life backoff the next hit actually needs', async () => {
8586
evalStub.resolves('24')
8687
const result = await rateLimiter.hit('key', 1, { period: 60000, rate: 6 })
87-
expect(result.retryAfterMs).to.equal(120000)
88+
expect(result.retryAfterMs).to.equal(135783)
89+
})
90+
91+
// rate 1 with step 1 leaves no headroom: no decay admits another hit, so
92+
// the honest answer is one period, which is also the key's TTL.
93+
it('falls back to the period when no decay can admit a hit', async () => {
94+
evalStub.resolves('3')
95+
const result = await rateLimiter.hit('key', 1, { period: 60000, rate: 1 })
96+
expect(result.retryAfterMs).to.equal(60000)
8897
})
8998

9099
it('never sends a zero or sub-millisecond backoff', async () => {

0 commit comments

Comments
 (0)