From dd84ddf78373551be29c0e1178507cad1a06e6d4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alex=20S=C3=B8rlie?= Date: Sun, 2 Aug 2026 21:40:53 +0200 Subject: [PATCH 01/11] fix: harden inbound message handling against malformed server input A malformed or hostile server could crash the client by sending messages with fewer parameters than a handler expected: nearly every handler indexed `message.parameters[N]` directly, and an out-of-bounds access is a fatal trap. Add two layers of defence: - A minimum-parameter-count guard at dispatch (`IRCReply.minimumParameterCount`) that drops undersized messages before any handler runs. - `[safe:]` + `guard let` in the handlers themselves, so safety does not depend on the dispatch table being exhaustive. Also fix the remaining traps in the same class: the duration-only STS `["port"]!` unwrap, the `PREFIX` parenthesis unwraps in `IRCChannelUserMode.map`, `IRCUser(fromPrivateMessage:)` (now failable, so a server-sourced PRIVMSG is dropped rather than crashing), the MODE `sender!`, and a tag-parser trap on malformed keys such as `@=;=`. In passing, the tag parser no longer truncates values containing `=`, CHGHOST-for-self is written back to `currentSender`, and the PART reason uses the correct parameter index. Add MalformedMessageTests covering 34 previously-crashing lines, and make the test target compile under Swift 6. --- .../Authentication/Authentication.swift | 7 +- .../Authentication/SASLExternal.swift | 5 +- .../Authentication/SASLPlainText.swift | 5 +- .../IRCClient/Authentication/SASLSha256.swift | 8 +- Sources/IRCKit/IRCClient/Handlers/CAP.swift | 18 +++- .../IRCClient/Handlers/ChannelEvents.swift | 60 ++++++++---- .../IRCClient/Handlers/ChannelInfo.swift | 29 ++++-- .../IRCKit/IRCClient/Handlers/Notice.swift | 18 +++- .../IRCKit/IRCClient/Handlers/Privmsg.swift | 36 +++---- .../IRCClient/Handlers/UserEvents.swift | 18 ++-- Sources/IRCKit/IRCClient/IRCClient.swift | 12 ++- Sources/IRCKit/IRCClient/IRCServerInfo.swift | 6 ++ Sources/IRCKit/IRCMessage.swift | 7 +- Sources/IRCKit/IRCReply.swift | 23 +++++ Sources/IRCKit/IRCUser.swift | 27 ++++-- Tests/IRCKitTests/MalformedMessageTests.swift | 95 +++++++++++++++++++ Tests/IRCKitTests/irckitTests.swift | 4 +- 17 files changed, 302 insertions(+), 76 deletions(-) create mode 100644 Tests/IRCKitTests/MalformedMessageTests.swift diff --git a/Sources/IRCKit/IRCClient/Authentication/Authentication.swift b/Sources/IRCKit/IRCClient/Authentication/Authentication.swift index 642fa57..e73fd9a 100644 --- a/Sources/IRCKit/IRCClient/Authentication/Authentication.swift +++ b/Sources/IRCKit/IRCClient/Authentication/Authentication.swift @@ -73,10 +73,13 @@ extension IRCClient { return } + guard let account = message.parameters[safe: 0] else { + return + } + for channel in self.channels { if let member = channel.member(fromSender: sender) { - - member.account = message.parameters[0] != "*" ? message.parameters[0] : nil + member.account = account != "*" ? account : nil channel.set(member: member) } } diff --git a/Sources/IRCKit/IRCClient/Authentication/SASLExternal.swift b/Sources/IRCKit/IRCClient/Authentication/SASLExternal.swift index 1eefbf1..9d92219 100644 --- a/Sources/IRCKit/IRCClient/Authentication/SASLExternal.swift +++ b/Sources/IRCKit/IRCClient/Authentication/SASLExternal.swift @@ -34,7 +34,10 @@ class ExternalSASLHandler: SASLHandler { } func handleResponse(message: IRCMessage) { - if message.parameters[0] == "+" { + guard let response = message.parameters[safe: 0] else { + return + } + if response == "+" { client.sendAuthenticate(message: "+") } else { client.abortSaslAuthentication() diff --git a/Sources/IRCKit/IRCClient/Authentication/SASLPlainText.swift b/Sources/IRCKit/IRCClient/Authentication/SASLPlainText.swift index c1e8a61..5b51e39 100644 --- a/Sources/IRCKit/IRCClient/Authentication/SASLPlainText.swift +++ b/Sources/IRCKit/IRCClient/Authentication/SASLPlainText.swift @@ -34,7 +34,10 @@ class PlainTextSASLHandler: SASLHandler { } func handleResponse(message: IRCMessage) { - if message.parameters[0] == "+" { + guard let response = message.parameters[safe: 0] else { + return + } + if response == "+" { guard let password = client.configuration.authenticationPassword else { client.abortSaslAuthentication() return diff --git a/Sources/IRCKit/IRCClient/Authentication/SASLSha256.swift b/Sources/IRCKit/IRCClient/Authentication/SASLSha256.swift index 3d1f87e..3653548 100644 --- a/Sources/IRCKit/IRCClient/Authentication/SASLSha256.swift +++ b/Sources/IRCKit/IRCClient/Authentication/SASLSha256.swift @@ -37,7 +37,10 @@ class Sha256SASLHandler: SASLHandler { } func handleResponse(message: IRCMessage) { - if message.parameters[0] == "+" { + guard let response = message.parameters[safe: 0] else { + return + } + if response == "+" { self.sendAuthenticationChallenge() } else { parseSASLScramResponse(message: message) @@ -60,7 +63,8 @@ class Sha256SASLHandler: SASLHandler { func parseSASLScramResponse(message: IRCMessage) { guard - let scramData = Data(base64Encoded: message.parameters[0]), + let response = message.parameters[safe: 0], + let scramData = Data(base64Encoded: response), let scramMessage = String(data: scramData, encoding: .utf8) else { return diff --git a/Sources/IRCKit/IRCClient/Handlers/CAP.swift b/Sources/IRCKit/IRCClient/Handlers/CAP.swift index feb4bb4..8447c40 100644 --- a/Sources/IRCKit/IRCClient/Handlers/CAP.swift +++ b/Sources/IRCKit/IRCClient/Handlers/CAP.swift @@ -26,7 +26,9 @@ import Foundation extension IRCClient { func handleIRCv3CapabilityReply(message: IRCMessage) { - let capProtocolCommand = message.parameters[1] + guard let capProtocolCommand = message.parameters[safe: 1] else { + return + } switch capProtocolCommand { case "LS": capNegotiation(message: message) @@ -46,7 +48,9 @@ extension IRCClient { // final line omits the `*`. Accumulate the tokens and only negotiate once the // complete list has arrived. let isMultilineContinuation = message.parameters.count > 3 && message.parameters[2] == "*" - let capString = isMultilineContinuation ? message.parameters[3] : message.parameters[2] + guard let capString = message.parameters[safe: isMultilineContinuation ? 3 : 2] else { + return + } if self.capabilityLSBuffer.isEmpty { self.capabilityLSBuffer = capString @@ -63,7 +67,7 @@ extension IRCClient { let caps = IRCv3CapabilityInfo.from(string: fullCapString) if let strictTransportInfo = caps.keyValuePairs(cap: .strictTransportSecurity) { - if let port = Int.parse(strictTransportInfo["port"]!), port != self.configuration.serverPort { + if let port = Int.parse(strictTransportInfo["port"] ?? nil), port != self.configuration.serverPort { self.configuration.serverPort = port self.configuration.prefersInsecureConnection = false self.connection.disconnect() @@ -77,10 +81,14 @@ extension IRCClient { } func capNegotiationComplete(message: IRCMessage) { - let acceptedCapabilities = IRCv3Capability.list(fromString: message.parameters[2]) + guard let capList = message.parameters[safe: 2] else { + self.send(command: .CAP, parameters: ["END"]) + return + } + let acceptedCapabilities = IRCv3Capability.list(fromString: capList) self.serverInfo.enabledIRCv3Capabilities = acceptedCapabilities - let caps = IRCv3CapabilityInfo.from(string: message.parameters[2]) + let caps = IRCv3CapabilityInfo.from(string: capList) if let saslCap = caps[.sasl] as? [String] { let mechanisms = saslCap.compactMap({ (serverMechanism: String) -> SASLHandler.Type? in return IRCClient.supportedHandlers.first(where: { (clientHandler: SASLHandler.Type) -> Bool in diff --git a/Sources/IRCKit/IRCClient/Handlers/ChannelEvents.swift b/Sources/IRCKit/IRCClient/Handlers/ChannelEvents.swift index 7ef2784..792c61c 100644 --- a/Sources/IRCKit/IRCClient/Handlers/ChannelEvents.swift +++ b/Sources/IRCKit/IRCClient/Handlers/ChannelEvents.swift @@ -53,17 +53,20 @@ extension IRCClient { account: nil ) + guard let channelName = message.parameters[safe: 0] else { + return + } + if self.hasIRCv3Capability(.extendedJoin) { - let account = message.parameters[1] - if account != "*" { + if let account = message.parameters[safe: 1], account != "*" { user.account = account } - user.realName = message.parameters[2] + user.realName = message.parameters[safe: 2] } if sender.isCurrentUser(client: self) { - let channel = IRCChannel(channelName: message.parameters[0], onClient: self) + let channel = IRCChannel(channelName: channelName, onClient: self) channel.add(member: user) self.addChannel(channel: channel) @@ -84,7 +87,7 @@ extension IRCClient { raw: message )).post() } else { - guard let channel = self.getChannel(named: message.parameters[0]) else { + guard let channel = self.getChannel(named: channelName) else { return } channel.set(member: user) @@ -105,7 +108,11 @@ extension IRCClient { return } - guard let channel = self.getChannel(named: message.parameters[0]) else { + guard let channelName = message.parameters[safe: 0] else { + return + } + + guard let channel = self.getChannel(named: channelName) else { return } guard let user = channel.member(fromSender: sender) else { @@ -114,14 +121,14 @@ extension IRCClient { channel.remove(member: user) if sender.isCurrentUser(client: self) { - self.removeChannel(named: message.parameters[0]) + self.removeChannel(named: channelName) } IRCUserLeftChannelNotification().encode(payload: IRCChannelEvent( id: message.label, user: user, channel: channel, - message: message.parameters[safe: 0], + message: message.parameters[safe: 1], event: .Part, raw: message )).post() @@ -132,18 +139,22 @@ extension IRCClient { return } - guard let channel = self.getChannel(named: message.parameters[0]) else { + guard let channelName = message.parameters[safe: 0], let kickTarget = message.parameters[safe: 1] else { + return + } + + guard let channel = self.getChannel(named: channelName) else { return } - guard let kickUser = channel.member(named: message.parameters[1]) else { + guard let kickUser = channel.member(named: kickTarget) else { return } channel.remove(member: kickUser) if sender.isCurrentUser(client: self) { - self.removeChannel(named: message.parameters[0]) + self.removeChannel(named: channelName) } IRCChannelKickNotification().encode(payload: IRCChannelKickNotification.IRCChannelKick( @@ -160,14 +171,18 @@ extension IRCClient { return } - guard let channel = self.getChannel(named: message.parameters[0]) else { + guard let channelName = message.parameters[safe: 0], let invitedNick = message.parameters[safe: 1] else { + return + } + + guard let channel = self.getChannel(named: channelName) else { return } IRCChannelInviteNotification().encode(payload: IRCChannelInviteNotification.IRCChannelInvite( sender: sender, channel: channel, - invitedNick: message.parameters[1], + invitedNick: invitedNick, raw: message )).post() } @@ -177,30 +192,37 @@ extension IRCClient { return } - guard let channel = self.getChannel(named: message.parameters[0]) else { + guard let channelName = message.parameters[safe: 0], let topicContents = message.parameters[safe: 1] else { return } - channel.topic = IRCChannel.Topic(contents: message.parameters[1], author: sender.nickname, date: message.time) + + guard let channel = self.getChannel(named: channelName) else { + return + } + channel.topic = IRCChannel.Topic(contents: topicContents, author: sender.nickname, date: message.time) let user = channel.member(fromSender: sender) IRCChannelTopicChangeNotification().encode(payload: IRCChannelTopicChangeNotification.IRCChannelTopicChange( user: user, channel: channel, - contents: message.parameters[1], + contents: topicContents, raw: message )).post() } func handleChannelModeChangeEvent(message: IRCMessage) { - guard let channel = self.getChannel(named: message.parameters[0]) else { + guard let channelName = message.parameters[safe: 0], let modes = message.parameters[safe: 1] else { + return + } + + guard let channel = self.getChannel(named: channelName) else { return } - let modes = message.parameters[1] var modeArgs = message.parameters[2...] var revoking = false - let user = channel.member(fromSender: message.sender!) + let user = message.sender.flatMap({ channel.member(fromSender: $0) }) for modeChar in Array(modes) { if modeChar == "+" { diff --git a/Sources/IRCKit/IRCClient/Handlers/ChannelInfo.swift b/Sources/IRCKit/IRCClient/Handlers/ChannelInfo.swift index dcb0cde..f035bd6 100644 --- a/Sources/IRCKit/IRCClient/Handlers/ChannelInfo.swift +++ b/Sources/IRCKit/IRCClient/Handlers/ChannelInfo.swift @@ -104,21 +104,29 @@ extension IRCClient { } func handleTopicInformation(message: IRCMessage) { - let channelName = message.parameters[1] + guard let channelName = message.parameters[safe: 1] else { + return + } guard let channel = self.getChannel(named: channelName) else { return } switch message.command { case .RPL_TOPIC: - channel.topic = IRCChannel.Topic(contents: message.parameters[2], author: nil, date: nil) + if let contents = message.parameters[safe: 2] { + channel.topic = IRCChannel.Topic(contents: contents, author: nil, date: nil) + } case .RPL_TOPICWHOTIME: - guard let date = DateFormatter.iso8601Full.date(from: message.parameters[3]) else { + guard + let author = message.parameters[safe: 2], + let dateString = message.parameters[safe: 3], + let date = DateFormatter.iso8601Full.date(from: dateString) + else { return } - channel.topic?.author = message.parameters[2] + channel.topic?.author = author channel.topic?.date = date case .RPL_NOTOPIC: @@ -130,7 +138,9 @@ extension IRCClient { } func handleChannelModeInformation(message: IRCMessage) { - let channelName = message.parameters[1] + guard let channelName = message.parameters[safe: 1] else { + return + } guard let channel = self.getChannel(named: channelName) else { return } @@ -139,12 +149,17 @@ extension IRCClient { } func handleChannelCreatedInformation(message: IRCMessage) { - let channelName = message.parameters[1] + guard let channelName = message.parameters[safe: 1] else { + return + } guard let channel = self.getChannel(named: channelName) else { return } - guard let date = DateFormatter.iso8601Full.date(from: message.parameters[2]) else { + guard + let createdString = message.parameters[safe: 2], + let date = DateFormatter.iso8601Full.date(from: createdString) + else { return } diff --git a/Sources/IRCKit/IRCClient/Handlers/Notice.swift b/Sources/IRCKit/IRCClient/Handlers/Notice.swift index e615b8a..1b26f39 100644 --- a/Sources/IRCKit/IRCClient/Handlers/Notice.swift +++ b/Sources/IRCKit/IRCClient/Handlers/Notice.swift @@ -38,7 +38,11 @@ extension IRCClient { return } - if let channel = self.getChannel(named: message.parameters[0]) { + guard let target = message.parameters[safe: 0] else { + return + } + + if let channel = self.getChannel(named: target) { self.handleChannelNoticeEvent(message: message, channel: channel) } else { self.handleNonChannelNoticeEvent(message: message) @@ -51,7 +55,9 @@ extension IRCClient { return } - var messageContents = message.parameters[1] + guard var messageContents = message.parameters[safe: 1] else { + return + } if sender.isServer { IRCChannelServerNoticeNotification().encode(payload: IRCServerNotice( client: message.client, @@ -99,7 +105,9 @@ extension IRCClient { return } - var messageContents = message.parameters[1] + guard var messageContents = message.parameters[safe: 1] else { + return + } if sender.isServer { IRCPrivateServerNoticeNotification().encode(payload: IRCServerNotice( @@ -112,7 +120,9 @@ extension IRCClient { return } - let user = IRCUser(fromPrivateMessage: message, onClient: self) + guard let user = IRCUser(fromPrivateMessage: message, onClient: self) else { + return + } let destination = IRCChannel(privateMessage: user, onClient: self) if message.isCTCPReply { diff --git a/Sources/IRCKit/IRCClient/Handlers/Privmsg.swift b/Sources/IRCKit/IRCClient/Handlers/Privmsg.swift index 7d96299..cd872a7 100644 --- a/Sources/IRCKit/IRCClient/Handlers/Privmsg.swift +++ b/Sources/IRCKit/IRCClient/Handlers/Privmsg.swift @@ -48,7 +48,11 @@ extension IRCClient { return } - if let channel = self.getChannel(named: message.parameters[0]) { + guard let target = message.parameters[safe: 0] else { + return + } + + if let channel = self.getChannel(named: target) { self.handleChannelPrivmsgEvent(message: message, channel: channel) } else { self.handleNonChannelPrivmsgEvent(message: message) @@ -60,11 +64,15 @@ extension IRCClient { guard let sender = message.sender else { return } - var user = IRCUser(fromPrivateMessage: message, onClient: self) - let destination = self.getChannel(named: message.parameters[0]) + guard let target = message.parameters[safe: 0], let messageContents = message.parameters[safe: 1] else { + return + } + guard var user = IRCUser(fromPrivateMessage: message, onClient: self) else { + return + } + let destination = self.getChannel(named: target) ?? IRCChannel(privateMessage: user, onClient: self) user = destination.member(fromSender: sender) ?? user - let messageContents = message.parameters[1] IRCEchoMessageNotification().encode(payload: IRCPrivateMessage( id: message.label, @@ -81,7 +89,9 @@ extension IRCClient { return } - var messageContents = message.parameters[1] + guard var messageContents = message.parameters[safe: 1] else { + return + } if message.isCTCPRequest { messageContents.remove(at: messageContents.startIndex) @@ -124,11 +134,13 @@ extension IRCClient { } func handleNonChannelPrivmsgEvent(message: IRCMessage) { - guard message.sender != nil else { + guard var messageContents = message.parameters[safe: 1] else { return } - - var messageContents = message.parameters[1] + guard let user = IRCUser(fromPrivateMessage: message, onClient: self) else { + return + } + let destination = IRCChannel(privateMessage: user, onClient: self) if message.isCTCPRequest { messageContents.remove(at: messageContents.startIndex) @@ -138,8 +150,6 @@ extension IRCClient { messageContents = String(messageContents.suffix( from: messageContents.index(messageContents.startIndex, offsetBy: 7)) ) - let user = IRCUser(fromPrivateMessage: message, onClient: self) - let destination = IRCChannel(privateMessage: user, onClient: self) IRCPrivateActionMessageNotification().encode(payload: IRCPrivateMessage( id: message.label, @@ -150,9 +160,6 @@ extension IRCClient { raw: message )).post() } else { - let user = IRCUser(fromPrivateMessage: message, onClient: self) - let destination = IRCChannel(privateMessage: user, onClient: self) - IRCPrivateCTCPRequestNotification().encode(payload: IRCPrivateMessage( id: message.label, client: self, @@ -163,9 +170,6 @@ extension IRCClient { )).post() } } else { - let user = IRCUser(fromPrivateMessage: message, onClient: self) - let destination = IRCChannel(privateMessage: user, onClient: self) - IRCPrivateMessageNotification().encode(payload: IRCPrivateMessage( id: message.label, client: self, diff --git a/Sources/IRCKit/IRCClient/Handlers/UserEvents.swift b/Sources/IRCKit/IRCClient/Handlers/UserEvents.swift index abeb643..844fb12 100644 --- a/Sources/IRCKit/IRCClient/Handlers/UserEvents.swift +++ b/Sources/IRCKit/IRCClient/Handlers/UserEvents.swift @@ -46,7 +46,9 @@ extension IRCClient { return } - let newNick = message.parameters[0] + guard let newNick = message.parameters[safe: 0] else { + return + } if sender.nickname == self.currentNick { var newSender = sender @@ -87,7 +89,9 @@ extension IRCClient { return } - let realName = message.parameters[0] + guard let realName = message.parameters[safe: 0] else { + return + } for channel in self.channels { if let member = channel.member(fromSender: sender) { @@ -101,13 +105,13 @@ extension IRCClient { return } - let newUser = message.parameters[0] - let newHost = message.parameters[1] + guard let newUser = message.parameters[safe: 0], let newHost = message.parameters[safe: 1] else { + return + } if sender.nickname == self.currentNick { - var newSender = sender - newSender.username = newUser - newSender.hostmask = newHost + self.currentSender?.username = newUser + self.currentSender?.hostmask = newHost } for channel in self.channels { diff --git a/Sources/IRCKit/IRCClient/IRCClient.swift b/Sources/IRCKit/IRCClient/IRCClient.swift index 66a6f87..409aae2 100644 --- a/Sources/IRCKit/IRCClient/IRCClient.swift +++ b/Sources/IRCKit/IRCClient/IRCClient.swift @@ -129,13 +129,23 @@ open class IRCClient: IRCConnectionDelegate, @unchecked Sendable { return } + guard message.parameters.count >= message.command.minimumParameterCount else { + let command = message.command.rawValue + let minimum = message.command.minimumParameterCount + logger.trace("Ignoring \(command): expected at least \(minimum) parameters, got \(message.parameters.count)") + return + } + switch message.command { case .ERROR: logger.warning("Disconnecting due to error") self.connection.disconnect() case .PING: - self.send(command: .PONG, parameters: [":\(message.parameters[0])"]) + guard let token = message.parameters[safe: 0] else { + break + } + self.send(command: .PONG, parameters: [":\(token)"]) case .RPL_WELCOME: if diff --git a/Sources/IRCKit/IRCClient/IRCServerInfo.swift b/Sources/IRCKit/IRCClient/IRCServerInfo.swift index 186c005..616e49d 100644 --- a/Sources/IRCKit/IRCClient/IRCServerInfo.swift +++ b/Sources/IRCKit/IRCClient/IRCServerInfo.swift @@ -62,6 +62,9 @@ public struct IRCServerInfo: @unchecked Sendable { } internal mutating func setServerInfo(parameters: [String]) { + guard parameters.count >= 5 else { + return + } self.serverName = parameters[1] self.serverVersion = parameters[2] @@ -72,6 +75,9 @@ public struct IRCServerInfo: @unchecked Sendable { } internal mutating func setSupported(parameters: [String]) { + guard parameters.count >= 2 else { + return + } var supportEntries = parameters supportEntries.removeFirst() supportEntries.removeLast() diff --git a/Sources/IRCKit/IRCMessage.swift b/Sources/IRCKit/IRCMessage.swift index cfd74e7..f78315b 100644 --- a/Sources/IRCKit/IRCMessage.swift +++ b/Sources/IRCKit/IRCMessage.swift @@ -119,8 +119,11 @@ public struct IRCMessage: Sendable { static func parseMessageTags(tagsString: String, client: IRCClient) -> [String: String] { let tagsString = tagsString.suffix(from: tagsString.index(tagsString.startIndex, offsetBy: 1)) return tagsString.split(separator: ";").reduce(into: [String: String](), { tags, tag in - let tagComponents = tag.split(separator: "=") - tags[String(tagComponents[0])] = tagComponents.count > 1 ? String(tagComponents[1]) : nil + let tagComponents = tag.split(separator: "=", maxSplits: 1, omittingEmptySubsequences: false) + guard let key = tagComponents.first, key.isEmpty == false else { + return + } + tags[String(key)] = tagComponents.count > 1 ? String(tagComponents[1]) : nil }) } } diff --git a/Sources/IRCKit/IRCReply.swift b/Sources/IRCKit/IRCReply.swift index 5379afd..125989a 100644 --- a/Sources/IRCKit/IRCReply.swift +++ b/Sources/IRCKit/IRCReply.swift @@ -189,3 +189,26 @@ public enum IRCReply: String, Sendable { case ERR_SASLALREADY = "907" case RPL_SASLMECHS = "908" } + +extension IRCReply { + /// The minimum number of parameters a message for this command must carry before its handler + /// is allowed to run. Messages with fewer parameters are dropped at dispatch, so a malformed or + /// hostile server cannot drive a handler into an out-of-bounds trap. This is a coarse first line + /// of defence; handlers remain individually defensive via `[safe:]` for parameters they access + /// conditionally (e.g. extended-join fields or optional trailing text). + var minimumParameterCount: Int { + switch self { + case .PING, .NICK, .PART, .SETNAME, .ACCOUNT, .AUTHENTICATE: + return 1 + + case .CAP, .RPL_ISUPPORT, .PRIVMSG, .NOTICE, .KICK, .INVITE, .MODE, .TOPIC, .CHGHOST: + return 2 + + case .RPL_MYINFO: + return 5 + + default: + return 0 + } + } +} diff --git a/Sources/IRCKit/IRCUser.swift b/Sources/IRCKit/IRCUser.swift index 40d4196..e8c57b2 100644 --- a/Sources/IRCKit/IRCUser.swift +++ b/Sources/IRCKit/IRCUser.swift @@ -69,13 +69,18 @@ public class IRCUser: @unchecked Sendable { self.channelUserModes = userModes } - init (fromPrivateMessage message: IRCMessage, onClient client: IRCClient) { + init? (fromPrivateMessage message: IRCMessage, onClient client: IRCClient) { + guard + let sender = message.sender, + let username = sender.username, + let hostmask = sender.hostmask + else { + return nil + } self.client = client - let sender = message.sender! - self.nickname = sender.nickname - self.username = sender.username! - self.hostmask = sender.hostmask! + self.username = username + self.hostmask = hostmask self.realName = nil self.account = message.account self.isAway = false @@ -113,8 +118,16 @@ public enum IRCChannelUserMode: Character, Sendable { } static func map(fromString prefixString: String) -> [IRCChannelUserMode: Character] { - let prefixLettersStartIndex = prefixString.index(prefixString.firstIndex(of: "(")!, offsetBy: 1) - let prefixLettersEndIndex = prefixString.firstIndex(of: ")")! + guard + let openParenIndex = prefixString.firstIndex(of: "("), + let prefixLettersEndIndex = prefixString.firstIndex(of: ")") + else { + return [:] + } + let prefixLettersStartIndex = prefixString.index(openParenIndex, offsetBy: 1) + guard prefixLettersStartIndex <= prefixLettersEndIndex else { + return [:] + } let prefixSymbolsStartIndex = prefixString.index(prefixLettersEndIndex, offsetBy: 1) let prefixLetters = prefixString[prefixLettersStartIndex.. IRCClient { + let configuration = IRCClientConfiguration( + serverName: "test", + serverAddress: "irc.example.org", + serverPort: 6697, + nickname: "tester", + username: "tester", + realName: "Integration Tester" + ) + return IRCClient(configuration: configuration) + } + + /// Every branch of the command dispatcher, fed a message that is missing the parameters its + /// handler previously indexed unconditionally. None of these may trap. + func testMalformedMessagesDoNotCrash() { + let client = makeClient() + + let malformedLines = [ + // Registration / server info + ":irc.example.org 004 tester", // RPL_MYINFO, missing 4 params + ":irc.example.org 005", // RPL_ISUPPORT, no params + ":irc.example.org 005 tester PREFIX=badvalue :ok", // PREFIX without parentheses + ":irc.example.org 005 tester PREFIX=)( :ok", // PREFIX parens reversed + // Capability negotiation + ":irc.example.org CAP", // missing subcommand + ":irc.example.org CAP * LS", // LS with no capability list + ":irc.example.org CAP * ACK", // ACK with no capability list + // Channel lifecycle + ":n!u@h JOIN", // JOIN with no channel + ":n!u@h PART", // PART with no channel + ":n!u@h KICK #channel", // KICK with no target + ":n!u@h INVITE tester", // INVITE with no channel + ":n!u@h TOPIC #channel", // TOPIC with no text + "MODE #channel", // MODE with no modes and no prefix + ":n!u@h MODE #channel", // MODE with no mode string + // Messaging + ":n!u@h PRIVMSG", // PRIVMSG with no target/text + ":n!u@h PRIVMSG #channel", // PRIVMSG with no text + ":n!u@h NOTICE", // NOTICE with no target/text + ":n!u@h NOTICE tester", // NOTICE with no text + ":irc.example.org PRIVMSG tester :from a server", // server source lacks user!host + ":irc.example.org NOTICE tester :from a server", // server source lacks user!host + // User events + ":n!u@h NICK", // NICK with no new nick + ":n!u@h SETNAME", // SETNAME with no realname + ":n!u@h CHGHOST newuser", // CHGHOST with no host + ":n!u@h ACCOUNT", // ACCOUNT with no account + // Ping / topic info numerics + "PING", // PING with no token + ":irc.example.org 332 tester", // RPL_TOPIC missing channel/text + ":irc.example.org 333 tester #channel", // RPL_TOPICWHOTIME missing fields + ":irc.example.org 329 tester #channel", // RPL_CREATIONTIME missing timestamp + ":irc.example.org 353 tester", // RPL_NAMEREPLY wrong arity + ":irc.example.org 352 tester", // RPL_WHOREPLY wrong arity + // Authentication + "AUTHENTICATE", // AUTHENTICATE with no payload + // Total garbage + "", + ":", + "@tag", + "@=;= :n!u@h" + ] + + for line in malformedLines { + client.didReceiveDataFromConnection(data: line) + } + + // If we reached here the dispatcher survived every malformed line; sanity-check the client + // is still usable by processing a well-formed self-JOIN. + client.didReceiveDataFromConnection(data: ":tester!u@host JOIN #welformed") + XCTAssertTrue(client.channels.contains(where: { $0.name == "#welformed" })) + } + + /// A private message whose source is a server (no `user@host`) must be dropped, not crash, now + /// that `IRCUser(fromPrivateMessage:)` is failable. + func testServerSourcedPrivateMessageIsIgnored() { + let client = makeClient() + client.didReceiveDataFromConnection(data: ":services. PRIVMSG tester :hello from services") + // No channel is created for an unusable sender. + XCTAssertTrue(client.channels.isEmpty) + } + + /// A duration-only STS capability (valid: no `port=`) must not trap on a forced unwrap. + func testStrictTransportSecurityWithoutPortDoesNotCrash() { + let client = makeClient() + client.didReceiveDataFromConnection(data: ":irc.example.org CAP * LS :sts=duration=2592000") + } +} diff --git a/Tests/IRCKitTests/irckitTests.swift b/Tests/IRCKitTests/irckitTests.swift index d172142..1f6220d 100644 --- a/Tests/IRCKitTests/irckitTests.swift +++ b/Tests/IRCKitTests/irckitTests.swift @@ -8,7 +8,7 @@ final class irckitTests: XCTestCase { // results. } - static var allTests = [ - ("testExample", testExample), + static let allTests = [ + ("testExample", testExample) ] } From 64f540d80bf9c655c03e785b0ebf0737fcef7590 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alex=20S=C3=B8rlie?= Date: Sun, 2 Aug 2026 21:44:28 +0200 Subject: [PATCH 02/11] fix: dispatch INVITE and MONITOR replies to their handlers Two advertised features were dead on the receive path: - INVITE was routed to `handleChannelKickEvent`, and the dedicated `handleChannelInviteEvent` was never called. That handler also read the parameters in KICK order; INVITE is ` `. Route INVITE correctly, read the parameters in the right order, and fall back to a transient channel so the notification fires for invites to channels we have not joined. - RPL_MONONLINE (730) and RPL_MONOFFLINE (731) had handlers but no dispatch cases, so MONITOR online/offline notifications were silently dropped. Wire them up. Add EventDispatchTests covering invite-notify field mapping and MONITOR online/offline dispatch. --- .../IRCClient/Handlers/ChannelEvents.swift | 8 +- Sources/IRCKit/IRCClient/IRCClient.swift | 8 +- Tests/IRCKitTests/EventDispatchTests.swift | 90 +++++++++++++++++++ 3 files changed, 101 insertions(+), 5 deletions(-) create mode 100644 Tests/IRCKitTests/EventDispatchTests.swift diff --git a/Sources/IRCKit/IRCClient/Handlers/ChannelEvents.swift b/Sources/IRCKit/IRCClient/Handlers/ChannelEvents.swift index 792c61c..a8bdeab 100644 --- a/Sources/IRCKit/IRCClient/Handlers/ChannelEvents.swift +++ b/Sources/IRCKit/IRCClient/Handlers/ChannelEvents.swift @@ -171,13 +171,13 @@ extension IRCClient { return } - guard let channelName = message.parameters[safe: 0], let invitedNick = message.parameters[safe: 1] else { + // INVITE is ` `. The channel is often one we are not a member of + // (a plain invite), so fall back to a transient channel when it is not already tracked. + guard let invitedNick = message.parameters[safe: 0], let channelName = message.parameters[safe: 1] else { return } - guard let channel = self.getChannel(named: channelName) else { - return - } + let channel = self.getChannel(named: channelName) ?? IRCChannel(channelName: channelName, onClient: self) IRCChannelInviteNotification().encode(payload: IRCChannelInviteNotification.IRCChannelInvite( sender: sender, diff --git a/Sources/IRCKit/IRCClient/IRCClient.swift b/Sources/IRCKit/IRCClient/IRCClient.swift index 409aae2..d7b508a 100644 --- a/Sources/IRCKit/IRCClient/IRCClient.swift +++ b/Sources/IRCKit/IRCClient/IRCClient.swift @@ -221,7 +221,7 @@ open class IRCClient: IRCConnectionDelegate, @unchecked Sendable { handleChannelKickEvent(message: message) case .INVITE: - handleChannelKickEvent(message: message) + handleChannelInviteEvent(message: message) case .MODE: handleChannelModeChangeEvent(message: message) @@ -235,6 +235,12 @@ open class IRCClient: IRCConnectionDelegate, @unchecked Sendable { case .RPL_HOSTISHIDDEN: handleHostHidden(message: message) + case .RPL_MONONLINE: + handleMonitorOnlineEvent(message: message) + + case .RPL_MONOFFLINE: + handleMonitorOfflineEvent(message: message) + default: break } diff --git a/Tests/IRCKitTests/EventDispatchTests.swift b/Tests/IRCKitTests/EventDispatchTests.swift new file mode 100644 index 0000000..45255bf --- /dev/null +++ b/Tests/IRCKitTests/EventDispatchTests.swift @@ -0,0 +1,90 @@ +import XCTest +@testable import IRCKit + +/// Tests that inbound commands reach the correct handler and produce the expected notification. +/// Guards two previously-broken advertised features: invite-notify (INVITE was routed to the KICK +/// handler with swapped parameters) and MONITOR (730/731 replies were never dispatched). +final class EventDispatchTests: XCTestCase { + /// Collects payloads delivered on the notification (main) queue so the test thread can read them + /// after `wait(for:)`. Mutated only on the main queue; read only after the wait completes. + private final class Collector: @unchecked Sendable { + private(set) var values: [Value] = [] + func append(_ value: Value) { + values.append(value) + } + } + + private func makeClient() -> IRCClient { + let configuration = IRCClientConfiguration( + serverName: "test", + serverAddress: "irc.example.org", + serverPort: 6697, + nickname: "tester", + username: "tester", + realName: "Integration Tester" + ) + return IRCClient(configuration: configuration) + } + + func testInviteIsDispatchedWithCorrectFields() { + let client = makeClient() + let received = expectation(description: "invite notification") + let collector = Collector() + let token = NotificationCenter.default.addObserver( + descriptor: IRCChannelInviteNotification(), + queue: .main + ) { payload in + collector.append(payload) + received.fulfill() + } + + // ` INVITE ` — the channel is not one we are in. + client.didReceiveDataFromConnection(data: ":inviter!u@host INVITE tester #secret") + + wait(for: [received], timeout: 2.0) + withExtendedLifetime(token) {} + let invite = collector.values.first + XCTAssertEqual(invite?.invitedNick, "tester") + XCTAssertEqual(invite?.channel.name, "#secret") + XCTAssertEqual(invite?.sender.nickname, "inviter") + } + + func testMonitorOnlineReplyIsDispatchedPerTarget() { + let client = makeClient() + let received = expectation(description: "online notifications") + received.expectedFulfillmentCount = 2 + let collector = Collector() + let token = NotificationCenter.default.addObserver( + descriptor: IRCUserOnlineNotification(), + queue: .main + ) { target in + collector.append(target) + received.fulfill() + } + + client.didReceiveDataFromConnection(data: ":irc.example.org 730 tester :Alice!a@host,Bob!b@host") + + wait(for: [received], timeout: 2.0) + withExtendedLifetime(token) {} + XCTAssertEqual(collector.values.sorted(), ["Alice!a@host", "Bob!b@host"]) + } + + func testMonitorOfflineReplyIsDispatched() { + let client = makeClient() + let received = expectation(description: "offline notification") + let collector = Collector() + let token = NotificationCenter.default.addObserver( + descriptor: IRCUserOfflineNotification(), + queue: .main + ) { target in + collector.append(target) + received.fulfill() + } + + client.didReceiveDataFromConnection(data: ":irc.example.org 731 tester :Alice") + + wait(for: [received], timeout: 2.0) + withExtendedLifetime(token) {} + XCTAssertEqual(collector.values, ["Alice"]) + } +} From 427fb0415335aa3c120386a065c949f5ab7f18f3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alex=20S=C3=B8rlie?= Date: Sun, 2 Aug 2026 21:46:26 +0200 Subject: [PATCH 03/11] feat: escape and unescape IRCv3 message-tag values Tag values were parsed and emitted verbatim, so any value containing a semicolon, space, backslash, CR or LF corrupted the tag frame outbound and was read wrong inbound. Add `String.ircTagValueEscaped()` / `ircTagValueUnescaped()` implementing the message-tags escape table (`\\`, `\:`, `\s`, `\r`, `\n`, with unknown escapes resolving to the bare character and a trailing lone backslash dropped). Unescape values when parsing inbound tags and escape them when building outbound tags. Add MessageTagTests covering the escape table, edge cases, round-tripping, and inbound parsing integration. --- Sources/IRCKit/Extensions/String.swift | 44 ++++++++++++++ Sources/IRCKit/IRCClient/IRCClient.swift | 2 +- Sources/IRCKit/IRCMessage.swift | 2 +- Tests/IRCKitTests/MessageTagTests.swift | 75 ++++++++++++++++++++++++ 4 files changed, 121 insertions(+), 2 deletions(-) create mode 100644 Tests/IRCKitTests/MessageTagTests.swift diff --git a/Sources/IRCKit/Extensions/String.swift b/Sources/IRCKit/Extensions/String.swift index 6862bc7..575239d 100644 --- a/Sources/IRCKit/Extensions/String.swift +++ b/Sources/IRCKit/Extensions/String.swift @@ -92,4 +92,48 @@ extension String { return randomString } + /// Escapes this string for use as an IRCv3 message-tag value, per the message-tags spec: + /// `\` → `\\`, `;` → `\:`, space → `\s`, CR → `\r`, LF → `\n`. + func ircTagValueEscaped() -> String { + var escaped = "" + escaped.reserveCapacity(self.count) + for character in self { + switch character { + case "\\": escaped += "\\\\" + case ";": escaped += "\\:" + case " ": escaped += "\\s" + case "\r": escaped += "\\r" + case "\n": escaped += "\\n" + default: escaped.append(character) + } + } + return escaped + } + + /// Reverses IRCv3 message-tag value escaping. Unrecognised escape sequences resolve to the + /// escaped character itself, and a trailing lone backslash is dropped, both per the spec. + func ircTagValueUnescaped() -> String { + var unescaped = "" + unescaped.reserveCapacity(self.count) + var iterator = self.makeIterator() + while let character = iterator.next() { + guard character == "\\" else { + unescaped.append(character) + continue + } + guard let escaped = iterator.next() else { + break + } + switch escaped { + case ":": unescaped.append(";") + case "s": unescaped.append(" ") + case "\\": unescaped.append("\\") + case "r": unescaped.append("\r") + case "n": unescaped.append("\n") + default: unescaped.append(escaped) + } + } + return unescaped + } + } diff --git a/Sources/IRCKit/IRCClient/IRCClient.swift b/Sources/IRCKit/IRCClient/IRCClient.swift index d7b508a..0291379 100644 --- a/Sources/IRCKit/IRCClient/IRCClient.swift +++ b/Sources/IRCKit/IRCClient/IRCClient.swift @@ -288,7 +288,7 @@ open class IRCClient: IRCConnectionDelegate, @unchecked Sendable { if tags.count > 0 && self.hasIRCv3Capability(.messageTags) { let tagString = tags.map({ (key, value) -> String in if let value = value { - return "\(key)=\(value)" + return "\(key)=\(value.ircTagValueEscaped())" } return key }).joined(separator: ";") diff --git a/Sources/IRCKit/IRCMessage.swift b/Sources/IRCKit/IRCMessage.swift index f78315b..57ce03b 100644 --- a/Sources/IRCKit/IRCMessage.swift +++ b/Sources/IRCKit/IRCMessage.swift @@ -123,7 +123,7 @@ public struct IRCMessage: Sendable { guard let key = tagComponents.first, key.isEmpty == false else { return } - tags[String(key)] = tagComponents.count > 1 ? String(tagComponents[1]) : nil + tags[String(key)] = tagComponents.count > 1 ? String(tagComponents[1]).ircTagValueUnescaped() : nil }) } } diff --git a/Tests/IRCKitTests/MessageTagTests.swift b/Tests/IRCKitTests/MessageTagTests.swift new file mode 100644 index 0000000..e827186 --- /dev/null +++ b/Tests/IRCKitTests/MessageTagTests.swift @@ -0,0 +1,75 @@ +import XCTest +@testable import IRCKit + +/// Tests for IRCv3 message-tag value escaping/unescaping (message-tags spec) and its integration +/// into inbound message parsing. +final class MessageTagTests: XCTestCase { + private func makeClient() -> IRCClient { + let configuration = IRCClientConfiguration( + serverName: "test", + serverAddress: "irc.example.org", + serverPort: 6697, + nickname: "tester", + username: "tester", + realName: "Integration Tester" + ) + return IRCClient(configuration: configuration) + } + + func testUnescapeKnownSequences() { + XCTAssertEqual("a\\:b".ircTagValueUnescaped(), "a;b") + XCTAssertEqual("a\\sb".ircTagValueUnescaped(), "a b") + XCTAssertEqual("a\\\\b".ircTagValueUnescaped(), "a\\b") + XCTAssertEqual("a\\rb".ircTagValueUnescaped(), "a\rb") + XCTAssertEqual("a\\nb".ircTagValueUnescaped(), "a\nb") + } + + func testUnescapeEdgeCases() { + // Unrecognised escapes resolve to the escaped character itself. + XCTAssertEqual("a\\xb".ircTagValueUnescaped(), "axb") + // A trailing lone backslash is dropped. + XCTAssertEqual("abc\\".ircTagValueUnescaped(), "abc") + // A value with no escapes is unchanged. + XCTAssertEqual("plain".ircTagValueUnescaped(), "plain") + } + + func testEscapeKnownSequences() { + XCTAssertEqual("a;b".ircTagValueEscaped(), "a\\:b") + XCTAssertEqual("a b".ircTagValueEscaped(), "a\\sb") + XCTAssertEqual("a\\b".ircTagValueEscaped(), "a\\\\b") + XCTAssertEqual("a\rb".ircTagValueEscaped(), "a\\rb") + XCTAssertEqual("a\nb".ircTagValueEscaped(), "a\\nb") + } + + func testEscapeUnescapeRoundTrip() { + let originals = [ + "simple", + "has spaces and ; semicolons", + "back\\slash and \r\n newlines", + "\\:\\s\\\\", + "" + ] + for original in originals { + XCTAssertEqual(original.ircTagValueEscaped().ircTagValueUnescaped(), original) + } + } + + func testEscapingHappensBeforeSeparatorMeaning() { + // A raw semicolon and space must survive a round trip without being read as tag/word + // separators once escaped. + let value = "key1=val1;key2=val2 trailing" + XCTAssertEqual(value.ircTagValueEscaped().ircTagValueUnescaped(), value) + XCTAssertFalse(value.ircTagValueEscaped().contains(";")) + XCTAssertFalse(value.ircTagValueEscaped().contains(" ")) + } + + func testInboundParsingUnescapesTagValues() { + let client = makeClient() + let message = IRCMessage( + line: "@account=a\\sb;+example/tag=x\\:y :nick!user@host PRIVMSG #channel :hi", + client: client + ) + XCTAssertEqual(message?.messageTags["account"], "a b") + XCTAssertEqual(message?.messageTags["+example/tag"], "x;y") + } +} From 1ba25dcff0b02672e2049333216fe00518bef8c4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alex=20S=C3=B8rlie?= Date: Sun, 2 Aug 2026 21:51:30 +0200 Subject: [PATCH 04/11] test: add parser, SASL, and ISUPPORT coverage The test target previously held only an empty example and could not exercise any of the wire-parsing, authentication, or capability logic. Add: - IRCMessageParsingTests: prefix/command/parameter parsing, server vs user prefixes, trailing parameters with spaces and colons, tags, CTCP/ACTION detection, unknown-command rejection, and server-time parsing. - SASLTests: mechanism selection priority (EXTERNAL > SCRAM > PLAIN, and failure without credentials) and the PLAIN AUTHENTICATE payload, captured via the connection send queue. - ISupportTests: PREFIX/NETWORK/CASEMAPPING/WHOX/MONITOR/length-limit parsing, valueless tokens, prefix-mapping replacement, and malformed/invalid values. - IRCTestSupport: shared offline-client factory. --- .../IRCKitTests/IRCMessageParsingTests.swift | 92 +++++++++++++++++++ Tests/IRCKitTests/IRCTestSupport.swift | 20 ++++ Tests/IRCKitTests/ISupportTests.swift | 52 +++++++++++ Tests/IRCKitTests/SASLTests.swift | 70 ++++++++++++++ 4 files changed, 234 insertions(+) create mode 100644 Tests/IRCKitTests/IRCMessageParsingTests.swift create mode 100644 Tests/IRCKitTests/IRCTestSupport.swift create mode 100644 Tests/IRCKitTests/ISupportTests.swift create mode 100644 Tests/IRCKitTests/SASLTests.swift diff --git a/Tests/IRCKitTests/IRCMessageParsingTests.swift b/Tests/IRCKitTests/IRCMessageParsingTests.swift new file mode 100644 index 0000000..cfe60c4 --- /dev/null +++ b/Tests/IRCKitTests/IRCMessageParsingTests.swift @@ -0,0 +1,92 @@ +import XCTest +@testable import IRCKit + +/// Tests for the hand-rolled IRC wire parser in `IRCMessage`. +final class IRCMessageParsingTests: XCTestCase { + func testParsesPrefixCommandAndParameters() { + let message = IRCMessage(line: ":nick!user@host PRIVMSG #channel :hello world", client: IRCTestSupport.client()) + XCTAssertEqual(message?.command, .PRIVMSG) + XCTAssertEqual(message?.sender?.nickname, "nick") + XCTAssertEqual(message?.sender?.username, "user") + XCTAssertEqual(message?.sender?.hostmask, "host") + XCTAssertEqual(message?.sender?.isServer, false) + XCTAssertEqual(message?.parameters, ["#channel", "hello world"]) + } + + func testParsesServerPrefix() { + let message = IRCMessage(line: ":irc.example.org 001 tester :Welcome to the network", client: IRCTestSupport.client()) + XCTAssertEqual(message?.command, .RPL_WELCOME) + XCTAssertEqual(message?.sender?.isServer, true) + XCTAssertEqual(message?.sender?.nickname, "irc.example.org") + XCTAssertNil(message?.sender?.username) + XCTAssertEqual(message?.parameters, ["tester", "Welcome to the network"]) + } + + func testParsesMessageWithoutPrefix() { + let message = IRCMessage(line: "PING :LAG1234567", client: IRCTestSupport.client()) + XCTAssertEqual(message?.command, .PING) + XCTAssertNil(message?.sender) + XCTAssertEqual(message?.parameters, ["LAG1234567"]) + } + + func testTrailingParameterPreservesSpacesAndColons() { + let message = IRCMessage(line: ":n!u@h PRIVMSG #c :see http://x/y :end", client: IRCTestSupport.client()) + XCTAssertEqual(message?.parameters, ["#c", "see http://x/y :end"]) + } + + func testParsesMultipleMiddleParameters() { + let message = IRCMessage( + line: ":irc.example.org 353 tester = #channel :alice bob carol", + client: IRCTestSupport.client() + ) + XCTAssertEqual(message?.command, .RPL_NAMEREPLY) + XCTAssertEqual(message?.parameters, ["tester", "=", "#channel", "alice bob carol"]) + } + + func testParsesTagsPrefixAndCommandTogether() { + let message = IRCMessage(line: "@id=42;account=alice :n!u@h PRIVMSG #c :hi", client: IRCTestSupport.client()) + XCTAssertEqual(message?.messageTags["id"], "42") + XCTAssertEqual(message?.account, "alice") + XCTAssertEqual(message?.command, .PRIVMSG) + XCTAssertEqual(message?.sender?.nickname, "n") + XCTAssertEqual(message?.parameters, ["#c", "hi"]) + } + + func testMessageIdAndLabelFromTags() { + let message = IRCMessage(line: "@msgid=abc123;label=req-1 :n!u@h PRIVMSG #c :hi", client: IRCTestSupport.client()) + XCTAssertEqual(message?.messageId, "abc123") + XCTAssertEqual(message?.label, "req-1") + XCTAssertEqual(message?.id, "req-1") + } + + func testLabelDefaultsToRandomWhenAbsent() { + let message = IRCMessage(line: ":n!u@h PRIVMSG #c :hi", client: IRCTestSupport.client()) + XCTAssertEqual(message?.label.isEmpty, false) + XCTAssertNil(message?.messageId) + } + + func testDetectsCTCPActionMessage() { + let message = IRCMessage(line: ":n!u@h PRIVMSG #c :\u{01}ACTION waves\u{01}", client: IRCTestSupport.client()) + XCTAssertEqual(message?.isCTCPRequest, true) + XCTAssertEqual(message?.isActionMessage, true) + } + + func testPlainMessageIsNotCTCP() { + let message = IRCMessage(line: ":n!u@h PRIVMSG #c :just text", client: IRCTestSupport.client()) + XCTAssertEqual(message?.isCTCPRequest, false) + XCTAssertEqual(message?.isActionMessage, false) + } + + func testUnknownCommandReturnsNil() { + XCTAssertNil(IRCMessage(line: ":n!u@h FLOOOP #c :hi", client: IRCTestSupport.client())) + } + + func testServerTimeTagIsParsedRatherThanNow() { + let message = IRCMessage( + line: "@time=2021-01-15T12:30:45.000Z :n!u@h PRIVMSG #c :hi", + client: IRCTestSupport.client() + ) + let expected = ISO8601DateFormatter().date(from: "2021-01-15T12:30:45Z") + XCTAssertEqual(message?.time.timeIntervalSince1970, expected?.timeIntervalSince1970) + } +} diff --git a/Tests/IRCKitTests/IRCTestSupport.swift b/Tests/IRCKitTests/IRCTestSupport.swift new file mode 100644 index 0000000..996a8ab --- /dev/null +++ b/Tests/IRCKitTests/IRCTestSupport.swift @@ -0,0 +1,20 @@ +@testable import IRCKit + +/// Shared factories for building an offline `IRCClient` in tests. `autoConnect` defaults to false, so +/// no network connection is opened. +enum IRCTestSupport { + static func configuration() -> IRCClientConfiguration { + IRCClientConfiguration( + serverName: "test", + serverAddress: "irc.example.org", + serverPort: 6697, + nickname: "tester", + username: "tester", + realName: "Integration Tester" + ) + } + + static func client() -> IRCClient { + IRCClient(configuration: configuration()) + } +} diff --git a/Tests/IRCKitTests/ISupportTests.swift b/Tests/IRCKitTests/ISupportTests.swift new file mode 100644 index 0000000..d85187a --- /dev/null +++ b/Tests/IRCKitTests/ISupportTests.swift @@ -0,0 +1,52 @@ +import XCTest +@testable import IRCKit + +/// Tests for RPL_ISUPPORT (005) parsing in `IRCServerInfo.setSupported`. +final class ISupportTests: XCTestCase { + func testParsesCommonTokens() { + let client = IRCTestSupport.client() + let line = ":irc.example.org 005 tester PREFIX=(ov)@+ CHANTYPES=# NETWORK=TestNet " + + "CASEMAPPING=rfc1459 WHOX AWAYLEN=200 NICKLEN=30 MONITOR=100 :are supported by this server" + client.didReceiveDataFromConnection(data: line) + + let info = client.serverInfo + XCTAssertEqual(info.networkName, "TestNet") + XCTAssertEqual(info.caseMapping, "rfc1459") + XCTAssertTrue(info.supportsExtendedWhoQuery) + XCTAssertTrue(info.supportsMonitor) + XCTAssertEqual(info.maximumMonitorTargets, 100) + XCTAssertEqual(info.maximumAwayMessageLength, 200) + XCTAssertEqual(info.maximumNicknameLength, 30) + XCTAssertEqual(info.prefixMapping[.operator], "@") + XCTAssertEqual(info.prefixMapping[.voice], "+") + } + + func testValuelessTokenIsRecognised() { + let client = IRCTestSupport.client() + XCTAssertFalse(client.serverInfo.supportsExtendedWhoQuery) + client.didReceiveDataFromConnection(data: ":irc.example.org 005 tester WHOX :are supported by this server") + XCTAssertTrue(client.serverInfo.supportsExtendedWhoQuery) + } + + func testPrefixMappingReplacesDefault() { + let client = IRCTestSupport.client() + // Default mapping includes owner/admin/halfop; a server offering only o/v should narrow it. + client.didReceiveDataFromConnection(data: ":irc.example.org 005 tester PREFIX=(ov)@+ :are supported") + let mapping = client.serverInfo.prefixMapping + XCTAssertEqual(mapping[.operator], "@") + XCTAssertEqual(mapping[.voice], "+") + XCTAssertNil(mapping[.owner]) + } + + func testMalformedPrefixYieldsEmptyMapping() { + let client = IRCTestSupport.client() + client.didReceiveDataFromConnection(data: ":irc.example.org 005 tester PREFIX=noparens :are supported") + XCTAssertTrue(client.serverInfo.prefixMapping.isEmpty) + } + + func testInvalidNumericValuesAreIgnored() { + let client = IRCTestSupport.client() + client.didReceiveDataFromConnection(data: ":irc.example.org 005 tester AWAYLEN=notanumber :are supported") + XCTAssertNil(client.serverInfo.maximumAwayMessageLength) + } +} diff --git a/Tests/IRCKitTests/SASLTests.swift b/Tests/IRCKitTests/SASLTests.swift new file mode 100644 index 0000000..288de23 --- /dev/null +++ b/Tests/IRCKitTests/SASLTests.swift @@ -0,0 +1,70 @@ +import Foundation +import XCTest +@testable import IRCKit + +/// Tests for SASL mechanism selection and the PLAIN authentication payload. +final class SASLTests: XCTestCase { + /// Setting the flood-control ceiling to zero forces every outbound line into + /// `IRCConnection.sendQueue` (rather than being written to a socket that isn't connected), so a + /// test can inspect exactly what the client would have sent. + private func queueingClient(configure: (inout IRCClientConfiguration) -> Void = { _ in }) -> IRCClient { + var configuration = IRCTestSupport.configuration() + configuration.floodControlMaximumMessages = 0 + configure(&configuration) + return IRCClient(configuration: configuration) + } + + func testExternalIsPreferredWhenClientCertificateConfigured() { + let client = queueingClient { $0.clientCertificatePath = "/path/to/cert.pem" } + client.serverInfo.supportedSASLMechanisms = [PlainTextSASLHandler.self, ExternalSASLHandler.self] + + XCTAssertTrue(client.saslNegotiation()) + XCTAssertTrue(client.activeAuthenticationHandler is ExternalSASLHandler) + } + + func testScramIsPreferredOverPlainWhenBothOffered() { + let client = queueingClient { $0.authenticationPassword = "s3cr3t" } + client.serverInfo.supportedSASLMechanisms = [PlainTextSASLHandler.self, Sha256SASLHandler.self] + + XCTAssertTrue(client.saslNegotiation()) + XCTAssertTrue(client.activeAuthenticationHandler is Sha256SASLHandler) + } + + func testPlainIsSelectedWhenOnlyMechanismOffered() { + let client = queueingClient { $0.authenticationPassword = "s3cr3t" } + client.serverInfo.supportedSASLMechanisms = [PlainTextSASLHandler.self] + + XCTAssertTrue(client.saslNegotiation()) + XCTAssertTrue(client.activeAuthenticationHandler is PlainTextSASLHandler) + } + + func testNegotiationFailsWithoutCredentials() { + let client = queueingClient() + client.serverInfo.supportedSASLMechanisms = [PlainTextSASLHandler.self] + + XCTAssertFalse(client.saslNegotiation()) + XCTAssertNil(client.activeAuthenticationHandler) + } + + func testPlainProducesCorrectAuthenticatePayload() { + let client = queueingClient { + $0.authenticationUsername = "alice" + $0.authenticationPassword = "s3cr3t" + } + client.serverInfo.supportedSASLMechanisms = [PlainTextSASLHandler.self] + + XCTAssertTrue(client.saslNegotiation()) + // The server acknowledges the mechanism with an empty challenge; the client responds with the + // base64-encoded `authzid\0authcid\0passwd` credential. + client.didReceiveDataFromConnection(data: "AUTHENTICATE +") + + let payloadLine = client.connection.sendQueue.last(where: { + $0.hasPrefix("AUTHENTICATE ") && $0 != "AUTHENTICATE PLAIN" + }) + let base64 = payloadLine.map { String($0.dropFirst("AUTHENTICATE ".count)) } + let decoded = base64 + .flatMap { Data(base64Encoded: $0) } + .flatMap { String(data: $0, encoding: .utf8) } + XCTAssertEqual(decoded, "alice\u{0}alice\u{0}s3cr3t") + } +} From 099d718d6134056cf64f682a0c335234fd8ab38c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alex=20S=C3=B8rlie?= Date: Mon, 3 Aug 2026 13:20:48 +0200 Subject: [PATCH 05/11] fix(sasl): correct SCRAM-SHA-256 to RFC 5802/7677 The previous implementation could not authenticate against any conforming server and did not actually verify the server: - The AuthMessage was assembled from an unordered dictionary rather than the required `client-first-bare , server-first , client-final-without-proof` concatenation, so the proof never matched the server's computation. - The salt was fed to PBKDF2 as raw UTF-8 bytes instead of being base64-decoded (and the attribute parser split on every `=`, truncating the salt's base64 padding). - Server-signature verification compared the base64 *text* bytes of `v=` against the raw signature, so it could never match. Rewrite the exchange to build the messages in the exact order the spec prescribes, retain the client-first-bare and server-first strings verbatim for the AuthMessage, base64-decode the salt, escape the username (`=3D`/`=2C`), verify the server nonce is prefixed by the client nonce, and compare the server signature in constant time after base64-decoding it. Add SCRAMTests driving the canonical RFC 7677 vector (user/pencil): the derived client-final proof and the server-signature acceptance both match the RFC, plus abort paths for a bad signature, a forged nonce, and a server error. Remove the now-unused keyValueString dictionary helpers. --- Sources/IRCKit/Extensions/String.swift | 18 -- .../IRCClient/Authentication/SASLSha256.swift | 188 ++++++++++++------ Tests/IRCKitTests/SCRAMTests.swift | 116 +++++++++++ 3 files changed, 242 insertions(+), 80 deletions(-) create mode 100644 Tests/IRCKitTests/SCRAMTests.swift diff --git a/Sources/IRCKit/Extensions/String.swift b/Sources/IRCKit/Extensions/String.swift index 575239d..956cb9c 100644 --- a/Sources/IRCKit/Extensions/String.swift +++ b/Sources/IRCKit/Extensions/String.swift @@ -35,24 +35,6 @@ extension Array where Element == String { } } -extension Dictionary where Key == String, Value == String { - func keyValueString(joinedBy separator: String) -> String { - return self.map({ (kv: (String, String)) -> String in - let (key, value) = kv - return "\(key)=\(value)" - }).joined(separator: separator) - } -} - -extension Dictionary where Key == String, Value == String? { - func keyValueString(joinedBy separator: String) -> String { - return self.map({ (kv: (String, String?)) -> String in - let (key, value) = kv - return value != nil ? "\(key)=\(value!)" : key - }).joined(separator: separator) - } -} - extension Array where Element == UInt8 { func xor(with key: [UInt8]) -> String? { if self.isEmpty { diff --git a/Sources/IRCKit/IRCClient/Authentication/SASLSha256.swift b/Sources/IRCKit/IRCClient/Authentication/SASLSha256.swift index 3653548..06bca16 100644 --- a/Sources/IRCKit/IRCClient/Authentication/SASLSha256.swift +++ b/Sources/IRCKit/IRCClient/Authentication/SASLSha256.swift @@ -1,18 +1,18 @@ /* Copyright 2020 The Fuel Rats Mischief - + Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: - + 1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. - + 2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. - + 3. Neither the name of the copyright holder nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission. - + THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, @@ -25,11 +25,22 @@ import Foundation import CryptoSwift +/// SCRAM-SHA-256 SASL authentication, per RFC 5802 (SCRAM) and RFC 7677 (SHA-256 profile). +/// +/// The exchange is: client-first (`n,,n=user,r=nonce`) → server-first (`r=,s=,i=`) → +/// client-final (`c=,r=,p=proof`) → server-final (`v=signature`). The client derives its proof from +/// the salted password and verifies the server's signature before completing. class Sha256SASLHandler: SASLHandler { static let mechanism = "SCRAM-SHA-256" var client: IRCClient - var nonce: String? - var serverSignature: [UInt8]? + + /// Overrides the randomly generated client nonce. This is a test-only seam for reproducing the + /// fixed RFC 7677 vector; it is nil in normal operation. + var clientNonceOverride: String? + + private var clientNonce: String? + private var clientFirstMessageBare: String? + private var expectedServerSignature: [UInt8]? required init(client: IRCClient) { self.client = client @@ -41,123 +52,176 @@ class Sha256SASLHandler: SASLHandler { return } if response == "+" { - self.sendAuthenticationChallenge() + self.sendClientFirstMessage() } else { - parseSASLScramResponse(message: message) + self.handleServerMessage(response) } } - func sendAuthenticationChallenge() { - let username = self.client.configuration.authenticationUsername ?? self.client.configuration.username - let nonce = String.random(length: 32) - self.nonce = nonce + private func sendClientFirstMessage() { + let username = Sha256SASLHandler.encodeUsername( + self.client.configuration.authenticationUsername ?? self.client.configuration.username + ) + let clientNonce = self.clientNonceOverride ?? String.random(length: 32) + self.clientNonce = clientNonce + + let clientFirstMessageBare = "n=\(username),r=\(clientNonce)" + self.clientFirstMessageBare = clientFirstMessageBare - let challenge = "n,,n=\(username),r=\(nonce)" - guard let encodedChallenge = challenge.data(using: .utf8)?.base64EncodedString() else { + // gs2-header "n,," — no channel binding, no authzid. + let clientFirstMessage = "n,,\(clientFirstMessageBare)" + guard let encoded = clientFirstMessage.data(using: .utf8)?.base64EncodedString() else { self.client.abortSaslAuthentication() return } - - self.client.sendAuthenticate(message: encodedChallenge) + self.client.sendAuthenticate(message: encoded) } - func parseSASLScramResponse(message: IRCMessage) { + private func handleServerMessage(_ response: String) { guard - let response = message.parameters[safe: 0], - let scramData = Data(base64Encoded: response), - let scramMessage = String(data: scramData, encoding: .utf8) + let data = Data(base64Encoded: response), + let message = String(data: data, encoding: .utf8) else { + self.client.abortSaslAuthentication() return } - let scramParams = scramMessage.keyValuePairs(separatedBy: ",") + let attributes = Sha256SASLHandler.parseAttributes(message) - guard scramParams["e"] == nil else { + guard attributes["e"] == nil else { self.client.abortSaslAuthentication() return } - if let verification = scramParams["v"] as? String { - scramSha256Verify(verification: verification) + if let verifier = attributes["v"] { + self.verifyServerSignature(verifier) return } guard - let nonce = scramParams["r"] as? String, - let salt = scramParams["s"] as? String, - let iterationCount = Int(scramParams["i"] as? String ?? "") + let fullNonce = attributes["r"], + let encodedSalt = attributes["s"], + let salt = Data(base64Encoded: encodedSalt), + let iterationString = attributes["i"], + let iterations = Int(iterationString), iterations > 0, + let clientNonce = self.clientNonce, + fullNonce.hasPrefix(clientNonce) else { + self.client.abortSaslAuthentication() return } - scramSha256Authenticate(salt: salt, nonce: nonce, iterationCount: iterationCount, message: scramMessage) + self.sendClientFinalMessage( + serverFirstMessage: message, + fullNonce: fullNonce, + salt: Array(salt), + iterations: iterations + ) } - /* Thank you to github.com/moortens for documenting how this works in their "yoil" IRC Library - because the people who made the specification sure didn't bother to. */ - func scramSha256Authenticate(salt: String, nonce: String, iterationCount: Int, message: String) { + private func sendClientFinalMessage( + serverFirstMessage: String, + fullNonce: String, + salt: [UInt8], + iterations: Int + ) { guard let password = self.client.configuration.authenticationPassword, - let saltedPassword = pbkdf2(password: password, salt: salt, iteration: iterationCount), + let clientFirstMessageBare = self.clientFirstMessageBare, + let saltedPassword = Sha256SASLHandler.pbkdf2(password: password, salt: salt, iterations: iterations), let clientKey = try? HMAC(key: saltedPassword, variant: .sha2(.sha256)) .authenticate(Array("Client Key".utf8)), let serverKey = try? HMAC(key: saltedPassword, variant: .sha2(.sha256)) - .authenticate(Array("Server Key".utf8)), - let base64Message = message.data(using: .utf8)?.base64EncodedString() + .authenticate(Array("Server Key".utf8)) else { self.client.abortSaslAuthentication() return } let storedKey = Digest.sha256(clientKey) - let username = self.client.configuration.authenticationUsername ?? self.client.configuration.username - let authMessage = [ - "n": username, - "r": nonce, - base64Message: nil, - "c": "biws" - ].keyValueString(joinedBy: ",") + + // The gs2-header "n,," base64-encoded is the constant "biws". + let channelBinding = Data("n,,".utf8).base64EncodedString() + let clientFinalMessageWithoutProof = "c=\(channelBinding),r=\(fullNonce)" + let authMessage = "\(clientFirstMessageBare),\(serverFirstMessage),\(clientFinalMessageWithoutProof)" guard let clientSignature = try? HMAC(key: storedKey, variant: .sha2(.sha256)) .authenticate(Array(authMessage.utf8)), let serverSignature = try? HMAC(key: serverKey, variant: .sha2(.sha256)) .authenticate(Array(authMessage.utf8)), - let clientXor = clientKey.xor(with: clientSignature) + let clientProof = clientKey.xor(with: clientSignature) else { self.client.abortSaslAuthentication() return } - self.serverSignature = serverSignature + self.expectedServerSignature = serverSignature - let final = [ - "c": "biws", - "r": nonce, - "p": clientXor - ].keyValueString(joinedBy: ",") - - guard let base64Final = final.data(using: .utf8)?.base64EncodedString() else { + let clientFinalMessage = "\(clientFinalMessageWithoutProof),p=\(clientProof)" + guard let encoded = clientFinalMessage.data(using: .utf8)?.base64EncodedString() else { self.client.abortSaslAuthentication() return } - - self.client.sendAuthenticate(message: base64Final) + self.client.sendAuthenticate(message: encoded) } - func scramSha256Verify(verification: String) { - if verification.bytes == self.serverSignature { + private func verifyServerSignature(_ verifier: String) { + guard + let expected = self.expectedServerSignature, + let decoded = Data(base64Encoded: verifier) + else { + self.client.abortSaslAuthentication() + return + } + + if Sha256SASLHandler.constantTimeEquals(Array(decoded), expected) { self.client.sendAuthenticate(message: "+") } else { self.client.abortSaslAuthentication() } } -} -private func pbkdf2(password: String, salt: String, iteration: Int) -> [UInt8]? { - return try? PKCS5.PBKDF2( - password: Array(password.utf8), - salt: Array(salt.utf8), - iterations: iteration, variant: .sha2(.sha256) - ).calculate() + /// Parses a SCRAM message into its single-letter attributes. Values may themselves contain `=` + /// (e.g. base64 salt padding), so only the first `=` separates key from value. + private static func parseAttributes(_ message: String) -> [String: String] { + var attributes: [String: String] = [:] + for token in message.split(separator: ",") { + let key = String(token.prefix(1)) + let afterKey = token.dropFirst() + guard afterKey.first == "=" else { + continue + } + attributes[key] = String(afterKey.dropFirst()) + } + return attributes + } + + /// Escapes a SASL username for the `n=` attribute, per RFC 5802: `=` → `=3D`, `,` → `=2C`. + /// The `=` substitution runs first so it does not corrupt the escapes it introduces. + private static func encodeUsername(_ username: String) -> String { + return username + .replacingOccurrences(of: "=", with: "=3D") + .replacingOccurrences(of: ",", with: "=2C") + } + + private static func pbkdf2(password: String, salt: [UInt8], iterations: Int) -> [UInt8]? { + return try? PKCS5.PBKDF2( + password: Array(password.utf8), + salt: salt, + iterations: iterations, + variant: .sha2(.sha256) + ).calculate() + } + + private static func constantTimeEquals(_ lhs: [UInt8], _ rhs: [UInt8]) -> Bool { + guard lhs.count == rhs.count else { + return false + } + var difference: UInt8 = 0 + for (left, right) in zip(lhs, rhs) { + difference |= left ^ right + } + return difference == 0 + } } diff --git a/Tests/IRCKitTests/SCRAMTests.swift b/Tests/IRCKitTests/SCRAMTests.swift new file mode 100644 index 0000000..a83edee --- /dev/null +++ b/Tests/IRCKitTests/SCRAMTests.swift @@ -0,0 +1,116 @@ +import Foundation +import XCTest +@testable import IRCKit + +/// SCRAM-SHA-256 tests driven by the canonical RFC 7677 test vector (username `user`, +/// password `pencil`, client nonce `rOprNGfwEbeRWgbNEkqO`). +final class SCRAMTests: XCTestCase { + private let clientNonce = "rOprNGfwEbeRWgbNEkqO" + private let serverFirstMessage = + "r=rOprNGfwEbeRWgbNEkqO%hvYDpWUa2RaTCAfuxFIlj)hNlF$k0,s=W22ZaJ0SNY7soEsUEjb6gQ==,i=4096" + private let expectedClientFinalMessage = + "c=biws,r=rOprNGfwEbeRWgbNEkqO%hvYDpWUa2RaTCAfuxFIlj)hNlF$k0," + + "p=dHzbZapWIk4jUhN+Ute9ytag9zjfMHgsqmmiz7AndVQ=" + private let serverFinalMessage = "v=6rriTRBi23WpRR/wtup+mMhUZUn/dB5nLTJRsjl95G4=" + + /// A client whose outbound lines accumulate in `IRCConnection.sendQueue` (flood ceiling zero), + /// with the RFC vector's credentials. + private func makeClient() -> IRCClient { + var configuration = IRCTestSupport.configuration() + configuration.floodControlMaximumMessages = 0 + configuration.authenticationUsername = "user" + configuration.authenticationPassword = "pencil" + return IRCClient(configuration: configuration) + } + + private func makeHandler(_ client: IRCClient) -> Sha256SASLHandler { + let handler = Sha256SASLHandler(client: client) + handler.clientNonceOverride = clientNonce + return handler + } + + private func authenticate(_ payload: String, client: IRCClient) -> IRCMessage { + return IRCMessage(line: "AUTHENTICATE \(payload)", client: client)! + } + + private func base64(_ text: String) -> String { + return Data(text.utf8).base64EncodedString() + } + + private func lastSentLine(_ client: IRCClient) -> String? { + return client.connection.sendQueue.last + } + + private func lastDecodedAuthenticate(_ client: IRCClient) -> String? { + guard let line = lastSentLine(client), line.hasPrefix("AUTHENTICATE ") else { + return nil + } + return Data(base64Encoded: String(line.dropFirst("AUTHENTICATE ".count))) + .flatMap { String(data: $0, encoding: .utf8) } + } + + func testClientFirstMessageMatchesVector() { + let client = makeClient() + let handler = makeHandler(client) + + handler.handleResponse(message: authenticate("+", client: client)) + + XCTAssertEqual(lastDecodedAuthenticate(client), "n,,n=user,r=\(clientNonce)") + } + + func testClientFinalMessageMatchesVector() { + let client = makeClient() + let handler = makeHandler(client) + + handler.handleResponse(message: authenticate("+", client: client)) + handler.handleResponse(message: authenticate(base64(serverFirstMessage), client: client)) + + XCTAssertEqual(lastDecodedAuthenticate(client), expectedClientFinalMessage) + } + + func testValidServerSignatureCompletesAuthentication() { + let client = makeClient() + let handler = makeHandler(client) + + handler.handleResponse(message: authenticate("+", client: client)) + handler.handleResponse(message: authenticate(base64(serverFirstMessage), client: client)) + handler.handleResponse(message: authenticate(base64(serverFinalMessage), client: client)) + + // The client acknowledges a valid server signature with an empty response. + XCTAssertEqual(lastSentLine(client), "AUTHENTICATE +") + } + + func testInvalidServerSignatureAborts() { + let client = makeClient() + let handler = makeHandler(client) + + handler.handleResponse(message: authenticate("+", client: client)) + handler.handleResponse(message: authenticate(base64(serverFirstMessage), client: client)) + // Flip a byte of the server signature. + let tampered = "v=7rriTRBi23WpRR/wtup+mMhUZUn/dB5nLTJRsjl95G4=" + handler.handleResponse(message: authenticate(base64(tampered), client: client)) + + XCTAssertEqual(lastSentLine(client), "AUTHENTICATE *") + } + + func testServerNonceNotPrefixedByClientNonceAborts() { + let client = makeClient() + let handler = makeHandler(client) + + handler.handleResponse(message: authenticate("+", client: client)) + let forgedFirst = "r=totallyDifferentNonce,s=W22ZaJ0SNY7soEsUEjb6gQ==,i=4096" + handler.handleResponse(message: authenticate(base64(forgedFirst), client: client)) + + XCTAssertEqual(lastSentLine(client), "AUTHENTICATE *") + } + + func testServerErrorAttributeAborts() { + let client = makeClient() + let handler = makeHandler(client) + + handler.handleResponse(message: authenticate("+", client: client)) + handler.handleResponse(message: authenticate(base64("e=invalid-proof"), client: client)) + + XCTAssertEqual(lastSentLine(client), "AUTHENTICATE *") + } +} From 1f38cf70a43ff8fd0f4f582478f9244005962d02 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alex=20S=C3=B8rlie?= Date: Mon, 3 Aug 2026 13:26:21 +0200 Subject: [PATCH 06/11] fix: parse channel timestamps as unix epoch seconds MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit RPL_TOPICWHOTIME (333) and RPL_CREATIONTIME (329) carry the set/creation time as whole seconds since the Unix epoch, but both were parsed with the ISO8601 formatter, which always failed — so topic author/time and channel creation time were silently dropped. Add `Date.fromUnixTimestamp(_:)` and use it for both numerics. Add ChannelInfoTests covering the epoch parsing and a non-numeric value. --- Sources/IRCKit/Extensions/DateFormatter.swift | 11 +++++ .../IRCClient/Handlers/ChannelInfo.swift | 4 +- Tests/IRCKitTests/ChannelInfoTests.swift | 45 +++++++++++++++++++ 3 files changed, 58 insertions(+), 2 deletions(-) create mode 100644 Tests/IRCKitTests/ChannelInfoTests.swift diff --git a/Sources/IRCKit/Extensions/DateFormatter.swift b/Sources/IRCKit/Extensions/DateFormatter.swift index f55aa66..9107d98 100644 --- a/Sources/IRCKit/Extensions/DateFormatter.swift +++ b/Sources/IRCKit/Extensions/DateFormatter.swift @@ -34,3 +34,14 @@ extension DateFormatter { return formatter }() } + +extension Date { + /// Parses an IRC server timestamp, expressed as whole seconds since the Unix epoch (e.g. the + /// `setat` field of RPL_TOPICWHOTIME and the creation time of RPL_CREATIONTIME). + static func fromUnixTimestamp(_ string: String) -> Date? { + guard let seconds = TimeInterval(string) else { + return nil + } + return Date(timeIntervalSince1970: seconds) + } +} diff --git a/Sources/IRCKit/IRCClient/Handlers/ChannelInfo.swift b/Sources/IRCKit/IRCClient/Handlers/ChannelInfo.swift index f035bd6..e616e6e 100644 --- a/Sources/IRCKit/IRCClient/Handlers/ChannelInfo.swift +++ b/Sources/IRCKit/IRCClient/Handlers/ChannelInfo.swift @@ -121,7 +121,7 @@ extension IRCClient { guard let author = message.parameters[safe: 2], let dateString = message.parameters[safe: 3], - let date = DateFormatter.iso8601Full.date(from: dateString) + let date = Date.fromUnixTimestamp(dateString) else { return } @@ -158,7 +158,7 @@ extension IRCClient { guard let createdString = message.parameters[safe: 2], - let date = DateFormatter.iso8601Full.date(from: createdString) + let date = Date.fromUnixTimestamp(createdString) else { return } diff --git a/Tests/IRCKitTests/ChannelInfoTests.swift b/Tests/IRCKitTests/ChannelInfoTests.swift new file mode 100644 index 0000000..0642eb7 --- /dev/null +++ b/Tests/IRCKitTests/ChannelInfoTests.swift @@ -0,0 +1,45 @@ +import Foundation +import XCTest +@testable import IRCKit + +/// Tests for channel metadata numerics whose timestamps are Unix epoch seconds (not ISO8601). +final class ChannelInfoTests: XCTestCase { + private let epochSeconds: TimeInterval = 1_609_459_200 // 2021-01-01T00:00:00Z + + private func clientJoinedTo(_ channelName: String) -> IRCClient { + let client = IRCTestSupport.client() + client.didReceiveDataFromConnection(data: ":tester!u@host JOIN \(channelName)") + return client + } + + private func channel(_ client: IRCClient, _ name: String) -> IRCChannel? { + return client.channels.first(where: { $0.name == name }) + } + + func testTopicWhoTimeParsesUnixTimestamp() { + let client = clientJoinedTo("#chan") + client.didReceiveDataFromConnection(data: ":irc.example.org 332 tester #chan :The topic") + client.didReceiveDataFromConnection(data: ":irc.example.org 333 tester #chan setterNick 1609459200") + + let topic = channel(client, "#chan")?.topic + XCTAssertEqual(topic?.author, "setterNick") + XCTAssertEqual(topic?.date, Date(timeIntervalSince1970: epochSeconds)) + } + + func testCreationTimeParsesUnixTimestamp() { + let client = clientJoinedTo("#chan") + client.didReceiveDataFromConnection(data: ":irc.example.org 329 tester #chan 1609459200") + + XCTAssertEqual(channel(client, "#chan")?.createdAt, Date(timeIntervalSince1970: epochSeconds)) + } + + func testNonNumericTimestampIsIgnored() { + let client = clientJoinedTo("#chan") + client.didReceiveDataFromConnection(data: ":irc.example.org 332 tester #chan :The topic") + client.didReceiveDataFromConnection(data: ":irc.example.org 333 tester #chan setterNick notanumber") + + // The topic survives, but the unparseable set-time is dropped rather than crashing. + XCTAssertEqual(channel(client, "#chan")?.topic?.contents, "The topic") + XCTAssertNil(channel(client, "#chan")?.topic?.date) + } +} From 9f938a2d52be8456ea0d8787ee1d16a53ad65a61 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alex=20S=C3=B8rlie?= Date: Mon, 3 Aug 2026 13:26:21 +0200 Subject: [PATCH 07/11] fix(networking): raise minimum TLS version to 1.2 The client accepted TLS 1.1 (2006), which is deprecated and exposed to downgrade/BEAST-class attacks. Require TLS 1.2 as the floor. --- Sources/IRCKit/IRCConnection/IRCConnection.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/IRCKit/IRCConnection/IRCConnection.swift b/Sources/IRCKit/IRCConnection/IRCConnection.swift index b57d9c8..c41ae9f 100644 --- a/Sources/IRCKit/IRCConnection/IRCConnection.swift +++ b/Sources/IRCKit/IRCConnection/IRCConnection.swift @@ -69,7 +69,7 @@ public class IRCConnection: ChannelInboundHandler, @unchecked Sendable { var sslConfiguration = TLSConfiguration.makeClientConfiguration() sslConfiguration.cipherSuites = configuration.chiperSuite ?? TLSConfiguration.clientDefault.cipherSuites - sslConfiguration.minimumTLSVersion = .tlsv11 + sslConfiguration.minimumTLSVersion = .tlsv12 sslConfiguration.maximumTLSVersion = nil sslConfiguration.certificateVerification = verification sslConfiguration.certificateChain = certificateChain From e388e840e1fad3aede3d92babf7abf10f3b7dd3c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alex=20S=C3=B8rlie?= Date: Mon, 3 Aug 2026 13:26:21 +0200 Subject: [PATCH 08/11] fix: quote all trailing parameters and strip forbidden characters Outbound line construction only prefixed the trailing parameter with ':' when it contained a space, so a one-word message beginning with ':' (e.g. ":)") was sent unquoted and read by the server as an empty parameter. It also never removed NUL/CR/LF, letting caller-supplied text inject additional commands. Prefix the last parameter with ':' whenever it is empty, contains a space, or begins with ':', and strip NUL/CR/LF from every parameter (over unicode scalars, since Swift treats CR+LF as one grapheme). Drop the now-redundant manual ':' on PONG. Add SendMethodTests for quoting, injection stripping, and PONG. --- Sources/IRCKit/Extensions/String.swift | 9 +++ Sources/IRCKit/IRCClient/IRCClient.swift | 13 +++-- Tests/IRCKitTests/SendMethodTests.swift | 70 ++++++++++++++++++++++++ 3 files changed, 86 insertions(+), 6 deletions(-) create mode 100644 Tests/IRCKitTests/SendMethodTests.swift diff --git a/Sources/IRCKit/Extensions/String.swift b/Sources/IRCKit/Extensions/String.swift index 956cb9c..f907b45 100644 --- a/Sources/IRCKit/Extensions/String.swift +++ b/Sources/IRCKit/Extensions/String.swift @@ -59,6 +59,15 @@ extension String { return tokens.keyValuePairs() } + /// Removes the characters forbidden inside an IRC parameter (NUL, CR, LF) so caller-supplied + /// text cannot terminate the line and inject additional commands. Filtering operates on unicode + /// scalars because Swift treats a CR+LF pair as a single grapheme cluster. + func ircParameterSanitized() -> String { + return String(String.UnicodeScalarView(self.unicodeScalars.filter({ + $0 != "\0" && $0 != "\r" && $0 != "\n" + }))) + } + static func random(length: Int = 20) -> String { let base = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" var randomString: String = "" diff --git a/Sources/IRCKit/IRCClient/IRCClient.swift b/Sources/IRCKit/IRCClient/IRCClient.swift index 0291379..e3f4eed 100644 --- a/Sources/IRCKit/IRCClient/IRCClient.swift +++ b/Sources/IRCKit/IRCClient/IRCClient.swift @@ -145,7 +145,7 @@ open class IRCClient: IRCConnectionDelegate, @unchecked Sendable { guard let token = message.parameters[safe: 0] else { break } - self.send(command: .PONG, parameters: [":\(token)"]) + self.send(command: .PONG, parameters: [token]) case .RPL_WELCOME: if @@ -271,15 +271,16 @@ open class IRCClient: IRCConnectionDelegate, @unchecked Sendable { public func send(_ command: String, parameters: [String], tags: [String: String?] = [:]) { var tags = tags - var params = parameters + // Strip NUL/CR/LF so caller-supplied text cannot inject additional commands into the stream. + var params = parameters.map({ $0.ircParameterSanitized() }) if self.hasIRCv3Capability(.labeledResponses) && tags["label"] == nil { tags["label"] = String.random(length: 10) } - /* In IRC if a command has more than one argument, - the last argument can contain spaces if it is prefixed with : */ - let lastParam = params.last ?? "" - if params.count > 1 && lastParam.components(separatedBy: .whitespaces).count > 1 { + /* The last argument is the "trailing" parameter and must be prefixed with ':' when it is + empty, contains a space, or itself begins with ':', otherwise the server would misparse it + (for example a one-word message ":)" would be read as an empty trailing parameter). */ + if let lastParam = params.last, lastParam.isEmpty || lastParam.contains(" ") || lastParam.hasPrefix(":") { params[params.count - 1] = ":" + lastParam } diff --git a/Tests/IRCKitTests/SendMethodTests.swift b/Tests/IRCKitTests/SendMethodTests.swift new file mode 100644 index 0000000..a75b66f --- /dev/null +++ b/Tests/IRCKitTests/SendMethodTests.swift @@ -0,0 +1,70 @@ +import XCTest +@testable import IRCKit + +/// Tests for outbound line construction in `IRCClient.send`: trailing-parameter quoting and +/// sanitisation of forbidden characters. +final class SendMethodTests: XCTestCase { + /// A client whose outbound lines accumulate in `IRCConnection.sendQueue` rather than being + /// written to a socket that isn't connected. + private func queueingClient() -> IRCClient { + var configuration = IRCTestSupport.configuration() + configuration.floodControlMaximumMessages = 0 + return IRCClient(configuration: configuration) + } + + private func lastLine(_ client: IRCClient) -> String? { + return client.connection.sendQueue.last + } + + func testTrailingParameterWithSpaceIsColonPrefixed() { + let client = queueingClient() + client.send("PRIVMSG", parameters: ["#channel", "hello world"]) + XCTAssertEqual(lastLine(client), "PRIVMSG #channel :hello world") + } + + func testTrailingParameterStartingWithColonIsColonPrefixed() { + let client = queueingClient() + // A one-word message that begins with ':' must still be quoted, or it reads as empty. + client.send("PRIVMSG", parameters: ["#channel", ":)"]) + XCTAssertEqual(lastLine(client), "PRIVMSG #channel ::)") + } + + func testEmptyTrailingParameterIsColonPrefixed() { + let client = queueingClient() + client.send("PART", parameters: ["#channel", ""]) + XCTAssertEqual(lastLine(client), "PART #channel :") + } + + func testSingleWordTrailingParameterIsNotColonPrefixed() { + let client = queueingClient() + client.send("PRIVMSG", parameters: ["#channel", "hello"]) + XCTAssertEqual(lastLine(client), "PRIVMSG #channel hello") + } + + func testSingleParameterIsNotColonPrefixed() { + let client = queueingClient() + client.send("JOIN", parameters: ["#channel"]) + XCTAssertEqual(lastLine(client), "JOIN #channel") + } + + func testForbiddenCharactersAreStrippedToPreventInjection() { + let client = queueingClient() + client.send("PRIVMSG", parameters: ["#channel", "hi there\r\nJOIN #evil"]) + let line = lastLine(client) + XCTAssertEqual(line, "PRIVMSG #channel :hi thereJOIN #evil") + XCTAssertEqual(line?.contains("\r"), false) + XCTAssertEqual(line?.contains("\n"), false) + } + + func testPongEchoesTokenWithoutDoubleColon() { + let client = queueingClient() + client.didReceiveDataFromConnection(data: ":irc.example.org PING :LAG1234567") + XCTAssertEqual(lastLine(client), "PONG LAG1234567") + } + + func testPongQuotesTokenContainingSpace() { + let client = queueingClient() + client.didReceiveDataFromConnection(data: ":irc.example.org PING :tok en") + XCTAssertEqual(lastLine(client), "PONG :tok en") + } +} From 56db0e15a89103eed3b0f545650b06a60fdae465 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alex=20S=C3=B8rlie?= Date: Mon, 3 Aug 2026 13:49:48 +0200 Subject: [PATCH 09/11] fix: compare nicknames and channel names per CASEMAPPING MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Nick and channel comparisons were inconsistent — some used exact `==`, one used `lowercased()` — so a server that varied the case of a name (or used the rfc1459 `[]\~` ⇄ `{}|^` equivalence) could desync membership and channel tracking. Add `String.ircCaseFolded(mapping:)` implementing the ascii / rfc1459 / rfc1459-strict mappings (defaulting to rfc1459), plus `IRCClient.caseFold` / `isSameName`, and route channel lookups (get/add/remove), member lookups, and self-nick detection through them. Add CaseMappingTests for the folding table and case-insensitive channel/member lookups. --- Sources/IRCKit/Extensions/String.swift | 29 ++++++++++ Sources/IRCKit/IRCChannel.swift | 13 ++--- .../IRCKit/IRCClient/Handlers/Notice.swift | 2 +- .../IRCKit/IRCClient/Handlers/Privmsg.swift | 2 +- .../IRCClient/Handlers/UserEvents.swift | 4 +- Sources/IRCKit/IRCClient/IRCClient.swift | 19 ++++++- Sources/IRCKit/IRCSender.swift | 2 +- Tests/IRCKitTests/CaseMappingTests.swift | 57 +++++++++++++++++++ 8 files changed, 113 insertions(+), 15 deletions(-) create mode 100644 Tests/IRCKitTests/CaseMappingTests.swift diff --git a/Sources/IRCKit/Extensions/String.swift b/Sources/IRCKit/Extensions/String.swift index f907b45..f5fee98 100644 --- a/Sources/IRCKit/Extensions/String.swift +++ b/Sources/IRCKit/Extensions/String.swift @@ -68,6 +68,35 @@ extension String { }))) } + /// Folds a nickname or channel name to a canonical lowercase form for case-insensitive + /// comparison, honouring the server's CASEMAPPING (RFC 2812 §2.2). `rfc1459` additionally treats + /// `[]\~` as the uppercase forms of `{}|^`; `rfc1459-strict` does the same except for `~`; + /// `ascii` folds only A–Z. Defaults to `rfc1459` when the server has not advertised a mapping. + func ircCaseFolded(mapping: String?) -> String { + let mapping = mapping?.lowercased() ?? "rfc1459" + let foldsBrackets = mapping == "rfc1459" || mapping == "rfc1459-strict" + let foldsTilde = mapping == "rfc1459" + + var folded = String.UnicodeScalarView() + for scalar in self.unicodeScalars { + switch scalar.value { + case 0x41...0x5A: // A–Z + folded.append(UnicodeScalar(scalar.value + 32) ?? scalar) + case 0x5B where foldsBrackets: // [ + folded.append("{") + case 0x5D where foldsBrackets: // ] + folded.append("}") + case 0x5C where foldsBrackets: // \ + folded.append("|") + case 0x7E where foldsTilde: // ~ + folded.append("^") + default: + folded.append(scalar) + } + } + return String(folded) + } + static func random(length: Int = 20) -> String { let base = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" var randomString: String = "" diff --git a/Sources/IRCKit/IRCChannel.swift b/Sources/IRCKit/IRCChannel.swift index 9f6b8cc..6f87fd7 100644 --- a/Sources/IRCKit/IRCChannel.swift +++ b/Sources/IRCKit/IRCChannel.swift @@ -75,7 +75,7 @@ public class IRCChannel: Equatable, @unchecked Sendable { func set(nickname: String, member: IRCUser) { let existingMemberIndex = self.members.firstIndex(where: { - $0.nickname == nickname + self.client.isSameName($0.nickname, nickname) }) if let existingMemberIndex = existingMemberIndex { self.members[existingMemberIndex] = member @@ -86,7 +86,7 @@ public class IRCChannel: Equatable, @unchecked Sendable { func set(member: IRCUser) { let existingMemberIndex = self.members.firstIndex(where: { - $0.nickname == member.nickname + self.client.isSameName($0.nickname, member.nickname) }) if let existingMemberIndex = existingMemberIndex { self.members[existingMemberIndex] = member @@ -97,13 +97,13 @@ public class IRCChannel: Equatable, @unchecked Sendable { func remove(member: IRCUser) { self.members.removeAll(where: { - member.nickname == $0.nickname + self.client.isSameName(member.nickname, $0.nickname) }) } func remove(named nickname: String) { self.members.removeAll(where: { - $0.nickname == nickname + self.client.isSameName($0.nickname, nickname) }) } @@ -112,15 +112,14 @@ public class IRCChannel: Equatable, @unchecked Sendable { } public func member(named nickname: String) -> IRCUser? { - let nickname = nickname.lowercased() return members.first(where: { - $0.nickname.lowercased() == nickname + self.client.isSameName($0.nickname, nickname) }) } public func member(fromSender sender: IRCSender) -> IRCUser? { return members.first(where: { - $0.nickname == sender.nickname + self.client.isSameName($0.nickname, sender.nickname) }) } diff --git a/Sources/IRCKit/IRCClient/Handlers/Notice.swift b/Sources/IRCKit/IRCClient/Handlers/Notice.swift index 1b26f39..43b7cce 100644 --- a/Sources/IRCKit/IRCClient/Handlers/Notice.swift +++ b/Sources/IRCKit/IRCClient/Handlers/Notice.swift @@ -34,7 +34,7 @@ public struct IRCServerNotice: Sendable { extension IRCClient { func handleNoticeEvent(message: IRCMessage) { - if message.sender?.nickname == self.currentNick { + if let senderNick = message.sender?.nickname, self.isSameName(senderNick, self.currentNick) { return } diff --git a/Sources/IRCKit/IRCClient/Handlers/Privmsg.swift b/Sources/IRCKit/IRCClient/Handlers/Privmsg.swift index cd872a7..0237377 100644 --- a/Sources/IRCKit/IRCClient/Handlers/Privmsg.swift +++ b/Sources/IRCKit/IRCClient/Handlers/Privmsg.swift @@ -43,7 +43,7 @@ public struct IRCPrivateMessage: IRCNotification, Sendable { extension IRCClient { func handlePrivmsgEvent(message: IRCMessage) { - if message.sender?.nickname == self.currentNick { + if let senderNick = message.sender?.nickname, self.isSameName(senderNick, self.currentNick) { self.handleEchoPrivmsgEvent(message: message) return } diff --git a/Sources/IRCKit/IRCClient/Handlers/UserEvents.swift b/Sources/IRCKit/IRCClient/Handlers/UserEvents.swift index 844fb12..e482c40 100644 --- a/Sources/IRCKit/IRCClient/Handlers/UserEvents.swift +++ b/Sources/IRCKit/IRCClient/Handlers/UserEvents.swift @@ -50,7 +50,7 @@ extension IRCClient { return } - if sender.nickname == self.currentNick { + if self.isSameName(sender.nickname, self.currentNick) { var newSender = sender newSender.nickname = newNick self.currentNick = newNick @@ -109,7 +109,7 @@ extension IRCClient { return } - if sender.nickname == self.currentNick { + if self.isSameName(sender.nickname, self.currentNick) { self.currentSender?.username = newUser self.currentSender?.hostmask = newHost } diff --git a/Sources/IRCKit/IRCClient/IRCClient.swift b/Sources/IRCKit/IRCClient/IRCClient.swift index e3f4eed..e937d46 100644 --- a/Sources/IRCKit/IRCClient/IRCClient.swift +++ b/Sources/IRCKit/IRCClient/IRCClient.swift @@ -246,19 +246,32 @@ open class IRCClient: IRCConnectionDelegate, @unchecked Sendable { } } + /// Folds a nickname or channel name using the server's CASEMAPPING, for case-insensitive + /// comparison of the two. + func caseFold(_ name: String) -> String { + return name.ircCaseFolded(mapping: self.serverInfo.caseMapping) + } + + /// Whether two nicknames or channel names refer to the same entity under the server's CASEMAPPING. + func isSameName(_ lhs: String, _ rhs: String) -> Bool { + return self.caseFold(lhs) == self.caseFold(rhs) + } + func getChannel(named channelName: String) -> IRCChannel? { - return self.channels.first(where: { $0.name == channelName }) + let target = self.caseFold(channelName) + return self.channels.first(where: { self.caseFold($0.name) == target }) } func addChannel(channel: IRCChannel) { - guard self.channels.first(where: { $0.name == channel.name }) == nil else { + guard self.getChannel(named: channel.name) == nil else { return } self.channels.append(channel) } func removeChannel(named channelName: String) { - self.channels.removeAll(where: { $0.name == channelName }) + let target = self.caseFold(channelName) + self.channels.removeAll(where: { self.caseFold($0.name) == target }) } public func send(command: IRCCommand, parameters: String...) { diff --git a/Sources/IRCKit/IRCSender.swift b/Sources/IRCKit/IRCSender.swift index 3d55b5c..2ff8402 100644 --- a/Sources/IRCKit/IRCSender.swift +++ b/Sources/IRCKit/IRCSender.swift @@ -61,7 +61,7 @@ public struct IRCSender: CustomStringConvertible, Sendable { } public func isCurrentUser(client: IRCClient) -> Bool { - return self.nickname == client.currentNick + return client.isSameName(self.nickname, client.currentNick) } static func hostmaskComponents(from senderString: String) -> (String, String, String)? { diff --git a/Tests/IRCKitTests/CaseMappingTests.swift b/Tests/IRCKitTests/CaseMappingTests.swift new file mode 100644 index 0000000..63e0b8f --- /dev/null +++ b/Tests/IRCKitTests/CaseMappingTests.swift @@ -0,0 +1,57 @@ +import XCTest +@testable import IRCKit + +/// Tests for CASEMAPPING-aware folding of nicknames and channel names (RFC 2812 §2.2) and its use in +/// channel/member lookups. +final class CaseMappingTests: XCTestCase { + func testAsciiFoldingOnlyLowercasesLetters() { + XCTAssertEqual("Nick[]\\~".ircCaseFolded(mapping: "ascii"), "nick[]\\~") + } + + func testRfc1459FoldsBracketsAndTilde() { + XCTAssertEqual("Nick[]\\~".ircCaseFolded(mapping: "rfc1459"), "nick{}|^") + } + + func testRfc1459StrictFoldsBracketsButNotTilde() { + XCTAssertEqual("Nick[]\\~".ircCaseFolded(mapping: "rfc1459-strict"), "nick{}|~") + } + + func testDefaultsToRfc1459WhenMappingUnspecified() { + XCTAssertEqual("A[".ircCaseFolded(mapping: nil), "a{") + } + + func testChannelLookupIsCaseInsensitive() { + let client = IRCTestSupport.client() + client.didReceiveDataFromConnection(data: ":tester!u@host JOIN #Channel") + // A later reference using different case must resolve to the same channel. + XCTAssertNotNil(client.getChannel(named: "#channel")) + XCTAssertNotNil(client.getChannel(named: "#CHANNEL")) + } + + func testChannelLookupHonoursRfc1459BracketEquivalence() { + let client = IRCTestSupport.client() + client.didReceiveDataFromConnection(data: ":tester!u@host JOIN #foo[bar]") + // Under rfc1459 (the default), '[' == '{' and ']' == '}'. + XCTAssertNotNil(client.getChannel(named: "#foo{bar}")) + } + + func testMemberLookupIsCaseInsensitive() { + let client = IRCTestSupport.client() + client.didReceiveDataFromConnection(data: ":tester!u@host JOIN #chan") + client.didReceiveDataFromConnection(data: ":Alice!a@host JOIN #chan") + + let channel = client.getChannel(named: "#chan") + XCTAssertNotNil(channel?.member(named: "alice")) + XCTAssertNotNil(channel?.member(named: "ALICE")) + } + + func testPartRemovesMemberRegardlessOfCase() { + let client = IRCTestSupport.client() + client.didReceiveDataFromConnection(data: ":tester!u@host JOIN #chan") + client.didReceiveDataFromConnection(data: ":Alice!a@host JOIN #chan") + // Server refers to the same user with different case on PART. + client.didReceiveDataFromConnection(data: ":ALICE!a@host PART #chan") + + XCTAssertNil(client.getChannel(named: "#chan")?.member(named: "alice")) + } +} From d5c87c21bdec73736b87f0087a4054cc7b6d475f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alex=20S=C3=B8rlie?= Date: Mon, 3 Aug 2026 13:53:40 +0200 Subject: [PATCH 10/11] feat: handle cap-notify CAP NEW and CAP DEL MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Capability changes advertised after the initial handshake were ignored: CAP NEW and CAP DEL fell through to the default case, so newly-available capabilities were never requested and withdrawn ones stayed marked enabled. Handle both — CAP NEW records the capabilities and requests any the client understands and has not enabled, CAP DEL drops them from the supported/enabled sets. Track a `capabilityNegotiationComplete` flag (reset per registration) so a CAP ACK/NAK arriving mid-session in response to a CAP NEW updates the enabled set without restarting SASL or re-sending CAP END. Route the handshake's terminal CAP END through a single `endCapabilityNegotiation()`. Add CapNotifyTests for NEW/DEL and the mid-session ACK path. --- .../Authentication/Authentication.swift | 2 +- Sources/IRCKit/IRCClient/Handlers/CAP.swift | 54 ++++++++++++++++- Sources/IRCKit/IRCClient/IRCClient.swift | 4 ++ .../IRCClient/IRCClientSendMethods.swift | 1 + Tests/IRCKitTests/CapNotifyTests.swift | 58 +++++++++++++++++++ 5 files changed, 115 insertions(+), 4 deletions(-) create mode 100644 Tests/IRCKitTests/CapNotifyTests.swift diff --git a/Sources/IRCKit/IRCClient/Authentication/Authentication.swift b/Sources/IRCKit/IRCClient/Authentication/Authentication.swift index e73fd9a..a8bfa1f 100644 --- a/Sources/IRCKit/IRCClient/Authentication/Authentication.swift +++ b/Sources/IRCKit/IRCClient/Authentication/Authentication.swift @@ -65,7 +65,7 @@ extension IRCClient { } func handleAuthenticationCompleted(message: IRCMessage) { - self.send(command: .CAP, parameters: ["END"]) + self.endCapabilityNegotiation() } func handleAccountChangeServerEvent(message: IRCMessage) { diff --git a/Sources/IRCKit/IRCClient/Handlers/CAP.swift b/Sources/IRCKit/IRCClient/Handlers/CAP.swift index 8447c40..e8d81ee 100644 --- a/Sources/IRCKit/IRCClient/Handlers/CAP.swift +++ b/Sources/IRCKit/IRCClient/Handlers/CAP.swift @@ -36,6 +36,10 @@ extension IRCClient { capNegotiationComplete(message: message) case "NAK": capNegotiationFailed(message: message) + case "NEW": + capabilitiesAdded(message: message) + case "DEL": + capabilitiesRemoved(message: message) default: break } @@ -82,11 +86,22 @@ extension IRCClient { func capNegotiationComplete(message: IRCMessage) { guard let capList = message.parameters[safe: 2] else { - self.send(command: .CAP, parameters: ["END"]) + if self.capabilityNegotiationComplete == false { + self.endCapabilityNegotiation() + } return } + let acceptedCapabilities = IRCv3Capability.list(fromString: capList) - self.serverInfo.enabledIRCv3Capabilities = acceptedCapabilities + for capability in acceptedCapabilities where self.hasIRCv3Capability(capability) == false { + self.serverInfo.enabledIRCv3Capabilities.append(capability) + } + + // An ACK received after the initial handshake (e.g. in response to a CAP NEW) only updates + // the enabled set; it must not restart SASL or re-send CAP END. + guard self.capabilityNegotiationComplete == false else { + return + } let caps = IRCv3CapabilityInfo.from(string: capList) if let saslCap = caps[.sasl] as? [String] { @@ -107,10 +122,43 @@ extension IRCClient { return } } - self.send(command: .CAP, parameters: ["END"]) + self.endCapabilityNegotiation() } func capNegotiationFailed(message: IRCMessage) { + // A NAK after the handshake (a rejected CAP NEW request) requires no action. + guard self.capabilityNegotiationComplete == false else { + return + } + self.endCapabilityNegotiation() + } + + /// Handles `CAP NEW` (cap-notify): records the newly-available capabilities and requests any the + /// client understands and has not already enabled. + func capabilitiesAdded(message: IRCMessage) { + guard let capString = message.parameters[safe: 2] else { + return + } + let caps = IRCv3CapabilityInfo.from(string: capString) + for capability in caps.keys where self.serverInfo.supportedIRCv3Capabilities.contains(capability) == false { + self.serverInfo.supportedIRCv3Capabilities.append(capability) + } + let capabilitiesToRequest = Array(caps.keys).filter({ self.hasIRCv3Capability($0) == false }) + self.requestIRCv3Capabilities(capabilities: capabilitiesToRequest) + } + + /// Handles `CAP DEL` (cap-notify): the server has withdrawn the given capabilities. + func capabilitiesRemoved(message: IRCMessage) { + guard let capString = message.parameters[safe: 2] else { + return + } + let removed = IRCv3Capability.list(fromString: capString) + self.serverInfo.enabledIRCv3Capabilities.removeAll(where: { removed.contains($0) }) + self.serverInfo.supportedIRCv3Capabilities.removeAll(where: { removed.contains($0) }) + } + + func endCapabilityNegotiation() { + self.capabilityNegotiationComplete = true self.send(command: .CAP, parameters: ["END"]) } diff --git a/Sources/IRCKit/IRCClient/IRCClient.swift b/Sources/IRCKit/IRCClient/IRCClient.swift index e937d46..fcc55b3 100644 --- a/Sources/IRCKit/IRCClient/IRCClient.swift +++ b/Sources/IRCKit/IRCClient/IRCClient.swift @@ -71,6 +71,10 @@ open class IRCClient: IRCConnectionDelegate, @unchecked Sendable { // server sends when the capability list exceeds one IRC message. var capabilityLSBuffer: String = "" + // True once the initial capability handshake has finished (CAP END sent). Used to distinguish a + // mid-session CAP ACK/NAK (from a cap-notify CAP NEW) from the registration handshake. + var capabilityNegotiationComplete = false + public var monitor: Set = [] { didSet { if self.connection.connected && self.serverInfo.supportsMonitor { diff --git a/Sources/IRCKit/IRCClient/IRCClientSendMethods.swift b/Sources/IRCKit/IRCClient/IRCClientSendMethods.swift index 7a93b44..1dacb59 100644 --- a/Sources/IRCKit/IRCClient/IRCClientSendMethods.swift +++ b/Sources/IRCKit/IRCClient/IRCClientSendMethods.swift @@ -26,6 +26,7 @@ import Foundation extension IRCClient { func sendRegistration() { + self.capabilityNegotiationComplete = false if let password = self.configuration.serverPassword { self.send(command: .PASS, parameters: [password]) } diff --git a/Tests/IRCKitTests/CapNotifyTests.swift b/Tests/IRCKitTests/CapNotifyTests.swift new file mode 100644 index 0000000..50ded1c --- /dev/null +++ b/Tests/IRCKitTests/CapNotifyTests.swift @@ -0,0 +1,58 @@ +import XCTest +@testable import IRCKit + +/// Tests for cap-notify: `CAP NEW` and `CAP DEL` handling after the initial capability handshake. +final class CapNotifyTests: XCTestCase { + /// Runs the initial capability handshake to completion (LS → REQ → ACK → CAP END) and returns the + /// client, whose outbound lines are captured in `IRCConnection.sendQueue`. + private func negotiatedClient(offered: String, acked: String) -> IRCClient { + var configuration = IRCTestSupport.configuration() + configuration.floodControlMaximumMessages = 0 + let client = IRCClient(configuration: configuration) + client.didReceiveDataFromConnection(data: ":irc.example.org CAP * LS :\(offered)") + client.didReceiveDataFromConnection(data: ":irc.example.org CAP * ACK :\(acked)") + return client + } + + private func capEndCount(_ client: IRCClient) -> Int { + return client.connection.sendQueue.filter({ $0 == "CAP END" }).count + } + + func testInitialHandshakeSendsOneCapEnd() { + let client = negotiatedClient(offered: "message-tags server-time", acked: "message-tags server-time") + XCTAssertEqual(capEndCount(client), 1) + XCTAssertTrue(client.capabilityNegotiationComplete) + XCTAssertTrue(client.serverInfo.enabledIRCv3Capabilities.contains(.messageTags)) + XCTAssertTrue(client.serverInfo.enabledIRCv3Capabilities.contains(.serverSentTimestamps)) + } + + func testCapDelDisablesCapability() { + let client = negotiatedClient(offered: "message-tags server-time", acked: "message-tags server-time") + client.didReceiveDataFromConnection(data: ":irc.example.org CAP * DEL :server-time") + + XCTAssertFalse(client.serverInfo.enabledIRCv3Capabilities.contains(.serverSentTimestamps)) + XCTAssertFalse(client.serverInfo.supportedIRCv3Capabilities.contains(.serverSentTimestamps)) + XCTAssertTrue(client.serverInfo.enabledIRCv3Capabilities.contains(.messageTags)) + } + + func testCapNewRequestsNewlyOfferedCapability() { + let client = negotiatedClient(offered: "message-tags", acked: "message-tags") + client.didReceiveDataFromConnection(data: ":irc.example.org CAP * NEW :echo-message") + + XCTAssertTrue(client.serverInfo.supportedIRCv3Capabilities.contains(.messageConfirmation)) + XCTAssertTrue(client.connection.sendQueue.contains(where: { + $0.hasPrefix("CAP REQ") && $0.contains("echo-message") + })) + } + + func testMidSessionAckEnablesWithoutAnotherCapEnd() { + let client = negotiatedClient(offered: "message-tags", acked: "message-tags") + let endsBefore = capEndCount(client) + + client.didReceiveDataFromConnection(data: ":irc.example.org CAP * NEW :echo-message") + client.didReceiveDataFromConnection(data: ":irc.example.org CAP * ACK :echo-message") + + XCTAssertTrue(client.serverInfo.enabledIRCv3Capabilities.contains(.messageConfirmation)) + XCTAssertEqual(capEndCount(client), endsBefore) + } +} From 8e96e67062de155194e3f3c9c5678bcbb40bd037 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alex=20S=C3=B8rlie?= Date: Mon, 3 Aug 2026 13:54:06 +0200 Subject: [PATCH 11/11] docs: clarify that allowsServerSelfSignedCertificate disables all TLS verification MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The flag switches certificate verification to `.none`, disabling hostname validation as well as chain-of-trust — it is not limited to self-signed certificates as the name suggests. Document the man-in-the-middle exposure so callers do not enable it in production expecting a narrower effect. --- Sources/IRCKit/IRCClientConfiguration.swift | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/Sources/IRCKit/IRCClientConfiguration.swift b/Sources/IRCKit/IRCClientConfiguration.swift index d473eca..f930895 100644 --- a/Sources/IRCKit/IRCClientConfiguration.swift +++ b/Sources/IRCKit/IRCClientConfiguration.swift @@ -61,6 +61,11 @@ public struct IRCClientConfiguration: Codable, Sendable { public var prefersInsecureConnection: Bool = false public var chiperSuite: String? public var clientCertificatePath: String? + + /// When true, TLS certificate verification is disabled entirely — not only the chain of trust + /// but hostname validation as well, which leaves the connection open to man-in-the-middle + /// attacks. Despite the name this is not limited to self-signed certificates; enable it only for + /// controlled testing against a known server, never in production. public var allowsServerSelfSignedCertificate: Bool = false public var channels: [String] = []