diff --git a/doc/readme.iauth b/doc/readme.iauth index 6572601b..a591189c 100644 --- a/doc/readme.iauth +++ b/doc/readme.iauth @@ -396,6 +396,17 @@ Next State: - Comments: Indicates that the iauth instance does not strongly trust to be accurate, but has no more trusted username. +f - Forced Nickname +Syntax: f +Example: f 5 192.168.1.10 23367 Buddha +States: REGISTER, HURRY +Next State: - +Comments: Indicates that the iauth instance wants the client to use + the specified nickname during registration, even if the client + requested a different one. If the nickname is missing, invalid, + juped, or already in use, the server reports an E error and does + not complete registration until iauth sends a subsequent valid f. + N - Client Hostname Syntax: N Example: N 5 192.168.1.10 23367 buddha.example.org diff --git a/include/s_user.h b/include/s_user.h index 4c4d63c1..8ce8dd41 100644 --- a/include/s_user.h +++ b/include/s_user.h @@ -70,6 +70,7 @@ extern int register_user(struct Client* cptr, struct Client *sptr); extern void user_count_memory(size_t* count_out, size_t* bytes_out); +extern int do_nick_name(char* nick); extern int set_nick_name(struct Client* cptr, struct Client* sptr, const char* nick, int parc, char* parv[]); extern void send_umode_out(struct Client* cptr, struct Client* sptr, diff --git a/ircd/m_nick.c b/ircd/m_nick.c index 8b62235c..34402394 100644 --- a/ircd/m_nick.c +++ b/ircd/m_nick.c @@ -117,7 +117,7 @@ * The '~'-character should be allowed, but a change should be global, * some confusion would result if only few servers allowed it... */ -static int do_nick_name(char* nick) +int do_nick_name(char* nick) { char* ch = nick; char* end = ch + NICKLEN; diff --git a/ircd/s_auth.c b/ircd/s_auth.c index 240e1d0e..9988b2bf 100644 --- a/ircd/s_auth.c +++ b/ircd/s_auth.c @@ -42,6 +42,7 @@ #include "ircd.h" #include "ircd_alloc.h" #include "ircd_chattr.h" +#include "hash.h" #include "ircd_events.h" #include "ircd_features.h" #include "ircd_log.h" @@ -86,6 +87,7 @@ enum AuthRequestFlag { AR_NEEDS_NICK, /**< user must send NICK command */ AR_LAST_SCAN = AR_NEEDS_NICK, /**< maximum flag to scan through */ AR_IAUTH_PENDING, /**< iauth request sent, waiting for response */ + AR_IAUTH_NEEDS_NICK,/**< iauth f failed; wait for a valid forced nick */ AR_IAUTH_HURRY, /**< we told iauth to hurry up */ AR_IAUTH_USERNAME, /**< iauth sent a username (preferred or forced) */ AR_IAUTH_FUSERNAME, /**< iauth sent a forced username */ @@ -382,7 +384,8 @@ static int auth_set_username(struct AuthRequest *auth) int auth_set_account(struct AuthRequest *auth, const char *account_info) { struct Client *sptr; - char *account_copy = NULL, *account = NULL, *id_str = NULL, *flags_str = NULL, *extra = NULL; + char *account_copy = NULL, *account = NULL, *id_str = NULL, *flags_str = NULL; + char *first_word, *rest, *extra = NULL, *p; assert(auth != NULL); @@ -390,15 +393,38 @@ int auth_set_account(struct AuthRequest *auth, const char *account_info) if (!cli_user(sptr) || EmptyString(account_info)) return 1; - /* Parse account information: username:id:flags */ + /* + * Payload shape (whitespace-separated): + * [:[:[:...]]] [+x [...]] + * + * Only the first three colon fields of the first word are used locally + * (account / id / flags). Further colon fields and further words after + * the first extra token are ignored for local parsing but the original + * string is still forwarded to iauth in full. + */ DupString(account_copy, account_info); if (!account_copy) return 1; - account = strtok(account_copy, ":"); + first_word = account_copy; + rest = strchr(account_copy, ' '); + if (rest) { + *rest++ = '\0'; + while (*rest == ' ') + rest++; + if (*rest) { + /* First extra token only (e.g. "+x"); ignore friends. */ + extra = rest; + p = strchr(extra, ' '); + if (p) + *p = '\0'; + } + } + + account = strtok(first_word, ":"); id_str = strtok(NULL, ":"); - flags_str = strtok(NULL, " "); - extra = strtok(NULL, ""); + flags_str = strtok(NULL, ":"); + /* strtok(NULL, ":") would be ":something"; intentionally unused. */ /* A malformed reply may contain no account name at all. */ if (EmptyString(account)) { @@ -420,12 +446,15 @@ int auth_set_account(struct AuthRequest *auth, const char *account_info) SetAccount(sptr); - /* Check for +x flag (host hiding) */ - if (extra && strstr(extra, "+x") && feature_bool(FEAT_HOST_HIDING)) { + /* + * Second word is umode-like if it starts with '+'. Presence of 'x' + * requests host hiding (e.g. "+x", "+xo"). + */ + if (extra && *extra == '+' && strchr(extra, 'x') + && feature_bool(FEAT_HOST_HIDING)) SetHiddenHost(sptr); - } - sendto_iauth(sptr, "A %s", cli_user(sptr)->account); + sendto_iauth(sptr, "A %s", account_info); MyFree(account_copy); return 0; } @@ -605,6 +634,15 @@ static int check_auth_finished(struct AuthRequest *auth, int bitclr) else FlagSet(&auth->flags, AR_IAUTH_HURRY); + /* A failed iauth "f" must be followed by a valid forced nick before + * registration can complete (even if iauth already sent D). */ + if (FlagHas(&auth->flags, AR_IAUTH_NEEDS_NICK)) + { + Debug((DEBUG_INFO, "Auth %p [%d] waiting for iauth forced nick", auth, + cli_fd(auth->client))); + return 0; + } + res = 0; if (IsUserPort(auth->client) || IsWebsocketPort(auth->client)) { @@ -2078,6 +2116,66 @@ static int iauth_cmd_username_bad(struct IAuth *iauth, struct Client *cli, return AR_AUTH_PENDING; } +/** Set client's nickname from iauth. + * @param[in] iauth Active IAuth session. + * @param[in] cli Client referenced by command. + * @param[in] parc Number of parameters (1). + * @param[in] params New nickname for client. + * @return Zero (auth_set_nick() handles registration progress). + */ +static int iauth_cmd_nick_forced(struct IAuth *iauth, struct Client *cli, + int parc, char **params) +{ + struct AuthRequest *auth; + struct Client *acptr; + char nick[NICKLEN + 2]; + char *tilde; + + auth = cli_auth(cli); + assert(auth != NULL); + + if (EmptyString(params[0])) { + FlagSet(&auth->flags, AR_IAUTH_NEEDS_NICK); + sendto_iauth(cli, "E Missing :Missing nickname parameter"); + return 0; + } + + ircd_strncpy(nick, params[0], NICKLEN); + if ((tilde = strchr(nick, '~'))) + *tilde = '\0'; + if (!do_nick_name(nick)) { + FlagSet(&auth->flags, AR_IAUTH_NEEDS_NICK); + sendto_iauth(cli, "E Invalid :Invalid nickname [%s]", params[0]); + return 0; + } + + if (isNickJuped(nick)) { + FlagSet(&auth->flags, AR_IAUTH_NEEDS_NICK); + sendto_iauth(cli, "E Invalid :Nickname is juped [%s]", nick); + return 0; + } + + acptr = FindClient(nick); + if (acptr && acptr != cli) { + FlagSet(&auth->flags, AR_IAUTH_NEEDS_NICK); + sendto_iauth(cli, "E InUse :Nickname in use [%s]", nick); + return 0; + } + + /* Tell the client about the assignment before renaming locally. */ + if (cli_name(cli)[0] && 0 != ircd_strcmp(cli_name(cli), nick)) + sendcmdto_one(cli, CMD_NICK, cli, ":%s", nick); + + if (cli_name(cli)[0]) + hRemClient(cli); + strcpy(cli_name(cli), nick); + hAddClient(cli); + + FlagClr(&auth->flags, AR_IAUTH_NEEDS_NICK); + auth_set_nick(auth, nick); + return 0; +} + /** Set client's hostname. * @param[in] iauth Active IAuth session. * @param[in] cli Client referenced by command. @@ -2441,6 +2539,7 @@ static void iauth_parse(struct IAuth *iauth, char *message) case 'o': handler = iauth_cmd_username_forced; has_cli = 1; break; case 'U': handler = iauth_cmd_username_good; has_cli = 1; break; case 'u': handler = iauth_cmd_username_bad; has_cli = 1; break; + case 'f': handler = iauth_cmd_nick_forced; has_cli = 1; break; case 'N': handler = iauth_cmd_hostname; has_cli = 1; break; case 'I': handler = iauth_cmd_ip_address; has_cli = 1; break; case 'M': handler = iauth_cmd_usermode; has_cli = 1; break; @@ -2494,9 +2593,11 @@ static void iauth_parse(struct IAuth *iauth, char *message) sendto_iauth(NULL, "E Gone :[%s %s %s]", params[0], params[1], params[2]); else if ((!(auth = cli_auth(cli)) || - !FlagHas(&auth->flags, AR_IAUTH_PENDING)) && + (!FlagHas(&auth->flags, AR_IAUTH_PENDING) && + !(handler == iauth_cmd_nick_forced && + FlagHas(&auth->flags, AR_IAUTH_NEEDS_NICK)))) && has_cli == 1) - /* Client is done with IAuth checks. */ + /* Client is done with IAuth checks (unless waiting for a valid f). */ sendto_iauth(cli, "E Done :[%s %s %s]", params[0], params[1], params[2]); else { struct irc_sockaddr addr; diff --git a/ircd/s_user.c b/ircd/s_user.c index bcce0032..b83afa31 100644 --- a/ircd/s_user.c +++ b/ircd/s_user.c @@ -573,6 +573,13 @@ int set_nick_name(struct Client* cptr, struct Client* sptr, /* * Client changing its nick * + * Unregistered local clients may not change nick once one is set: + * the chosen nick is what iauth sees (and may force via "f"). Allowing + * a second NICK before registration would bypass those checks. + */ + if (MyConnect(sptr) && !IsUser(sptr)) + return 0; /* nick locked for iauth until registration */ + /* * If the client belongs to me, then check to see * if client is on any channels where it is currently * banned. If so, do not allow the nick change to occur. diff --git a/tests/iauth_nick/__init__.py b/tests/iauth_nick/__init__.py new file mode 100644 index 00000000..8b137891 --- /dev/null +++ b/tests/iauth_nick/__init__.py @@ -0,0 +1 @@ + diff --git a/tests/iauth_nick/iauth_stub.py b/tests/iauth_nick/iauth_stub.py new file mode 100644 index 00000000..3f516679 --- /dev/null +++ b/tests/iauth_nick/iauth_stub.py @@ -0,0 +1,92 @@ +#!/usr/bin/env python3 +"""IAuth stub for nickname assignment tests. + +Logs every line from the ircd to the file given as argv[1]. Client +nicknames drive stub behaviour: + + testnick -> force nick to "Guest001", then approve (used with/without SASL) + tmpuser -> force nick to "finaluser", then approve + set_ -> force nick to , then approve + bad_ -> try invalid nick, then on E retry with "recovered", D + collide -> force "taken"; on E InUse retry with "freenick", D + stuckbad -> force invalid nick and D without retry (should not 001) + +All other clients are approved on "n" without changing their nick. + +For testnick, f is always sent before D so registration cannot finish on +the client's requested nick. SASL success (A) is independent and may +arrive before or after n; the stub does not wait for it. +""" + +import sys + + +def main(): + logf = open(sys.argv[1], "a", buffering=1) + + def out(line): + # Prefix outgoing replies so tests can assert f-before-D ordering. + logf.write("> " + line + "\n") + sys.stdout.write(line + "\n") + sys.stdout.flush() + + # R: iauth is required; U: enable Undernet extensions (U/u/n/H/T). + out("O RU") + + clients = {} + # cid -> ("bad"|"collide", ip, port) + awaiting_retry = {} + + for line in sys.stdin: + line = line.rstrip("\r\n") + logf.write(line + "\n") + parts = line.split(" ") + if len(parts) < 2: + continue + cid, cmd = parts[0], parts[1] + if cmd == "C" and len(parts) >= 4: + clients[cid] = (parts[2], parts[3]) + elif cmd == "n" and cid in clients: + ip, port = clients[cid] + nick = parts[2] if len(parts) >= 3 else "" + if nick == "testnick": + # Reject requested nick by forcing Guest001 before Done. + out(f"f {cid} {ip} {port} Guest001") + out(f"D {cid} {ip} {port}") + clients.pop(cid, None) + elif nick == "tmpuser": + out(f"f {cid} {ip} {port} finaluser") + out(f"D {cid} {ip} {port}") + clients.pop(cid, None) + elif nick.startswith("set_"): + out(f"f {cid} {ip} {port} {nick[4:]}") + out(f"D {cid} {ip} {port}") + clients.pop(cid, None) + elif nick.startswith("bad_"): + out(f"f {cid} {ip} {port} {nick[4:]}") + awaiting_retry[cid] = ("bad", ip, port) + elif nick == "stuckbad": + out(f"f {cid} {ip} {port} -invalid") + out(f"D {cid} {ip} {port}") + clients.pop(cid, None) + elif nick == "collide": + out(f"f {cid} {ip} {port} taken") + awaiting_retry[cid] = ("collide", ip, port) + else: + out(f"D {cid} {ip} {port}") + clients.pop(cid, None) + elif cmd == "E" and cid in awaiting_retry: + kind, ip, port = awaiting_retry.pop(cid) + if kind == "bad": + out(f"f {cid} {ip} {port} recovered") + else: + out(f"f {cid} {ip} {port} freenick") + out(f"D {cid} {ip} {port}") + clients.pop(cid, None) + elif cmd == "D": + clients.pop(cid, None) + awaiting_retry.pop(cid, None) + + +if __name__ == "__main__": + main() diff --git a/tests/iauth_nick/test_iauth_nick.py b/tests/iauth_nick/test_iauth_nick.py new file mode 100644 index 00000000..5891c658 --- /dev/null +++ b/tests/iauth_nick/test_iauth_nick.py @@ -0,0 +1,581 @@ +"""Tests for iauth forced nickname assignment (f command). + +These tests run the locally built ircd with an iauth stub that rewrites +nicknames during registration. Rebuild with `make` if sources change. + +Scenarios covered: + - SASL OK / FAIL / absent, with and without nick forcing (testnick -> Guest001) + - Client NICK changes attempted while iauth is still pending +""" + +from __future__ import annotations + +import asyncio +import socket +import subprocess +import sys +import time +from pathlib import Path + +import pytest + +from irc_client import IRCClient +from p10_server import P10Server + + +REPO_ROOT = Path(__file__).resolve().parents[2] +IRCD_BIN = REPO_ROOT / "ircd" / "ircd" +STUB = Path(__file__).resolve().parent / "iauth_stub.py" + + +def _ircd_bin_is_stale() -> bool: + if not IRCD_BIN.exists(): + return False + built = IRCD_BIN.stat().st_mtime + for pattern in ("ircd/*.c", "ircd/*.y", "include/*.h"): + for src in REPO_ROOT.glob(pattern): + if src.stat().st_mtime > built: + return True + return False + + +pytestmark = [ + pytest.mark.skipif( + not IRCD_BIN.exists(), reason="local ircd binary not built" + ), + pytest.mark.skipif( + _ircd_bin_is_stale(), + reason="local ircd binary is older than the sources; rebuild with make", + ), +] + + +def _free_port(): + with socket.socket() as s: + s.bind(("127.0.0.1", 0)) + return s.getsockname()[1] + + +def _spath(): + for line in (REPO_ROOT / "config.h").read_text().splitlines(): + if line.startswith("#define SPATH "): + return Path(line.split('"')[1]) + return None + + +@pytest.fixture +def ensure_spath(): + spath = _spath() + created = False + if spath and not spath.exists() and spath.parent.is_dir(): + spath.symlink_to(IRCD_BIN) + created = True + yield + if created: + spath.unlink(missing_ok=True) + + +CONF_TEMPLATE = """\ +General {{ + name = "iauthnick.example.net"; + vhost = "127.0.0.1"; + description = "iauth nick test server"; + numeric = 98; +}}; +Admin {{ + Location = "test"; + Location = "test"; + Contact = "test@example.net"; +}}; +Class {{ + name = "Local"; + pingfreq = 90 seconds; + sendq = 160000; + maxlinks = 100; +}}; +Class {{ + name = "Server"; + pingfreq = 90 seconds; + connectfreq = 5 minutes; + sendq = 9 megabytes; + maxlinks = 10; +}}; +Client {{ ip = "127.*"; class = "Local"; }}; +Connect {{ + name = "services.test.net"; + host = "127.0.0.1"; + password = "testpass"; + class = "Server"; + hub; +}}; +UWorld {{ + oper = "services.test.net"; +}}; +Port {{ port = {client_port}; }}; +Port {{ server = yes; port = {server_port}; }}; +IAuth {{ program = "{python}" "{stub}" "{log}"; }}; +Features {{ + "HUB" = "TRUE"; + "NODNS" = "TRUE"; +}}; +""" + + +async def _wait_listening(proc, port, timeout=10.0): + deadline = time.time() + timeout + while time.time() < deadline: + if proc.poll() is not None: + raise RuntimeError("ircd exited during startup") + try: + with socket.create_connection(("127.0.0.1", port), 0.2): + return + except OSError: + time.sleep(0.1) + raise RuntimeError("ircd did not start listening") + + +async def _warmup_iauth(port, attempts=5): + last_exc = None + for _ in range(attempts): + try: + await _register(port, "probeok", "testuser") + return + except (ConnectionError, OSError, asyncio.TimeoutError) as exc: + last_exc = exc + await asyncio.sleep(0.3) + raise RuntimeError(f"iauth stub never became ready: {last_exc!r}") + + +@pytest.fixture +async def local_ircd(tmp_path, ensure_spath): + """Local ircd + nick-forcing iauth stub (no SASL services).""" + client_port = _free_port() + server_port = _free_port() + log = tmp_path / "iauth.log" + log.touch() + conf = tmp_path / "ircd.conf" + conf.write_text( + CONF_TEMPLATE.format( + client_port=client_port, + server_port=server_port, + python=sys.executable, + stub=STUB, + log=log, + ) + ) + proc = subprocess.Popen( + [str(IRCD_BIN), "-n", "-f", str(conf), "-d", str(tmp_path)], + cwd=tmp_path, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + try: + await _wait_listening(proc, client_port) + await _warmup_iauth(client_port) + log.write_text("") + yield {"host": "127.0.0.1", "port": client_port, "log": log, "services": None} + finally: + proc.terminate() + try: + proc.wait(timeout=5) + except subprocess.TimeoutExpired: + proc.kill() + + +@pytest.fixture +async def sasl_ircd(tmp_path, ensure_spath): + """Local ircd + nick-forcing iauth stub + P10 services for SASL.""" + client_port = _free_port() + server_port = _free_port() + log = tmp_path / "iauth.log" + log.touch() + conf = tmp_path / "ircd.conf" + conf.write_text( + CONF_TEMPLATE.format( + client_port=client_port, + server_port=server_port, + python=sys.executable, + stub=STUB, + log=log, + ) + ) + proc = subprocess.Popen( + [str(IRCD_BIN), "-n", "-f", str(conf), "-d", str(tmp_path)], + cwd=tmp_path, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + services = None + try: + await _wait_listening(proc, client_port) + await _warmup_iauth(client_port) + log.write_text("") + + services = P10Server( + name="services.test.net", + numeric=4, + password="testpass", + ) + await services.connect("127.0.0.1", server_port) + await services.handshake() + await services.send_config("sasl.server", "services.test.net") + await services.send_config("sasl.mechanisms", "PLAIN") + await asyncio.sleep(0.5) + + yield { + "host": "127.0.0.1", + "port": client_port, + "log": log, + "services": services, + } + finally: + if services is not None: + try: + await services.disconnect() + except Exception: + pass + proc.terminate() + try: + proc.wait(timeout=5) + except subprocess.TimeoutExpired: + proc.kill() + + +async def _register(port, nick, username="testuser", timeout=15.0): + client = IRCClient() + await client.connect("127.0.0.1", port) + try: + await client.send(f"NICK {nick}") + await client.send(f"USER {username} 0 * :Test User") + msg = await client.wait_for("001", timeout=timeout) + return msg + finally: + try: + await client.send("QUIT :done") + except Exception: + pass + await client.disconnect() + + +async def _start_sasl(client, services): + await client.send("CAP LS 302") + msg = await client.wait_for("CAP", timeout=5.0) + assert "sasl" in msg.params[-1], "server does not advertise sasl" + + await client.send("CAP REQ :sasl") + msg = await client.wait_for("CAP", timeout=5.0) + assert msg.params[1] == "ACK", f"expected CAP ACK, got {msg.params}" + + await client.send("AUTHENTICATE PLAIN") + line = await services.wait_for_token("XQ", timeout=5.0) + parts = line.split() + hub_num = parts[0] + routing = parts[3] + assert routing.startswith("sasl:") + return hub_num, routing + + +async def _finish_registration(client, nick): + await client.send(f"NICK {nick}") + await client.send("USER testuser 0 * :Test User") + await client.send("CAP END") + return await client.wait_for("001", timeout=10.0) + + +def _iauth_errors(log_path): + errors = [] + for line in log_path.read_text().splitlines(): + parts = line.split(" ") + if len(parts) >= 3 and parts[1] == "E": + errors.append(parts[2]) + return errors + + +def _assert_force_before_done(log_path, forced="Guest001", nick="testnick"): + """Stub must emit f before D after seeing n .""" + text = log_path.read_text().splitlines() + saw_n = False + saw_f = False + for line in text: + if not saw_n and line.split(" ")[1:3] == ["n", nick]: + saw_n = True + continue + if not saw_n: + continue + if line.startswith("> f ") and line.rstrip().endswith(forced): + saw_f = True + continue + if line.startswith("> D "): + if not saw_f: + raise AssertionError( + f"iauth sent D before f {forced} for {nick}\nlog:\n" + + log_path.read_text() + ) + return + raise AssertionError( + f"missing n/{nick} -> f/{forced} -> D sequence\nlog:\n{log_path.read_text()}" + ) + + +def _assert_no_force(log_path, nick): + """After n , stub must Done without an f reply.""" + text = log_path.read_text().splitlines() + saw_n = False + for line in text: + if not saw_n and line.split(" ")[1:3] == ["n", nick]: + saw_n = True + continue + if not saw_n: + continue + if line.startswith("> f "): + raise AssertionError( + f"unexpected forced nick after n {nick}\nlog:\n{log_path.read_text()}" + ) + if line.startswith("> D "): + return + raise AssertionError(f"missing n/{nick} -> D sequence\nlog:\n{log_path.read_text()}") + + +async def test_iauth_forced_nick_on_registration(local_ircd): + """IAuth may replace the client's requested nick before registration.""" + msg = await _register(local_ircd["port"], "tmpuser") + assert msg.params[0] == "finaluser" + + +async def test_iauth_forced_nick_explicit_prefix(local_ircd): + """set_ requests a specific assigned nickname.""" + msg = await _register(local_ircd["port"], "set_custnick") + assert msg.params[0] == "custnick" + + +async def test_unregistered_client_cannot_change_nick(local_ircd): + """After the first NICK, further NICK before 001 is ignored. + + The nick is handed to iauth; changing it mid-auth would defeat forced + nick / pending checks. + """ + client = IRCClient() + await client.connect("127.0.0.1", local_ircd["port"]) + try: + await client.send("NICK firstnick") + await client.send("NICK secondnick") + await client.send("USER testuser 0 * :Test User") + welcome = await client.wait_for("001", timeout=15.0) + assert welcome.params[0] == "firstnick" + finally: + try: + await client.send("QUIT :done") + except Exception: + pass + await client.disconnect() + + +async def test_iauth_invalid_forced_nick_retries(local_ircd): + """Failed f reports E to iauth; a later valid f may finish registration.""" + msg = await _register(local_ircd["port"], "bad_-invalid") + assert msg.params[0] == "recovered" + await asyncio.sleep(0.3) + assert "Invalid" in _iauth_errors(local_ircd["log"]) + + +async def test_iauth_failed_forced_nick_blocks_without_retry(local_ircd): + """If iauth never sends a valid f after failure, registration must not finish.""" + client = IRCClient() + await client.connect("127.0.0.1", local_ircd["port"]) + try: + await client.send("NICK stuckbad") + await client.send("USER testuser 0 * :Test User") + with pytest.raises((asyncio.TimeoutError, TimeoutError)): + await client.wait_for("001", timeout=3.0) + await asyncio.sleep(0.3) + assert "Invalid" in _iauth_errors(local_ircd["log"]) + finally: + try: + await client.send("QUIT :done") + except Exception: + pass + await client.disconnect() + + +async def test_iauth_forced_nick_collision(local_ircd): + """In-use forced nick is rejected; iauth can assign another and continue.""" + holder = IRCClient() + await holder.connect("127.0.0.1", local_ircd["port"]) + try: + await holder.send("NICK set_taken") + await holder.send("USER holder 0 * :Holder") + msg = await holder.wait_for("001", timeout=15.0) + assert msg.params[0] == "taken" + + msg2 = await _register(local_ircd["port"], "collide") + assert msg2.params[0] == "freenick" + await asyncio.sleep(0.3) + assert "InUse" in _iauth_errors(local_ircd["log"]) + finally: + try: + await holder.send("QUIT :done") + except Exception: + pass + await holder.disconnect() + + +# --- SASL × nick-force matrix + pre-registration NICK edges -------------------- + + +async def test_sasl_ok_then_force_guest001(sasl_ircd): + """SASL succeeds; before Done, iauth forces testnick -> Guest001.""" + env = sasl_ircd + services = env["services"] + env["log"].write_text("") + + client = IRCClient() + await client.connect(env["host"], env["port"]) + try: + hub_num, routing = await _start_sasl(client, services) + await services.send_xreply(hub_num, routing, "OK forcedacct:1:0") + assert (await client.wait_for("903", timeout=5.0)) is not None + + await client.send("NICK testnick") + await client.send("USER testuser 0 * :Test User") + # Client may try alternate nicks while iauth is still deciding. + await client.send("NICK sneakynick") + await client.send("NICK Guest999") + await client.send("CAP END") + + nick_msg = await client.wait_for("NICK", timeout=10.0) + assert nick_msg.params[-1] == "Guest001" + welcome = await client.wait_for("001", timeout=10.0) + assert welcome.params[0] == "Guest001" + + await asyncio.sleep(0.2) + log = env["log"].read_text() + assert " A forcedacct:1:0" in log + _assert_force_before_done(env["log"]) + # SASL account notice must precede Done for this flow. + assert log.index(" A forcedacct:1:0") < log.index("> D ") + finally: + try: + await client.send("QUIT :done") + except Exception: + pass + await client.disconnect() + + +async def test_sasl_fail_then_force_guest001(sasl_ircd): + """Failed SASL still allows registration; iauth may force Guest001.""" + env = sasl_ircd + services = env["services"] + env["log"].write_text("") + + client = IRCClient() + await client.connect(env["host"], env["port"]) + try: + hub_num, routing = await _start_sasl(client, services) + await services.send_xreply(hub_num, routing, "NO bad credentials") + fail = await client.wait_for("904", timeout=5.0) + assert fail is not None + + welcome = await _finish_registration(client, "testnick") + assert welcome.params[0] == "Guest001" + await asyncio.sleep(0.2) + assert " A " not in env["log"].read_text() + _assert_force_before_done(env["log"]) + finally: + try: + await client.send("QUIT :done") + except Exception: + pass + await client.disconnect() + + +async def test_sasl_ok_without_nick_force(sasl_ircd): + """Successful SASL with a normal nick leaves the chosen nick alone.""" + env = sasl_ircd + services = env["services"] + env["log"].write_text("") + + client = IRCClient() + await client.connect(env["host"], env["port"]) + try: + hub_num, routing = await _start_sasl(client, services) + await services.send_xreply(hub_num, routing, "OK keepacct:2:0") + assert (await client.wait_for("903", timeout=5.0)) is not None + + welcome = await _finish_registration(client, "keepnick") + assert welcome.params[0] == "keepnick" + await asyncio.sleep(0.2) + assert " A keepacct:2:0" in env["log"].read_text() + _assert_no_force(env["log"], "keepnick") + finally: + try: + await client.send("QUIT :done") + except Exception: + pass + await client.disconnect() + + +async def test_no_sasl_force_guest001(local_ircd): + """Without SASL, testnick is still forced to Guest001 before Done.""" + local_ircd["log"].write_text("") + msg = await _register(local_ircd["port"], "testnick") + assert msg.params[0] == "Guest001" + await asyncio.sleep(0.2) + _assert_force_before_done(local_ircd["log"]) + + +async def test_no_sasl_without_nick_force(local_ircd): + """Without SASL and without a force trigger, the requested nick sticks.""" + local_ircd["log"].write_text("") + msg = await _register(local_ircd["port"], "plainnick") + assert msg.params[0] == "plainnick" + await asyncio.sleep(0.2) + _assert_no_force(local_ircd["log"], "plainnick") + + +async def test_nick_changes_ignored_during_sasl_and_force(sasl_ircd): + """NICK attempts during CAP/SASL/iauth must not stick; force still wins.""" + env = sasl_ircd + services = env["services"] + env["log"].write_text("") + + client = IRCClient() + await client.connect(env["host"], env["port"]) + try: + # Set nick early, then try to change it throughout SASL negotiation. + await client.send("NICK testnick") + await client.send("NICK during_cap") + + hub_num, routing = await _start_sasl(client, services) + await client.send("NICK during_sasl") + await services.send_xreply(hub_num, routing, "OK edgeacct:3:0") + assert (await client.wait_for("903", timeout=5.0)) is not None + + await client.send("NICK after_sasl") + await client.send("USER testuser 0 * :Test User") + await client.send("NICK before_cap_end") + await client.send("CAP END") + + nick_msg = await client.wait_for("NICK", timeout=10.0) + assert nick_msg.params[-1] == "Guest001" + welcome = await client.wait_for("001", timeout=10.0) + assert welcome.params[0] == "Guest001" + + await asyncio.sleep(0.2) + # Only the first client nick is announced to iauth. + n_lines = [ + line + for line in env["log"].read_text().splitlines() + if line.split(" ")[1:2] == ["n"] + ] + assert any(line.endswith(" testnick") or " n testnick" in f" {line}" for line in n_lines) + assert not any( + any(bad in line for bad in ("during_cap", "during_sasl", "after_sasl", "before_cap_end")) + for line in n_lines + ) + _assert_force_before_done(env["log"]) + finally: + try: + await client.send("QUIT :done") + except Exception: + pass + await client.disconnect() diff --git a/tests/pr_iauthverify/test_sasl_account.py b/tests/pr_iauthverify/test_sasl_account.py index 133729d6..6b78b49a 100644 --- a/tests/pr_iauthverify/test_sasl_account.py +++ b/tests/pr_iauthverify/test_sasl_account.py @@ -1,20 +1,23 @@ -"""TDD tests for the iauthverify branch: auth_set_account() hardening. - -The branch moves SASL "OK " reply parsing from sasl.c into -auth_set_account() in s_auth.c. The parsing uses strtok() and copies the -result with ircd_strncpy() without checking for NULL: a services server -that replies "OK" or "OK " (no account name, or a malformed one like -"OK ::::") makes strtok() return NULL and the ircd dereferences it, -crashing the whole server. - -These tests link a fake P10 services server to the hub, enable SASL via -netconf, run a client through SASL during registration, and have the -services server send back malformed OK replies. The server must survive -and the client must still be able to register. +"""SASL OK account parsing and the matching iauth "A" notification. + +auth_set_account() parses the first word as account[:id[:flags[:...]]] and +an optional second umode-like word (+...). The full original payload is +forwarded to iauth as "A " while only account/id/flags/+x affect +local state. + +These tests run a locally built ircd with the logging iauth stub, link a +fake P10 services server for SASL XREPLY, and assert both client-visible +effects and the exact "A" line iauth received. """ +from __future__ import annotations + import asyncio +import socket +import subprocess +import sys import time +from pathlib import Path import pytest @@ -22,33 +25,198 @@ from p10_server import P10Server -pytestmark = pytest.mark.single_server +REPO_ROOT = Path(__file__).resolve().parents[2] +IRCD_BIN = REPO_ROOT / "ircd" / "ircd" +STUB = Path(__file__).resolve().parent / "iauth_stub.py" +HIDDEN_HOST_SUFFIX = "users.undernet.org" + + +def _ircd_bin_is_stale() -> bool: + if not IRCD_BIN.exists(): + return False + built = IRCD_BIN.stat().st_mtime + for pattern in ("ircd/*.c", "ircd/*.y", "include/*.h"): + for src in REPO_ROOT.glob(pattern): + if src.stat().st_mtime > built: + return True + return False + + +pytestmark = [ + pytest.mark.skipif( + not IRCD_BIN.exists(), reason="local ircd binary not built" + ), + pytest.mark.skipif( + _ircd_bin_is_stale(), + reason="local ircd binary is older than the sources; rebuild with make", + ), +] + + +def _free_port() -> int: + with socket.socket() as s: + s.bind(("127.0.0.1", 0)) + return s.getsockname()[1] + + +def _spath() -> Path | None: + for line in (REPO_ROOT / "config.h").read_text().splitlines(): + if line.startswith("#define SPATH "): + return Path(line.split('"')[1]) + return None @pytest.fixture -async def services(ircd_hub): - """Connect a fake P10 services server to the hub and enable SASL.""" - srv = P10Server( - name="services.test.net", - numeric=4, - password="testpass", +def ensure_spath(): + spath = _spath() + created = False + if spath and not spath.exists() and spath.parent.is_dir(): + spath.symlink_to(IRCD_BIN) + created = True + yield + if created: + spath.unlink(missing_ok=True) + + +CONF_TEMPLATE = """\ +General {{ + name = "iavsasl.example.net"; + vhost = "127.0.0.1"; + description = "iauth sasl account test server"; + numeric = 1; +}}; +Admin {{ + Location = "test"; + Location = "test"; + Contact = "test@example.net"; +}}; +Class {{ + name = "Local"; + pingfreq = 90 seconds; + sendq = 160000; + maxlinks = 100; +}}; +Class {{ + name = "Server"; + pingfreq = 90 seconds; + connectfreq = 5 minutes; + sendq = 9 megabytes; + maxlinks = 10; +}}; +Client {{ ip = "127.*"; class = "Local"; }}; +Connect {{ + name = "services.test.net"; + host = "127.0.0.1"; + password = "testpass"; + class = "Server"; + hub; +}}; +UWorld {{ + oper = "services.test.net"; +}}; +Port {{ port = {client_port}; }}; +Port {{ server = yes; port = {server_port}; }}; +IAuth {{ program = "{python}" "{stub}" "{log}"; }}; +Features {{ + "HUB" = "TRUE"; + "NODNS" = "TRUE"; + "HOST_HIDING" = "TRUE"; + "HIDDEN_HOST" = "users.undernet.org"; +}}; +""" + + +@pytest.fixture +async def sasl_iauth_env(tmp_path, ensure_spath): + """Local ircd + logging iauth stub + P10 services with SASL enabled.""" + client_port = _free_port() + server_port = _free_port() + log = tmp_path / "iauth.log" + log.touch() + conf = tmp_path / "ircd.conf" + conf.write_text( + CONF_TEMPLATE.format( + client_port=client_port, + server_port=server_port, + python=sys.executable, + stub=STUB, + log=log, + ) ) - await srv.connect(ircd_hub["host"], ircd_hub["server_port"]) - await srv.handshake() - # Enable SASL through netconf, pointing at ourselves. - await srv.send_config("sasl.server", "services.test.net") - await srv.send_config("sasl.mechanisms", "PLAIN") - await asyncio.sleep(0.5) - yield srv - await srv.disconnect() + proc = subprocess.Popen( + [str(IRCD_BIN), "-n", "-f", str(conf), "-d", str(tmp_path)], + cwd=tmp_path, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + services = None + try: + deadline = time.time() + 10 + while time.time() < deadline: + if proc.poll() is not None: + raise RuntimeError("ircd exited during startup") + try: + with socket.create_connection(("127.0.0.1", client_port), 0.2): + break + except OSError: + time.sleep(0.1) + else: + raise RuntimeError("ircd did not start listening") + # Wait until iauth is up (required policy) via a throwaway register. + last_exc = None + for _ in range(5): + probe = IRCClient() + try: + await probe.connect("127.0.0.1", client_port) + await probe.register("iavwarm", "testuser", "Warmup") + await probe.send("QUIT :warmup") + await probe.disconnect() + break + except Exception as exc: + last_exc = exc + try: + await probe.disconnect() + except Exception: + pass + await asyncio.sleep(0.3) + else: + raise RuntimeError(f"iauth stub never became ready: {last_exc!r}") -async def _start_sasl(client, services): - """Negotiate the sasl cap and send AUTHENTICATE PLAIN. + log.write_text("") # drop warmup traffic - Returns (hub_numeric, routing) parsed from the XQUERY the hub sends - to the services server, so the test can send a matching XREPLY. - """ + services = P10Server( + name="services.test.net", + numeric=4, + password="testpass", + ) + await services.connect("127.0.0.1", server_port) + await services.handshake() + await services.send_config("sasl.server", "services.test.net") + await services.send_config("sasl.mechanisms", "PLAIN") + await asyncio.sleep(0.5) + + yield { + "host": "127.0.0.1", + "port": client_port, + "server_port": server_port, + "log": log, + "services": services, + } + finally: + if services is not None: + try: + await services.disconnect() + except Exception: + pass + proc.terminate() + try: + proc.wait(timeout=5) + except subprocess.TimeoutExpired: + proc.kill() + + +async def _start_sasl(client, services): await client.send("CAP LS 302") msg = await client.wait_for("CAP", timeout=5.0) assert "sasl" in msg.params[-1], "hub does not advertise sasl" @@ -59,7 +227,6 @@ async def _start_sasl(client, services): await client.send("AUTHENTICATE PLAIN") - # Hub relays the request: " XQ sasl: :SASL ..." line = await services.wait_for_token("XQ", timeout=5.0) parts = line.split() hub_num = parts[0] @@ -69,65 +236,142 @@ async def _start_sasl(client, services): async def _finish_registration(client, nick): - """Complete registration after SASL and wait for the 001 welcome.""" await client.send(f"NICK {nick}") - await client.send(f"USER testuser 0 * :Test User") + await client.send("USER testuser 0 * :Test User") await client.send("CAP END") await client.wait_for("001", timeout=10.0) -async def test_sasl_ok_with_valid_account(ircd_hub, services): - """Positive control: OK with a full account payload logs the client in.""" - client = IRCClient() - await client.connect(ircd_hub["host"], ircd_hub["port"]) - try: - hub_num, routing = await _start_sasl(client, services) - await services.send_xreply(hub_num, routing, "OK goodacct:42:0 +x") +async def _whois_account_and_host(client, nick): + await client.send(f"WHOIS {nick}") + found_account = None + found_host = None + deadline = time.time() + 5.0 + while time.time() < deadline: + msg = await client.recv(timeout=5.0) + if msg.command == "311": + found_host = msg.params[3] + if msg.command == "330": + found_account = msg.params[2] + if msg.command == "318": + break + return found_account, found_host - msg = await client.wait_for("903", timeout=5.0) - assert msg is not None - await _finish_registration(client, "iavok1") +def _wait_iauth_a(log: Path, payload: str, timeout: float = 5.0) -> str: + """Wait until iauth has logged `` A `` exactly.""" + deadline = time.time() + timeout + while time.time() < deadline: + text = log.read_text() + for line in text.splitlines(): + # " A " — payload may contain spaces. + if " A " not in line: + continue + fd, _, rest = line.partition(" A ") + if fd.lstrip("-").isdigit() and rest == payload: + return line + time.sleep(0.05) + raise AssertionError( + f"iauth never saw exact A payload {payload!r}\n" + f"log was:\n{log.read_text()}" + ) - # The account must be attached: WHOIS shows 330 (RPL_WHOISACCOUNT). - await client.send("WHOIS iavok1") - found_account = None - deadline = time.time() + 5.0 - while time.time() < deadline: - msg = await client.recv(timeout=5.0) - if msg.command == "330": - found_account = msg.params[2] - if msg.command == "318": # end of WHOIS - break - assert found_account == "goodacct" - finally: - await client.disconnect() + +def _iauth_a_payloads(log: Path) -> list[str]: + out = [] + for line in log.read_text().splitlines(): + if " A " not in line: + continue + fd, _, rest = line.partition(" A ") + if fd.lstrip("-").isdigit(): + out.append(rest) + return out -@pytest.mark.parametrize("bad_reply", ["OK", "OK ", "OK ::::"]) -async def test_sasl_ok_without_account_must_not_crash( - ircd_hub, services, bad_reply +OK_VARIANTS = [ + # account_info, nick, expect_account, expect_hidden + ("onlyacct", "iava0", "onlyacct", False), + ("acctid:42", "iava1", "acctid", False), + ("acctflags:42:7", "iava2", "acctflags", False), + ("fullacct:42:0 +x", "iava3", "fullacct", True), + ("hideid:77 +x", "iava5", "hideid", True), + ("plusxo:88:3:something +xo YRS", "iava6", "plusxo", True), + ("noyrs:9:0 YRS", "iava7", "noyrs", False), + ("pluso:9:0 +o", "iava8", "pluso", False), + ("spaced:1:2:extra +x TRAILING", "iava9", "spaced", True), +] + + +async def test_sasl_ok_variants_notify_iauth(sasl_iauth_env): + """Various OK payloads: parse effects on the client; iauth gets the full string.""" + env = sasl_iauth_env + services = env["services"] + log: Path = env["log"] + + for account_info, nick, expect_account, expect_hidden in OK_VARIANTS: + log.write_text("") + client = IRCClient() + await client.connect(env["host"], env["port"]) + try: + hub_num, routing = await _start_sasl(client, services) + await services.send_xreply(hub_num, routing, f"OK {account_info}") + + msg = await client.wait_for("903", timeout=5.0) + assert msg is not None, account_info + + _wait_iauth_a(log, account_info) + + await _finish_registration(client, nick) + + found_account, found_host = await _whois_account_and_host(client, nick) + assert found_account == expect_account, account_info + hidden = f"{expect_account}.{HIDDEN_HOST_SUFFIX}" + if expect_hidden: + assert found_host == hidden, ( + f"{account_info}: expected hide, got {found_host!r}" + ) + else: + assert found_host != hidden, ( + f"{account_info}: did not expect hide, got {found_host!r}" + ) + finally: + try: + await client.send("QUIT :done") + except Exception: + pass + await client.disconnect() + + +async def test_sasl_ok_without_account_must_not_crash_or_notify_iauth( + sasl_iauth_env, ): - """An OK reply with a missing or malformed account must not kill the ircd. + """Malformed OK must not crash; iauth must not receive an "A" line.""" + env = sasl_iauth_env + services = env["services"] + log: Path = env["log"] - On the unfixed branch, auth_set_account() passes strtok()'s NULL - result to ircd_strncpy() and the server segfaults. - """ - client = IRCClient() - await client.connect(ircd_hub["host"], ircd_hub["port"]) - try: - hub_num, routing = await _start_sasl(client, services) - await services.send_xreply(hub_num, routing, bad_reply) + for i, bad_reply in enumerate(("OK", "OK ", "OK ::::")): + log.write_text("") + client = IRCClient() + await client.connect(env["host"], env["port"]) + try: + hub_num, routing = await _start_sasl(client, services) + await services.send_xreply(hub_num, routing, bad_reply) + await _finish_registration(client, f"iavbad{i}") + finally: + try: + await client.send("QUIT :done") + except Exception: + pass + await client.disconnect() - # Whatever the server decides about the reply, it must stay up: - # the client must still be able to finish registering... - await _finish_registration(client, "iavbad1") - finally: - await client.disconnect() + assert _iauth_a_payloads(log) == [], ( + f"malformed {bad_reply!r} must not send A to iauth; got " + f"{_iauth_a_payloads(log)!r}" + ) - # ...and brand-new connections must still be accepted. probe = IRCClient() - await probe.connect(ircd_hub["host"], ircd_hub["port"]) + await probe.connect(env["host"], env["port"]) try: await probe.register("iavprobe", "testuser", "Test User") finally: