Skip to content

Commit b848e05

Browse files
vveerrggclaude
andcommitted
fix: wss enforcement, payload limits, backoff jitter, rate limiter cleanup
Warn on non-wss:// relay URLs. Set maxPayload 65536 on WebSocket servers. Replace fixed reconnect delay with exponential backoff and jitter. Add cleanup method to rate limiter with counter-based periodic invocation. Fix queue splice-during-iteration with reverse for loop. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent d71dd2f commit b848e05

6 files changed

Lines changed: 61 additions & 13 deletions

File tree

src/core/client.ts

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,11 @@ export class NostrWSClient {
6363

6464
try {
6565
const url = this.relayUrls[0]; // For now just use first relay
66+
67+
if (url.startsWith('ws://') && !url.includes('localhost') && !url.includes('127.0.0.1')) {
68+
console.warn('[nostr-websocket] WARNING: Connecting over plaintext ws:// — messages are not encrypted');
69+
}
70+
6671
this.ws = new WebSocket(url);
6772

6873
await new Promise<void>((resolve, reject) => {
@@ -164,17 +169,22 @@ export class NostrWSClient {
164169
this.connectionState = ConnectionState.RECONNECTING;
165170
this.reconnectAttempts++;
166171

167-
const delay = this.options.retryDelay || 1000;
172+
const baseDelay = this.options.retryDelay || 1000;
173+
const maxDelay = 30000; // 30 second cap
174+
const delay = Math.min(baseDelay * Math.pow(2, this.reconnectAttempts), maxDelay);
175+
const jitter = delay * 0.1 * Math.random(); // 10% jitter
176+
const totalDelay = delay + jitter;
177+
168178
this.logger.info(
169-
{ attempt: this.reconnectAttempts, maxAttempts: this.options.retryAttempts },
170-
`Reconnecting in ${delay}ms`
179+
{ attempt: this.reconnectAttempts, maxAttempts: this.options.retryAttempts, delay: Math.round(totalDelay) },
180+
`Reconnecting in ${Math.round(totalDelay)}ms`
171181
);
172182

173183
this.reconnectTimeout = setTimeout(() => {
174184
this.connect().catch(error => {
175185
this.logger.error({ error }, 'Reconnection failed');
176186
});
177-
}, delay);
187+
}, totalDelay);
178188
} else {
179189
this.logger.warn('Max reconnection attempts reached');
180190
this.connectionState = ConnectionState.FAILED;

src/core/nostr-server.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,9 +20,10 @@ export class NostrWSServer {
2020
* @param {NostrWSServerOptions} options - Server configuration options
2121
*/
2222
constructor(options: NostrWSServerOptions) {
23-
this.server = new WebSocketServer({
23+
this.server = new WebSocketServer({
2424
port: options.port,
25-
host: options.host
25+
host: options.host,
26+
maxPayload: options.maxPayload || 65536, // 64KB default
2627
});
2728

2829
/**

src/core/queue.ts

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -104,19 +104,19 @@ export class MessageQueue {
104104
this.processing = false;
105105
}
106106

107-
// Clean up stale messages
107+
// Clean up stale messages (reverse iteration to avoid index mutation bug)
108108
if (this.options.staleTimeout) {
109109
const now = Date.now();
110110
const staleTimeout = this.options.staleTimeout;
111-
this.queue.forEach((message, index) => {
112-
if (now - message.queuedAt > staleTimeout) {
111+
for (let i = this.queue.length - 1; i >= 0; i--) {
112+
if (now - this.queue[i].queuedAt > staleTimeout) {
113113
this.logger.warn(
114-
{ message },
114+
{ message: this.queue[i] },
115115
'Message is stale, removing from queue'
116116
);
117-
this.queue.splice(index, 1);
117+
this.queue.splice(i, 1);
118118
}
119-
});
119+
}
120120
}
121121
}
122122

src/core/server.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,8 @@ export class NostrWSServer {
3434

3535
this.wss = new WebSocketServer({
3636
port: this.options.port,
37-
host: this.options.host
37+
host: this.options.host,
38+
maxPayload: this.options.maxPayload || 65536, // 64KB default
3839
});
3940

4041
this.setupServer();

src/transport/websocket.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,10 @@ export class WebSocketTransport extends BaseTransport {
2424
}
2525

2626
try {
27+
if (endpoint.startsWith('ws://') && !endpoint.includes('localhost') && !endpoint.includes('127.0.0.1')) {
28+
console.warn('[nostr-websocket] WARNING: Connecting over plaintext ws:// — messages are not encrypted');
29+
}
30+
2731
const ws = new WebSocket(endpoint);
2832

2933
ws.on('open', () => {

src/utils/rate-limiter.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -203,6 +203,7 @@ export function createConnectionRateLimiter(
203203
export class RateLimiterImpl implements RateLimiter {
204204
private clients: Map<string, ClientState> = new Map();
205205
private config: Required<RateLimitConfig>;
206+
private checkCount = 0;
206207

207208
constructor(config: RateLimitConfig) {
208209
this.config = {
@@ -222,6 +223,12 @@ export class RateLimiterImpl implements RateLimiter {
222223
}
223224

224225
public async shouldLimit(clientId: string, message: NostrWSMessage): Promise<boolean> {
226+
// Periodically clean up stale client entries to prevent memory leaks
227+
this.checkCount++;
228+
if (this.checkCount % 100 === 0) {
229+
this.cleanup();
230+
}
231+
225232
const now = Date.now();
226233
const state = this.getClientState(clientId);
227234

@@ -268,4 +275,29 @@ export class RateLimiterImpl implements RateLimiter {
268275
const state = this.getClientState(clientId);
269276
return !!state.blockedUntil && state.blockedUntil > Date.now();
270277
}
278+
279+
/**
280+
* Remove stale client entries to prevent memory leaks
281+
*/
282+
private cleanup(): void {
283+
const now = Date.now();
284+
for (const [key, state] of this.clients) {
285+
// Remove if blocked period has expired and no recent requests
286+
const cutoff = now - this.config.windowMs - this.config.blockDurationMs;
287+
288+
// Check if all request arrays are empty or stale
289+
let hasRecentActivity = false;
290+
for (const [, timestamps] of state.requests) {
291+
if (timestamps.length > 0 && timestamps[timestamps.length - 1] > cutoff) {
292+
hasRecentActivity = true;
293+
break;
294+
}
295+
}
296+
297+
// Also keep if still actively blocked
298+
if (!hasRecentActivity && (!state.blockedUntil || state.blockedUntil < now)) {
299+
this.clients.delete(key);
300+
}
301+
}
302+
}
271303
}

0 commit comments

Comments
 (0)