# Pastebin l16A13jg # ProvisionIRCd v3.0-beta — Full Source Tree Audit **Audit Date:** April 5, 2026 **Source:** ProvisionIRCd-main.zip (98 Python files, ~16,226 lines) **Methodology:** Line-1-to-EOF review of every `.py` file, applying Cathexis IRCd audit standards --- ## 1. Executive Summary ProvisionIRCd is a Python 3.10+ IRC daemon with a modular architecture closely modeled on UnrealIRCd's protocol (PROTOCTL, SJOIN, UID, EOS, TKL, S2S metadata). It uses pyOpenSSL for TLS, a `selectors`-based single-threaded event loop, and a plugin system where nearly every IRC command and feature is a loadable module. **Verdict:** Functional beta with a clean modular design, but carrying **7 critical security defects**, **12 high-severity bugs**, and numerous code quality issues that would prevent deployment in any environment handling real user traffic. --- ## 2. Architecture Overview ### 2.1 Core Design The `IRCD` class (`handle/core.py`) is a monolithic static namespace: no instances are ever created. All global state — client tables, channel tables, configuration, module dispatch, the selector, the thread pool executor — lives as class-level attributes. This is a common pattern for single-process IRC daemons but creates tight coupling and makes testing impossible without mocking the entire global state. Client lifecycle is managed through three sub-objects: `User` (IRC user attributes), `Server` (server link attributes), and `LocalClient` (socket-level state for directly connected clients). The `Client` dataclass holds all three, discriminating type by which sub-objects are populated. This is the same pattern as UnrealIRCd's `aClient` struct and works well in practice. The module system uses a simple `init(module)` / `post_load(module)` convention. Modules register commands via `Command.add()`, hooks via `Hook.add()`, modes via `Channelmode.add()` / `Usermode.add()`, and capabilities via `Capability.add()`. Hooks support priority ordering. This is clean, extensible, and well-implemented. ### 2.2 Event Loop (`handle/sockets.py`) The main loop uses `selectors.DefaultSelector` with a 100ms timeout. Periodic tasks (pings, timeouts, backbuffer processing, throttle expiry, hostname resolution) run on a 1-second interval. The loop structure is straightforward: ``` while IRCD.running: events = selector.select(timeout=0.1) for event in events: process_event(event) if 1-second interval elapsed: run periodic tasks ``` This is adequate for moderate loads but will struggle above ~2,000 concurrent connections due to the linear iteration patterns in periodic tasks. ### 2.3 Module Inventory 98 Python files organized as: - **classes/** (4 files): Data models, configuration entries, error codes - **handle/** (9 files): Core runtime, client/channel management, sockets, TLS, config parsing, logging - **modules/** (55+ files): IRC commands, channel modes, user modes, IRCv3 extensions, services integration --- ## 3. Critical Security Defects ### 3.1 CRITICAL: Unauthenticated Command Socket **Files:** `ircd.py:63-93`, `handle/sockets.py:599-604,462-472` The rehash/restart mechanism binds a TCP socket on `127.0.0.1:65432` with **zero authentication**. Any local process can send `REHASH`, `RESTART`, or `SHUTDOWN` to control the daemon. ```python # ircd.py:63-68 sock.connect(("127.0.0.1", 65432)) sock.sendall(b"REHASH") # No password, no token, nothing ``` **Impact:** Local privilege escalation. Any compromised web application, container escape, or unprivileged user on the host can shut down or reconfigure the IRC daemon. **Remediation:** Use a Unix domain socket with `0600` permissions, or require a shared secret. Better yet, use a PID-file-based signal mechanism (`SIGHUP` for rehash). ### 3.2 CRITICAL: Plaintext Oper Password Fallback **File:** `modules/m_oper.py:137-145` ```python if oper.password.startswith("$2b$") and len(oper.password) > 58 and bcrypt is not None: if not bcrypt.checkpw(recv[2].encode("utf-8"), oper.password.encode("utf-8")): oper_fail(...) elif recv[2] != oper.password: # PLAINTEXT COMPARISON oper_fail(...) ``` If bcrypt is not installed (it's not in `requirements.txt`) or if the password doesn't look like a bcrypt hash, oper passwords are compared in **plaintext**. This means: - Configuration files must store passwords in cleartext - Passwords are visible in memory as raw strings - No timing-safe comparison is used **Impact:** Credential exposure via config file access, memory dumps, or timing side channels. **Remediation:** Make bcrypt mandatory in `requirements.txt`. Reject non-hashed passwords at config validation time. Use `hmac.compare_digest()` for any string comparison. ### 3.3 CRITICAL: Plaintext Server Link Passwords **File:** `modules/m_server.py:94,147` Server link authentication always uses plaintext password comparison: ```python if client.local.authpass != password: # Direct string comparison deny_direct_link(...) ``` No hashing, no constant-time comparison, no key derivation. The password is sent over the wire via `PASS :password` and stored in config files in cleartext. **Impact:** Any network observer between linked servers can capture link passwords. Timing attacks are trivially possible. **Remediation:** Use HMAC-based challenge-response or require TLS client certificates for all server links. At minimum, hash stored passwords and use `hmac.compare_digest()`. ### 3.4 CRITICAL: Weak Cloaking (SHA-512 → CRC32) **File:** `handle/core.py:636-662` ```python key_bytes = bytes(f"{host}{cloak_key}", "utf-8") hex_dig = hashlib.sha512(key_bytes).hexdigest() cloak1 = hex(binascii.crc32(bytes(hex_dig[0:32], "utf-8")) % (1 << 32))[2:] cloak2 = hex(binascii.crc32(bytes(hex_dig[32:64], "utf-8")) % (1 << 32))[2:] ``` Multiple issues: 1. **CRC32 output** — 32-bit checksum, not a cryptographic function. ~77,000 unique hosts = 50% collision probability per segment. 2. **Simple concatenation** — `host + cloak_key` is vulnerable to length-extension and key-recovery attacks. Should use HMAC. 3. **No salt** — Same host always produces the same cloak, enabling rainbow table attacks. 4. **Cloak key leaked** — `handleLink.py:95` sends `MD5:cloakhash` of the cloak key to linked servers in plaintext. **Impact:** User IP addresses can be recovered from cloaked hostnames with modest computational effort. **Remediation:** Replace with HMAC-SHA256, add per-instance salt, stop leaking the key hash over links. ### 3.5 CRITICAL: Thread-Unsafe WebSocket Bridge **File:** `modules/irc_websockets.py:70-109` The WebSocket server runs in a separate daemon thread but shares all mutable state with the main event loop — `Client.table`, `IRCD.client_by_id`, `IRCD.client_by_name`, `IRCD.client_by_sock` — without any locking: ```python def handler(self, websocket): client = make_client(direction=None, uplink=IRCD.me) # Modifies Client.table from websocket thread ... IRCD.websocketbridge.clients.add(client) # Race condition with main loop iteration ``` **Impact:** Data corruption, use-after-free conditions, and crashes under concurrent WebSocket and traditional connections. **Remediation:** Either marshal all client operations to the main thread via a thread-safe queue, or use asyncio for the WebSocket server and integrate with the main event loop. ### 3.6 CRITICAL: ssl_verify_callback Always Returns True **File:** `handle/handle_tls.py:13-14` ```python def ssl_verify_callback(*args): return True ``` This callback is set as the verify callback for all TLS contexts. Combined with `SSL.VERIFY_PEER`, this means the server requests client certificates but **always accepts them regardless of validity**. A self-signed, expired, or completely fabricated certificate will be accepted. **Impact:** Certificate-based authentication (certfp-based oper, server link auth) can be bypassed by presenting any certificate with the target fingerprint, even if it's not properly signed. **Remediation:** Implement proper certificate chain validation or remove the verify callback and rely on fingerprint matching only. ### 3.7 CRITICAL: WEBIRC Password in Plaintext **File:** `modules/m_webirc.py:47-49` ```python def cmd_webirc(client, recv): if client.registered or recv[1] != WebIRCConf.password or client.ip not in WebIRCConf.ip_whitelist: return ``` The WEBIRC password is compared in plaintext with no timing-safe comparison, and stored in cleartext in config. This is the gateway password that allows web gateways to set arbitrary client IPs. **Impact:** Timing attack on WEBIRC password enables IP spoofing from whitelisted gateways. --- ## 4. High-Severity Bugs ### 4.1 File Handle Leak **File:** `handle/core.py:487` ```python def read_from_file(file: str) -> str: return open(file, 'r').read() if os.path.exists(file) else '' ``` Opens file without context manager. Under sustained operation, this leaks file descriptors. ### 4.2 Unbound ThreadPoolExecutor **File:** `handle/core.py:320` ```python executor = ThreadPoolExecutor() # Default max_workers = min(32, cpu_count + 4) ``` Used for DNS resolution, DNSBL checks, geodata API calls, delayed operations, and STARTTLS handshakes. A connection flood can exhaust the pool, blocking all async operations including hostname resolution for legitimate users. ### 4.3 Duplicate Exception Handler **File:** `handle/sockets.py:583-592` ```python except SSL.SysCallError as ex: # Line 583 if ex.args[0] in (10035, 35): ... except (..., SSL.SysCallError, ...) as ex: # Line 590 — dead code for SysCallError ``` The second catch for `SSL.SysCallError` is unreachable. ### 4.4 Unbounded SASL Request Table **File:** `modules/m_sasl.py:22-29` ```python class SaslRequest: table = [] def __init__(self, client, user_id): ... SaslRequest.table.append(self) ``` No maximum size. A slowloris attack sending AUTHENTICATE but never completing can fill this list indefinitely. ### 4.5 Geodata Module Syntax Error **File:** `modules/geodata.py:66` ```python provider = API_PROVIDERS[current_index] // debian ``` This line contains `// debian` which will cause a `TypeError` at runtime (integer floor-division on a dict by an undefined variable `debian`). This module will crash on every API call. ### 4.6 `is_match()` Recursive Glob with No Depth Limit **File:** `handle/functions.py:88-100` ```python def is_match(first: str, second: str, memo=None) -> bool: ... elif first[0] == '*': result = is_match(first[1:], second, memo) or (second and is_match(first, second[1:], memo)) ``` The memoization dict prevents infinite recursion for identical subproblems, but pathological inputs like `"*" * 100` matched against a 100-char string can still produce O(n²) memo entries. This is used in ban matching, spamfilter matching, and client mask matching — all hot paths. ### 4.7 Regex Spamfilter Without Timeout **File:** `modules/m_spamfilter.py:57` ```python is_matched = bool(re.search(pattern, message)) ``` User-provided regex patterns are compiled and executed against every message with no timeout or complexity limit. A crafted ReDoS pattern (e.g., `(a+)+$`) can freeze the main event loop. ### 4.8 Race in `permanent.py` Channel Restore **File:** `modules/chanmodes/permanent.py` Channel data is stored as JSON via `IRCD.write_data_file()` on every mode change and topic change. If two mode changes happen in rapid succession, the file writes can interleave. ### 4.9 Founder Module IP/Mask Matching Too Loose **File:** `modules/founder.py:49-54` ```python return channel in Founders.channels and ( client.fullrealhost == Founders.channels[channel].get("fullrealhost") or client.get_md_value("certfp") == Founders.channels[channel].get("certfp") or client.user.account != '*' and client.user.account == Founders.channels[channel].get("account") ) ``` The `certfp` check will match if both are `None`/`0`/falsy, granting founder to any user without a certificate when the original founder also had no certificate. ### 4.10 Die/Restart Passwords in Plaintext **Files:** `modules/m_die.py:22`, `modules/m_restart.py:22` ```python if recv[1] != IRCD.get_setting("diepass"): ``` Plaintext comparison, no timing safety, passwords stored in cleartext config. ### 4.11 `exit()` Calls in Library Code **Files:** `handle/handle_tls.py:35`, `handle/client.py:1037`, `classes/conf_entries.py` (multiple) Calling `exit()` or `sys.exit()` from library functions makes error handling impossible for callers and can crash the daemon during module loading. ### 4.12 STARTTLS Race Condition **File:** `modules/starttls.py:13-17` ```python client.local.handshake = 0 client.sendnumeric(Numeric.RPL_STARTTLS, "STARTTLS successful, proceed with TLS handshake") IRCD.run_parallel_function(wrap_socket, args=(client,), kwargs={"starttls": 1}) ``` The `RPL_STARTTLS` is sent before the TLS handshake starts, in cleartext. Then `wrap_socket` runs in a **separate thread**, creating a race between the client sending data and the TLS handshake completing. --- ## 5. Code Quality Issues ### 5.1 Pattern: `next(..., 0)` Instead of `None` Used extensively across the codebase. `0` is falsy but also a valid integer in many contexts. Returning `0` when "not found" conflates "absent" with "zero," creating subtle bugs. Examples: `SaslRequest.get_from_id()`, `Capability.find_cap()`, `Snomask.add()`, `Stat.get()`, `Tkl.exists()`. ### 5.2 Dead Code - `client.py:960-994` — `direct_send_old()` method still present - `channel.py:96-104` — `clients_()` method, duplicates `clients()` with minor differences - `handle/sockets.py:590` — duplicate `SSL.SysCallError` catch - `handle/sockets.py:498` — unreferenced debug comment `# IRCD.ref_counts(self)` ### 5.3 Broad Exception Handlers At least 30 instances of `except Exception as ex: logging.exception(ex)` that swallow errors that should propagate. Most critically in `Command.do()` (core.py:144) which catches all exceptions during command execution, masking bugs in modules. ### 5.4 Global Mutable State in Modules Many modules use class-level or module-level mutable containers: - `SaslRequest.table = []` - `Blacklist.cache = []` - `Watch.watchlist = {}` - `Monitor.monlist = {}` - `Founders.channels = ChannelsDict()` - `nick_flood = defaultdict(...)` - `GeoData.data = {}` None of these are cleaned up during rehash, leading to memory leaks and stale state accumulation. ### 5.5 Inconsistent Error Handling in Config Validation `validate_conf.py` accumulates errors in `ConfErrors.entries` but many validation functions return early on the first error, missing subsequent issues. Some call `conf_error()` and return, others call it and continue. The `conf_error()` function itself doesn't raise — it just appends to a list, but callers treat it as if it stops processing. ### 5.6 `gc.collect()` Called on Every Client Disconnect **File:** `handle/client.py:508` ```python def cleanup(self): ... gc.collect() ``` Full garbage collection on every disconnect is expensive and unnecessary with Python's generational collector. Under a connection flood, this will significantly degrade performance. ### 5.7 m_quotes.py — 519 Lines of Hardcoded Quotes This module contains ~500 lines of hardcoded computer-related quotes sent on connect. Should be loaded from a file. --- ## 6. Protocol Compliance Notes ### 6.1 UnrealIRCd Compatibility The S2S protocol is closely modeled on UnrealIRCd 5.x/6.x: - PROTOCTL with EAUTH, SID, VL, SJOIN, SJOIN2, UMODE2, MTAGS, NICKIP, NEXTBANS - UID for user introduction - SID for server introduction - SJOIN for channel sync - TKL for bans - MD for metadata - EOS for end-of-sync - SLOG for remote logging This is well-implemented and should interoperate with UnrealIRCd networks, though the mode compatibility checks in `m_protoctl.py` are strict enough that minor mode mismatches will deny links. ### 6.2 IRCv3 Support Implemented capabilities: `message-tags`, `labeled-response`, `batch`, `echo-message`, `server-time`, `account-notify`, `account-tag`, `chghost`, `setname`, `extended-monitor`, `cap-notify`, `multi-prefix`, `userhost-in-names`, `oper-notify`, `tls` (STARTTLS), `sasl`, `typing`, `standard-replies`, `no-implicit-names`. Also implements `CHATHISTORY` and `MONITOR` (IRCv3 drafts). The implementation quality is generally good, with proper capability negotiation and tag filtering. ### 6.3 Channel Types Supports `#`, `+`, and `&` prefixes. The `!` prefix is in the CHANPREFIXES constant but has no special handling (no creation TS, no short-name routing). This is acceptable for a modern IRCd. --- ## 7. Comparison to Cathexis IRCd Standards | Criterion | Cathexis Standard | ProvisionIRCd | Verdict | |-----------|------------------|---------------|---------| | TLS minimum | TLS 1.2 enforced | TLS 1.2 effective (disables lower) | ✅ Pass | | Crypto API centralization | All through `ircd_crypto.h` | Scattered across modules | ❌ Fail | | Cloaking | HMAC-SHA256 | SHA-512 → CRC32 | ❌ Fail | | Password storage | Argon2id only | bcrypt optional, plaintext fallback | ❌ Fail | | Constant-time comparisons | `ircd_constcmp()` for secrets | Direct `!=` everywhere | ❌ Fail | | Key material wiping | `OPENSSL_cleanse` | Never wiped | ❌ Fail | | Deprecated API avoidance | Modern OpenSSL only | pyOpenSSL (legacy wrapper) | ⚠️ Marginal | | Code audit standard | 36-pass scan, line 1 to EOF | No formal audit process | ❌ Fail | | AI attribution | No AI attribution in code | N/A | ✅ Pass | | CodeQL clean | Target: zero findings | No static analysis | ❌ Fail | | Buffer safety | `ircd_strncpy`, range checks | Python handles this natively | ✅ N/A | --- ## 8. Positive Findings Despite the security issues, several aspects of ProvisionIRCd are well-engineered: 1. **Module system** — Clean `init()`/`post_load()` lifecycle with hook priorities, command registration, and capability management. This is a genuinely good plugin architecture. 2. **Non-blocking TLS state machine** — The `wrap_socket()` function in `sockets.py` correctly handles `WantReadError`/`WantWriteError` with flag-based state tracking and selector modification. This is tricky to get right and is well-implemented. 3. **Flood control** — Multi-layered: penalty-based (accumulated per action, decays over 60s), recvq/sendq byte limits, backbuffer entry counting, and per-command delays. The `check_flood()` method is thorough. 4. **Client lookup optimization** — `client_by_id`, `client_by_name`, and `client_by_sock` dicts provide O(1) lookup for the three most common search patterns, with the `name` property setter keeping the name dict in sync. 5. **Server link authentication** — The `auth` block in `m_server.py` supports three methods (password, certfp, CN matching) and requires them in combination. This is more flexible than many IRCd implementations. 6. **Configuration parser** — The custom `ConfigParser` in `handle/configparser.py` handles includes, nested blocks, and provides source-file-and-line-number tracking for error messages. Good UX for operators. 7. **Logging system** — MDC-based contextual logging with per-client context, file rotation with size and age limits, and colored console output. The `@logging.client_context` decorator is a nice touch. 8. **Root check** — `ircd.py:18-19` refuses to run as root on Linux. Simple but important. 9. **Host resolution** — Async hostname resolution via `ThreadPoolExecutor.submit()` with a 1-second timeout and result caching. Non-blocking and correct. 10. **Ban system** — TKL implementation is comprehensive: K-lines, G-lines, Z-lines, GZ-lines, shuns, Q-lines (nick), spamfilters, and E-lines (exceptions). Supports extended ban types (`~account:`, `~certfp:`). Expiry, persistence to JSON, and network sync all work correctly. --- ## 9. Recommendations ### Immediate (before any production use) 1. Replace the command socket with a Unix domain socket + permissions or signal-based mechanism 2. Add bcrypt to `requirements.txt` and reject plaintext oper/link passwords at config validation 3. Add `hmac.compare_digest()` for all secret comparisons 4. Replace CRC32 cloaking with HMAC-SHA256 5. Fix the `geodata.py` syntax error (`// debian`) 6. Add threading locks or marshal WebSocket operations to the main thread 7. Add timeout/complexity limits to regex spamfilters ### Short-term 8. Cap the `ThreadPoolExecutor` size and add backpressure 9. Add `re.TIMEOUT` or compile-time complexity analysis for spamfilter patterns 10. Remove `gc.collect()` from client cleanup 11. Fix the `certfp` check in `founder.py` to handle `None` values 12. Replace all `next(..., 0)` with `next(..., None)` and adjust callers 13. Remove dead code (`direct_send_old`, duplicate `clients_()`) 14. Add connection rate limiting to the WebSocket bridge ### Long-term 15. Migrate from pyOpenSSL to Python's `ssl` module (pyOpenSSL is deprecated) 16. Add a formal static analysis pipeline (pylint, mypy, bandit) 17. Implement proper certificate validation for server links 18. Add unit tests — the modular architecture makes this feasible 19. Move hardcoded quotes to a data file 20. Consider migrating the event loop to `asyncio` for better WebSocket integration and DNS resolution --- ## 10. File-by-File Summary ### classes/ | File | Lines | Purpose | Issues | |------|-------|---------|--------| | conf_entries.py | 422 | Config data models (Mask, Allow, Listen, Oper, etc.) | `exit()` calls in `Module.load()` | | configuration.py | 230 | Config builder, rehash logic | Rehash `_restore_config` doesn't restore all state | | data.py | 753 | Numerics, Flags, Hooks, Isupport, Extbans | Flag/hook IDs use sequential globals, fragile | | errors.py | 46 | S2S error codes | Clean | ### handle/ | File | Lines | Purpose | Issues | |------|-------|---------|--------| | channel.py | 500 | Channel and Channelmode classes | Duplicate `clients_()` method | | client.py | 1079 | Client, User, Server, LocalClient, flood control | Dead `direct_send_old()`, `gc.collect()` in cleanup | | configparser.py | 678 | Block-based config file parser | Adequate | | core.py | 1185 | IRCD class, Command, Usermode, Snomask, Capability | CRC32 cloaking, file handle leak, global mutable state | | functions.py | 115 | Utility functions (IP encoding, glob matching) | Recursive `is_match()` without depth limit | | handleLink.py | 193 | Server link negotiation and sync | Cloak key MD5 leaked in NETINFO | | handle_tls.py | 142 | TLS context creation and cert generation | Verify callback always returns True | | log.py | 93 | Log entry system, snomask routing | `cmd_slog` registered at module level (not in init) | | logger.py | 310 | Logging infrastructure with MDC | Well-engineered | | sockets.py | 652 | Event loop, socket I/O, TLS handshake | Duplicate exception handler, unbound command socket | | validate_conf.py | 752 | Config validation functions | Inconsistent error accumulation | ### modules/ (command modules) | File | Lines | Key Issues | |------|-------|------------| | m_oper.py | 213 | Plaintext password fallback | | m_server.py | 280 | Plaintext link password comparison | | m_sasl.py | 191 | Unbounded request table | | m_spamfilter.py | 337 | Regex without timeout | | m_tkl.py | 621 | Clean implementation | | m_nick.py | 219 | Clean, proper collision handling | | m_msg.py | 262 | Clean, proper flood penalties | | m_mode.py | 606 | Complex but functional | | m_sjoin.py | 337 | Proper timestamp-based conflict resolution | | m_protoctl.py | 148 | Strict mode compatibility checks | | m_webirc.py | 54 | Plaintext password comparison | | m_die.py | 36 | Plaintext password comparison | | m_restart.py | 36 | Plaintext password comparison | | m_rehash.py | 72 | Clean | | m_quotes.py | 519 | 500 lines of hardcoded strings | | m_antirandom.py | 505 | Nick entropy checker, well-implemented | | blacklist.py | 191 | Async DNSBL with caching, good | | irc_websockets.py | 170 | Thread-unsafe shared state | | geodata.py | 142 | Syntax error on line 66 | | certfp.py | 63 | Clean | | founder.py | 143 | certfp None-matching bug | | starttls.py | 25 | Race condition with thread | ### modules/chanmodes/ (26 files) All channel mode modules follow a consistent pattern and are generally clean. Notable: `m_history.py` (240 lines) implements full IRCv3 `CHATHISTORY` with `BEFORE`, `AFTER`, `BETWEEN`, `LATEST` subcommands. `permanent.py` handles JSON persistence correctly. ### modules/usermodes/ (5 files) Clean implementations of `+B` (bot), `+d` (deaf), `+i` (invisible), `+x` (cloak), `+z` (TLS), `+D` (block private messages), `+g` (caller-ID). ### modules/ircv3/ (16 files) IRCv3 capability implementations are generally correct. `messagetags.py` (110 lines) provides the tag filtering framework. `batch.py` (100 lines) handles `BATCH` creation and lifecycle. `labeled-response.py` (94 lines) correctly wraps responses in labels. --- *End of audit.*