Skip to content

Commit 6f2509e

Browse files
committed
fix: buffer server SASL challenges across 400-byte chunks
Server-to-client AUTHENTICATE follows the same 400-byte chunking rule as the client-to-server direction: a challenge is a sequence of 400-byte chunks, terminated by either a short chunk or a trailing AUTHENTICATE +. Responding to the first non-"+" line could desynchronize the exchange when an RFC 7628 JSON error spans multiple chunks. Track saslResponseSent to distinguish the initial prompt from post-response challenges, and accumulate challenge chunks until the challenge is complete. OAUTHBEARER's AQ== ack now fires only once the full challenge has arrived. Adds two tests: multi-chunk challenge (400-byte chunk then short tail) and 400-byte-multiple challenge terminated by trailing "+". Both verify the client holds off on AQ== until the challenge is complete.
1 parent d69a019 commit 6f2509e

2 files changed

Lines changed: 114 additions & 12 deletions

File tree

irc-socket.js

Lines changed: 37 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -168,6 +168,12 @@ class IrcSocket extends EventEmitter {
168168
this.emit("connect");
169169
timeout = setTimeout(onSilence, timeoutPeriod);
170170
let serverCapabilities, acknowledgedCapabilities, sentRequests, respondedRequests, allRequestsSent, nickname;
171+
// SASL challenge state: saslResponseSent flips once we've replied to
172+
// the initial AUTHENTICATE + prompt; saslChallenge accumulates
173+
// server-sent challenge chunks per IRCv3 SASL 3.1 (400-byte chunks,
174+
// terminated by a short chunk or a trailing AUTHENTICATE +).
175+
let saslResponseSent = false;
176+
let saslChallenge = '';
171177

172178
if (this.capabilities) {
173179
this.capabilities.requires = this.capabilities.requires || [];
@@ -288,13 +294,16 @@ class IrcSocket extends EventEmitter {
288294
}
289295

290296
} else if (parts[0] === "AUTHENTICATE") {
291-
if (parts[1] === '+') {
292-
// Server prompt: send our credential in 400-byte AUTHENTICATE
293-
// chunks. If the final chunk is exactly 400 bytes (or the
294-
// payload is empty), send a trailing AUTHENTICATE + per
295-
// IRCv3 SASL 3.1.
297+
const chunkSize = 400;
298+
if (!saslResponseSent) {
299+
// Initial server prompt. Send our credential in 400-byte
300+
// AUTHENTICATE chunks; if the final chunk is exactly 400
301+
// bytes (or the payload is empty), append a trailing
302+
// AUTHENTICATE + per IRCv3 SASL 3.1.
303+
if (parts[1] !== '+') {
304+
return;
305+
}
296306
const payload = encodeSaslCredential(this.saslMechanism, this.saslUsername, this.saslPassword);
297-
const chunkSize = 400;
298307
if (payload.length === 0) {
299308
this.raw(['AUTHENTICATE', '+']);
300309
} else {
@@ -305,12 +314,28 @@ class IrcSocket extends EventEmitter {
305314
this.raw(['AUTHENTICATE', '+']);
306315
}
307316
}
308-
} else if (this.saslMechanism === 'OAUTHBEARER') {
309-
// RFC 7628 §3.2.3: on failure the server sends a base64
310-
// error challenge; the client must reply with
311-
// AUTHENTICATE AQ== (base64 of \x01) so the server can
312-
// emit the failure numeric.
313-
this.raw(['AUTHENTICATE', 'AQ==']);
317+
saslResponseSent = true;
318+
} else {
319+
// Post-response server challenge. Accumulate chunks until
320+
// we see either a short chunk or a trailing "+" (after
321+
// 400-byte chunks). For OAUTHBEARER, RFC 7628 §3.2.3
322+
// requires AUTHENTICATE AQ== to ack the error challenge
323+
// before the server emits 904/905.
324+
let challengeComplete = false;
325+
if (parts[1] === '+') {
326+
challengeComplete = true;
327+
} else {
328+
saslChallenge += parts[1];
329+
if (parts[1].length < chunkSize) {
330+
challengeComplete = true;
331+
}
332+
}
333+
if (challengeComplete) {
334+
saslChallenge = '';
335+
if (this.saslMechanism === 'OAUTHBEARER') {
336+
this.raw(['AUTHENTICATE', 'AQ==']);
337+
}
338+
}
314339
}
315340
} else if (numeric === '903') {
316341
// RPL_SASLSUCCESS — advance past CAP. 900 RPL_LOGGEDIN is

test/irc-socket.js

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,11 @@ const messages = {
6969
rpl_loggedin_900: ":irc.test.net 900 testbot nick!user@host testbot :You are now logged in as testbot\r\n",
7070
// Simulated RFC 7628 failure challenge (base64 of '{"status":"invalid_token"}')
7171
auth_challenge_oauth: "AUTHENTICATE eyJzdGF0dXMiOiJpbnZhbGlkX3Rva2VuIn0=\r\n",
72+
// Multi-chunk challenge: a 400-byte chunk (must continue), then a short chunk (end).
73+
auth_challenge_chunk_400: "AUTHENTICATE " + "A".repeat(400) + "\r\n",
74+
auth_challenge_chunk_tail: "AUTHENTICATE " + "B".repeat(20) + "\r\n",
75+
// 400-byte-multiple challenge: 400-byte chunk then trailing "+".
76+
auth_challenge_terminator: "AUTHENTICATE +\r\n",
7277
cap_ack_a: ":irc.test.net CAP * ACK :a\r\n",
7378
cap_nak_a: ":irc.test.net CAP * NAK :a\r\n",
7479
cap_nak_b: ":irc.test.net CAP * NAK :b\r\n",
@@ -745,6 +750,78 @@ describe("IRC Sockets", function () {
745750
return promise;
746751
});
747752

753+
it("SASL OAUTHBEARER waits for multi-chunk challenge before acking", function () {
754+
const config = merge(baseConfig, {
755+
socket: MockSocket(logfn),
756+
saslUsername: 'foo',
757+
saslPassword: 'bad-token',
758+
saslMechanism: 'OAUTHBEARER',
759+
capabilities: { requires: ['sasl'] }
760+
});
761+
const socket = new IrcSocket(config);
762+
763+
const promise = socket.connect().then(function (res) {
764+
assert(res.isFail());
765+
assert(res.fail() === IrcSocket.connectFailures.saslAuthenticationFailed);
766+
});
767+
768+
socket.impl.acceptConnect();
769+
socket.impl.acceptData(messages.cap_sasl_only);
770+
socket.impl.acceptData(messages.cap_ack_sasl);
771+
socket.impl.acceptData(messages.auth_plus);
772+
773+
// First 400-byte challenge chunk — client must NOT ack yet.
774+
const callsBeforeTail = socket.impl.write.callCount;
775+
socket.impl.acceptData(messages.auth_challenge_chunk_400);
776+
const writesMid = socket.impl.write.getCalls().map(function (c) { return c.args[0]; });
777+
assert(writesMid.indexOf("AUTHENTICATE AQ==\r\n") === -1);
778+
assert(socket.impl.write.callCount === callsBeforeTail);
779+
780+
// Short terminating chunk — now the client acks.
781+
socket.impl.acceptData(messages.auth_challenge_chunk_tail);
782+
const writesAfter = socket.impl.write.getCalls().map(function (c) { return c.args[0]; });
783+
assert(writesAfter.indexOf("AUTHENTICATE AQ==\r\n") !== -1);
784+
785+
socket.impl.acceptData(messages.rpl_saslfail_904);
786+
787+
return promise;
788+
});
789+
790+
it("SASL OAUTHBEARER acks when 400-byte chunks end with trailing +", function () {
791+
const config = merge(baseConfig, {
792+
socket: MockSocket(logfn),
793+
saslUsername: 'foo',
794+
saslPassword: 'bad-token',
795+
saslMechanism: 'OAUTHBEARER',
796+
capabilities: { requires: ['sasl'] }
797+
});
798+
const socket = new IrcSocket(config);
799+
800+
const promise = socket.connect().then(function (res) {
801+
assert(res.isFail());
802+
assert(res.fail() === IrcSocket.connectFailures.saslAuthenticationFailed);
803+
});
804+
805+
socket.impl.acceptConnect();
806+
socket.impl.acceptData(messages.cap_sasl_only);
807+
socket.impl.acceptData(messages.cap_ack_sasl);
808+
socket.impl.acceptData(messages.auth_plus);
809+
810+
// Exactly-400-byte chunk — not terminal on its own.
811+
const callsBeforeTerm = socket.impl.write.callCount;
812+
socket.impl.acceptData(messages.auth_challenge_chunk_400);
813+
assert(socket.impl.write.callCount === callsBeforeTerm);
814+
815+
// Trailing "+" terminates the 400-byte-multiple challenge; ack now.
816+
socket.impl.acceptData(messages.auth_challenge_terminator);
817+
const writesAfter = socket.impl.write.getCalls().map(function (c) { return c.args[0]; });
818+
assert(writesAfter.indexOf("AUTHENTICATE AQ==\r\n") !== -1);
819+
820+
socket.impl.acceptData(messages.rpl_saslfail_904);
821+
822+
return promise;
823+
});
824+
748825
it("SASL rejects an unknown mechanism at construction", function () {
749826
const config = merge(baseConfig, {
750827
socket: MockSocket(logfn),

0 commit comments

Comments
 (0)