Skip to content
This repository was archived by the owner on Jul 12, 2025. It is now read-only.

Commit fae97d9

Browse files
committed
feat: add auth and idle timeouts
1 parent cad41b7 commit fae97d9

File tree

7 files changed

+116
-18
lines changed

7 files changed

+116
-18
lines changed

README.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,12 @@ const {server, closeOpenConnections} = await startServer({
118118
// format: winston.format.simple(),
119119
// transports: new winston.transport.Console(),
120120
//}),
121+
122+
// Authentication timeout, defaults to 1 second.
123+
//authTimeout: 1_000,
124+
125+
// Idle timeout, defaults to 30 second.
126+
//idleTimeout: 30_000,
121127
});
122128

123129
// This takes care of gracefully shutting down the server on CTRL+C

src/BufferedStream.ts

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,11 @@ class BufferedStream<T extends Socket> implements Stream {
4040
}
4141

4242
public async readExact(length : number) : Promise<Buffer> {
43+
/* istanbul ignore next */
44+
if (this.closed) {
45+
throw new StreamClosedError('Socket was closed');
46+
}
47+
4348
/* istanbul ignore next */
4449
if (this.readResolver) {
4550
throw new Error('Only one read can be performed at a time');
@@ -98,16 +103,20 @@ class BufferedStream<T extends Socket> implements Stream {
98103
public writeUint8(value : number) : void {
99104
const content = Buffer.allocUnsafe(1);
100105
content.writeUInt8(value, 0);
101-
this.socket.write(content);
106+
this.writeAll(content);
102107
}
103108

104109
public writeUInt32LE(value : number) : void {
105110
const content = Buffer.allocUnsafe(4);
106111
content.writeUInt32LE(value, 0);
107-
this.socket.write(content);
112+
this.writeAll(content);
108113
}
109114

110115
public writeAll(buffer : Buffer) : void {
116+
if (this.closed) {
117+
throw new StreamClosedError('Socket was closed');
118+
}
119+
111120
this.socket.write(buffer);
112121
}
113122

@@ -119,6 +128,10 @@ class BufferedStream<T extends Socket> implements Stream {
119128
public isClosed() : boolean {
120129
return this.closed;
121130
}
131+
132+
public setTimeout(timeout : number, callback : () => void) : void {
133+
this.socket.setTimeout(timeout, callback);
134+
}
122135
}
123136

124137
export default BufferedStream;

src/Stream.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ type Stream = {
99
writeAll : (buffer : Buffer) => void;
1010
close : () => void;
1111
isClosed : () => boolean;
12+
setTimeout : (timeout : number, callback : () => void) => void;
1213
};
1314

1415
export default Stream;

src/client.ts

Lines changed: 47 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -20,26 +20,59 @@ export enum Response {
2020
pong = 0,
2121
}
2222

23-
const handleClient = async (stream : Stream, processor : Processor, logger ?: Logger) : Promise<void> => {
24-
const credentialsLength = await stream.readUint8();
25-
const credentials = (await stream.readExact(credentialsLength)).toString('ascii');
23+
const handleClient = async (
24+
stream : Stream,
25+
processor : Processor,
26+
logger ?: Logger,
27+
authTimeout = 1_000,
28+
idleTimeout = 30_000,
29+
) : Promise<void> => {
30+
// We handle the auth timeout via race instead of a socket timeout. This forces the client to complete
31+
// authentication in a fixed amount of time. Otherwise, a malicious client could send individual bytes to keep the
32+
// connection open for up 256 times the socket timeout.
33+
let timeout;
34+
35+
const clientId = await Promise.race([
36+
new Promise<null>(resolve => {
37+
timeout = setTimeout(() => {
38+
resolve(null);
39+
}, authTimeout);
40+
}),
41+
(async () => {
42+
const credentialsLength = await stream.readUint8();
43+
const credentials = (await stream.readExact(credentialsLength)).toString('ascii');
44+
45+
if (!credentials.includes(':')) {
46+
logger?.info('Malformed credentials');
47+
stream.writeUint8(Response.invalidAuth);
48+
return null;
49+
}
2650

27-
if (!credentials.includes(':')) {
28-
logger?.info('Malformed credentials');
29-
stream.writeUint8(Response.invalidAuth);
30-
return;
31-
}
51+
const [clientId, secret] = credentials.split(':', 2);
52+
const authResult = await processor.authenticate(clientId, secret);
53+
54+
if (!authResult) {
55+
logger?.info(`Unknown client ID "${clientId}" or invalid secret`);
56+
stream.writeUint8(Response.invalidAuth);
57+
return null;
58+
}
59+
60+
stream.writeUint8(Response.validAuth);
61+
return clientId;
62+
})(),
63+
]);
3264

33-
const [clientId, secret] = credentials.split(':', 2);
34-
const authResult = await processor.authenticate(clientId, secret);
65+
clearTimeout(timeout);
3566

36-
if (!authResult) {
37-
logger?.info(`Unknown client ID "${clientId}" or invalid secret`);
38-
stream.writeUint8(Response.invalidAuth);
67+
if (!clientId) {
3968
return;
4069
}
4170

42-
stream.writeUint8(Response.validAuth);
71+
// At this point we can be certain that it's not a random client anymore, so further inactivity is handled via
72+
// socket timeouts.
73+
stream.setTimeout(idleTimeout, () => {
74+
stream.close();
75+
});
4376

4477
while (!stream.isClosed()) {
4578
const commandCode = await stream.readUint8();

src/server.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@ export type ServerOptions = {
1111
tls : TlsOptions;
1212
port ?: number;
1313
hostname ?: string;
14+
authTimeout ?: number;
15+
idleTimeout ?: number;
1416
processor : Processor;
1517
logger ?: Logger;
1618
};
@@ -27,7 +29,7 @@ export const startServer = async (options : ServerOptions) : Promise<StartServer
2729
const stream = new BufferedStream(socket);
2830
streams.add(stream);
2931

30-
handleClient(stream, options.processor, options.logger)
32+
handleClient(stream, options.processor, options.logger, options.authTimeout, options.idleTimeout)
3133
.then(() => {
3234
stream.close();
3335
streams.delete(stream);

test/client.test.ts

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,4 +10,39 @@ describe('Client', () => {
1010
});
1111
}).rejects.toBeInstanceOf(StreamClosedError);
1212
});
13+
14+
it('should time out when exceeding auth timeout', async () => {
15+
let timeout;
16+
17+
await expect(async () => {
18+
await testServer({}, async stream => {
19+
stream.writeUint8(255);
20+
stream.writeUint8(0);
21+
22+
await new Promise(resolve => {
23+
timeout = setTimeout(resolve, 75);
24+
});
25+
26+
stream.writeUint8(1);
27+
}, false, 50);
28+
}).rejects.toBeInstanceOf(StreamClosedError);
29+
30+
clearTimeout(timeout);
31+
});
32+
33+
it('should time out when exceeding idle timeout', async () => {
34+
let timeout;
35+
36+
await expect(async () => {
37+
await testServer({}, async stream => {
38+
await new Promise(resolve => {
39+
timeout = setTimeout(resolve, 75);
40+
});
41+
42+
stream.writeUint8(1);
43+
}, true, undefined, 50);
44+
}).rejects.toBeInstanceOf(StreamClosedError);
45+
46+
clearTimeout(timeout);
47+
});
1348
});

test/server-tester.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,9 +16,17 @@ type Tester = (socket : Stream) => Promise<void>;
1616

1717
const startServerResults = new Set<StartServerResult>();
1818

19-
export const testServer = async (processor : Partial<Processor>, tester : Tester, autoAuth = true) : Promise<void> => {
19+
export const testServer = async (
20+
processor : Partial<Processor>,
21+
tester : Tester,
22+
autoAuth = true,
23+
authTimeout ?: number,
24+
idleTimeout ?: number,
25+
) : Promise<void> => {
2026
const startServerResult = await startServer({
2127
tls: tlsOptions,
28+
authTimeout,
29+
idleTimeout,
2230
processor: {
2331
authenticate: processor.authenticate ?? (() => true),
2432
checkUid: processor.checkUid

0 commit comments

Comments
 (0)