This document defines security practices for developing HexBot. Every contributor and every Claude Code session should treat this as mandatory reading before writing code that handles user input, permissions, IRC output, or database operations.
An IRC bot is a privileged network participant. It holds channel operator status, manages user permissions, and executes commands on behalf of users. Threats include:
- Impersonation — attacker uses an admin's nick before NickServ identification completes
- Command injection — crafted IRC messages that manipulate command parsing or raw IRC output
- Privilege escalation — bypassing the flag system to execute admin commands
- Data leakage — plugin accessing another plugin's database namespace, or config secrets exposed in logs
- Denial of service — triggering flood disconnects, resource exhaustion via unbounded loops, or crash-inducing input
- Hostmask spoofing — relying on nick-only matching (
nick!*@*) which anyone can impersonate
Every field in an IRC message — nick, ident, hostname, channel, message text — is attacker-controlled. Never trust it.
// BAD: directly interpolating IRC input into raw IRC output
bot.raw(`PRIVMSG ${ctx.channel} :Hello ${ctx.text}`);
// GOOD: use the library's safe methods
api.say(ctx.channel, `Hello ${ctx.text}`);- The IRC bridge strips
\r,\n, and\0from all inbound fields viasanitize()(src/utils/sanitize.ts) before they reach handlers — this is the primary injection defence - IRC formatting codes (bold, color, underline, etc.) are stripped from the command word via
stripFormatting()(src/utils/strip-formatting.ts) before dispatch, so\x03colour codes can't disguise a command - Validate argument counts before accessing array indices
- Reject arguments that contain newlines (
\r,\n) — these can inject additional IRC commands (the bridge strips them, but defence in depth applies if a plugin constructs strings from multiple user inputs) - Limit argument length — don't pass unbounded strings to database queries or IRC output
// BAD: no validation
const target = ctx.args[0];
api.say(target, message);
// GOOD: validate target looks like a channel or nick
const target = ctx.args[0];
if (!target || target.includes('\r') || target.includes('\n')) return;
if (!target.match(/^[#&]?\w[\w\-\[\]\\`^{}]{0,49}$/)) {
ctx.reply('Invalid target.');
return;
}IRC commands are delimited by \r\n. If user input containing newlines is passed to raw() or interpolated into IRC protocol strings, the attacker can inject arbitrary IRC commands.
Rule: Never pass raw user input to client.raw(). Always sanitize or use the library's typed methods (say, notice, action, mode). If raw() is ever needed, strip \r, \n, and \0 from all interpolated values first.
Implementation: The IRC bridge calls sanitize() on every field of every inbound event (nick, ident, hostname, target, message). The plugin API's outbound methods (api.say, api.notice, api.action, api.ctcpResponse) also call sanitize() on the message before passing it to irc-framework, providing defence in depth.
better-sqlite3 uses prepared statements which prevent SQL injection. However:
- Always use the parameterized API (
db.prepare('... WHERE key = ?').get(key)), never string concatenation - Validate namespace isolation — the
Databaseclass must enforce that plugins can only access their own namespace - Be aware of storage exhaustion — a malicious plugin or user could fill the DB. Consider per-namespace size limits in a future phase.
Hostmask matching is the primary identity mechanism. Security depends on pattern quality:
| Pattern | Security | Notes |
|---|---|---|
$a:accountname |
Strongest | Matches by services account — requires identification |
*!*@user/account |
Strong | Network-verified cloak (Libera, etc.) |
*!*@specific.host.com |
Good | Static host, hard to spoof |
*!ident@*.isp.com |
Moderate | Ident can be faked on some servers |
nick!*@* |
Dangerous | Anyone can use any nick. Never use for privileged users |
Account-based identity ($a: patterns): The permissions system supports $a:<accountpattern> patterns that match a user's services account name instead of their hostmask. These are stronger than any hostmask pattern because they require the user to have identified with NickServ. The pattern supports wildcards (e.g., $a:alice*). Account data is sourced from IRCv3 account-tag, account-notify, and extended-join capabilities. When no account data is available for a user, $a: patterns are silently skipped and only hostmask patterns match.
Rule: Warn when an admin adds a nick!*@* hostmask for a user with +o or higher flags. Log a [security] warning. Account patterns ($a:) skip this warning — they are inherently secure.
When a user joins a channel:
- Bot sees the JOIN event
- User may or may not have identified with NickServ yet
- Bot queries NickServ (ACC for Atheme, STATUS for Anope)
- Response arrives asynchronously
If the bot ops on join without waiting for verification, an attacker can get ops by using an admin's nick before NickServ identifies them.
Enforcement: The dispatcher (src/dispatcher.ts) has a built-in VerificationProvider gate. When config.identity.require_acc_for includes a flag level (e.g., ["+o", "+n"]), the dispatcher automatically checks identity before calling any handler whose required flags match that threshold. Plugin authors do not need to call verifyUser() themselves — the dispatcher handles it.
Fast path: When the server supports IRCv3 account-notify / extended-join, the bot maintains a live nick-to-account map. The dispatcher checks this map first — if the account is already known, no NickServ round-trip is needed. The slow path (NickServ ACC/STATUS query with 5-second timeout) is used only when account data is not yet available.
Rule: When config.identity.require_acc_for includes a flag level, the bot MUST wait for the verification response (with timeout) before granting that privilege. The dispatcher enforces this automatically. Never bypass the dispatcher for privileged actions.
- The dispatcher MUST check flags before calling any handler that has a flag requirement
- The
checkFlagspath must be: resolve hostmask → find user → check flags → (optionally) verify via NickServ - Flag checking must not short-circuit on the first matching hostmask if that hostmask belongs to a different user
- The
-flag or an empty string''(no requirement) are the only cases where flag checking is skipped entirely - Owner flag (
n) implies all other flags — this is intentional but means owner accounts are high-value targets. Limitnto trusted, verified hostmasks only.
DCC CHAT and in-channel commands use different authentication models on purpose:
| Path | Authenticator | Why |
|---|---|---|
| In-channel commands | Hostmask + IRCv3 account-tag | Prompting on every channel message is impossible; the network already gates message delivery |
| DCC CHAT session | Per-user password (scrypt-hashed) | The socket-local prompt phase gives us a clean place to ask for proof-of-identity |
Why the split matters: On networks where a single vhost persists across nick changes (notably Rizon), the hostmask *!~ident@vhost.cloak identifies the cloak, not the user. An operator identified on their registered nick can /nick to an unregistered nick, keep the same cloak, and match any hostmask pattern that accepts the cloak. For in-channel commands this is inherent to the network — we mitigate with require_acc_for and account-tag matching. For DCC CHAT we have a better option: the bot holds its own secret (the password hash), independent of the network's notion of identity.
Password handling:
- Stored via scrypt (
src/core/password.ts) with a 16-byte random salt and N=16384/r=8/p=1 parameters. Format prefixscrypt$so future rotation to argon2 is unambiguous. - Set via
.chpassfrom the REPL or from inside an existing DCC session. The IRC PRIVMSG path is hard-rejected — passwords never travel over channel messages. - Minimum length 8 characters. No additional policy — operators are responsible for their own hygiene on a small admin user base.
- Never logged.
mod_logrecords(action=chpass, target=<handle>, by=<source>)with no plaintext or hash material.
Plaintext over DCC: The password is sent in the clear over the DCC TCP connection. This is the same failure mode as NickServ IDENTIFY on most networks — a passive observer of the socket already sees every subsequent command, so the password adds no incremental exposure. TLS DCC (DCC SCHAT) is out of scope. Operators who need end-to-end encryption should run a bot-to-user TLS tunnel at the transport layer.
CTCP offer race: A passive DCC handshake opens a TCP listener and advertises the port via CTCP. The first TCP connection is accepted, regardless of source IP. An attacker who observes the CTCP exchange and reaches the bot's IP could race to connect before the legitimate user — but they would then hit the password prompt and fail. This is a material improvement over the pre-0.3.0 model where a racer would inherit the legitimate user's session on connect.
Mitigations in place:
- The listening port is open for only 30 seconds before timing out.
- The listener accepts exactly one connection, then closes.
- Permission flags are checked before the port is offered.
- The session enters an
awaiting_passwordphase on connect — no commands run, no party-line broadcast, until the prompt succeeds. - Repeated bad-password attempts from the same hostmask trigger a per-identity lockout with exponential backoff (
DCCAuthTracker). - Session limits cap total concurrent DCC sessions.
- Users with no
password_hashon file are rejected at connect with a migration notice pointing at.chpass.
Rule: Administrators should treat the DCC password as the root of trust for remote administration and rotate it periodically. Hostmask patterns on a handle are still required for the DCC path to find the user, but they no longer authorize the connection.
Commands from the REPL run with implicit owner privileges — the person at the terminal has physical access. However:
- Log all REPL commands the same way IRC commands are logged
- Never expose the REPL over a network socket without authentication (future web panel must have its own auth)
Plugins receive a PluginAPI object. They must NOT:
- Import directly from
src/modules (bypasses the scoped API) - Access
globalThis,process.env, or the filesystem without going through an approved API - Modify the
apiobject or its prototypes - Access other plugins' state or database namespaces
- Call
eval()ornew Function()on user-supplied input — this is a critical vulnerability class. CVE-2019-19010 (Limnoria, CVSS 9.8) demonstrated that an IRC bot plugin usingeval()for user-submitted math expressions allows full code execution in the bot's process. Any plugin that needs to evaluate expressions must use a sandboxed library with no access to Node.js builtins.
Enforcement: The plugin loader validates exports. The scoped PluginAPI object returned by createPluginApi() is frozen at the top level via Object.freeze(api), and every sub-object (db, permissions, services, banStore, botConfig, config, channelSettings) is individually Object.freeze()-d. Database namespace isolation is enforced at the BotDatabase class level — every plugin DB call is scoped to pluginId as the namespace, not by convention. The plugin-facing botConfig is a separate PluginBotConfig view with the NickServ password omitted and filesystem paths (database, pluginDir) excluded.
- A thrown error in a plugin handler MUST NOT crash the bot or prevent other handlers from firing
- The dispatcher wraps every handler call in try/catch and logs the error with
(pluginId, type:mask)context - A plugin that throws repeatedly should be logged but not auto-unloaded (that's an admin decision)
teardown()must be called on unload — if it throws, log the error but continue the unloaddispatcher.unbindAll(pluginId)must remove ALL binds including timers- Help registry entries, channel setting definitions, channel setting change listeners, and
onModesReadyevent listeners are all removed on unload - Timer intervals that aren't cleaned up will leak and accumulate on reload
- IRC messages are limited to ~512 bytes including protocol overhead
- The bot's own prefix (
nick!ident@host) is prepended by the server, consuming ~60-100 bytes - Rule: Split long replies at word boundaries. Never send unbounded output.
splitMessage()(src/utils/split-message.ts) handles this automatically — it measures UTF-8 byte length (not JavaScript string length), preserves surrogate pairs, and caps output at 4 lines with" ..."truncation. - The message queue (
src/core/message-queue.ts) rate-limits outbound messages to avoid flood disconnects — configurable viaconfig.queue(default: 2 msg/sec, burst of 4). Messages are distributed across targets via per-target round-robin sub-queues.
Don't let user input appear in contexts where IRC formatting codes could mislead:
// BAD: user controls the nick display in a trust-relevant context
api.say(channel, `User ${nick} has been granted ops`);
// An attacker could set nick to include IRC color codes to hide/fake the message
// GOOD: use the shared utility from PluginAPI
api.say(channel, `User ${api.stripFormatting(nick)} has been granted ops`);api.stripFormatting(text) removes all IRC control characters (bold \x02, color \x03, hex color \x04, italic \x1D, underline \x1F, strikethrough \x1E, monospace \x11, reset \x0F, reverse \x16) including color/hex-color parameters (e.g., \x03 followed by 12,4 or \x04 followed by FF0000,00FF00). Apply it to any user-controlled string appearing in:
- Permission grant/revoke announcements
- Op/kick/ban action messages
- Any console or log output that contains user-supplied data
- Log mod actions (op, deop, kick, ban) to
mod_logwith who triggered them - Log permission changes (adduser, deluser, flag changes) with the source (REPL or IRC + nick)
- Never log passwords, SASL credentials, or NickServ passwords — even at debug level
- Sanitize nick/channel in log output to prevent log injection (strip control characters)
The full audit contract — schema, action vocabulary, plugin author rules, the .modlog / .audit-tail operator UI, and the retention story — lives in docs/AUDIT.md.
- High-value secrets are never stored inline in
config/bot.json. Each secret field is named via a<field>_envsuffix that points to an environment variable; the loader resolves it fromprocess.envat startup. Fields covered:services.password_env(NickServ/SASL password),botlink.password_env(bot-link shared secret),chanmod.nick_recovery_password_env(NickServ GHOST password),proxy.password_env(SOCKS5 auth). See docs/plans/config-secrets-env.md for the full spec. - SASL PLAIN over plaintext is refused. The bot will not start if
services.saslistrue,sasl_mechanismis"PLAIN"(the default), andirc.tlsisfalse. SASL PLAIN over cleartext leaks the NickServ password on the wire. Either enable TLS or usesasl_mechanism: "EXTERNAL"with a client certificate. - SASL EXTERNAL (CertFP) is the most secure authentication method: no password at all. Set
services.sasl_mechanism: "EXTERNAL"and configureirc.tls_cert+irc.tls_keypointing to PEM files. The bot authenticates via the TLS client certificate fingerprint registered with NickServ. - SASL authentication failure is a fatal exit, not a retry loop. When the server rejects the SASL credential (numeric 904) or advertises no acceptable mechanism (numeric 908), the reconnect driver exits the process with code 2 instead of retrying. Retrying a bad password against services — especially on networks with failure counters — risks the account being locked or flagged. The operator must fix the credential in
.env, then the supervisor can restart the bot. TLS certificate errors (unable to verify the first certificate, hostname mismatch, expired cert) are treated the same way: permanent until config changes. Seesrc/core/reconnect-driver.tsand DESIGN.md §5 for the full tiering. - Channel
+kkeys are an exception: they're low-sensitivity join tokens shared with every channel member and visible to any channel op via/mode. They may live inline on a channel entry ({"name": "#chan", "key": "..."}). For operators who want them out of the config anyway,key_envis available as an alternative. .envfiles hold the actual secret values and MUST be in.gitignore(they are, via.envand.env.*patterns).config/bot.jsonstill MUST be in.gitignore— while it no longer contains secrets directly, it does contain operational details (hostmasks, connection details) that should not be public.- Example configs (
config/bot.example.json,config/bot.env.example) must never contain real credentials. By construction,*.example.jsoncan only reference env var names, not secrets. - The bot refuses to start if
config/bot.jsonis world-readable. Apply the samechmod 600to.env*files. - Startup validation enforces that every enabled feature has its required env var set — the bot fails loudly with the exact var name when a secret is missing (see
validateResolvedSecretsinsrc/config.ts).
- Plugins must never read
process.envdirectly. Declare a<field>_envfield in the plugin'sconfig.json(or in theplugins.jsonoverride) and readapi.config.<field>from init. The loader resolves the env var before the plugin sees its config. Plugins readingprocess.envcan exfiltrate unrelated ambient secrets (AWS keys, cloud provider creds) that don't belong to the bot. - Never log resolved secret values, even at debug level. Log the env var name instead if a breadcrumb is useful ("HEX_NICKSERV_PASSWORD missing" — not the value).
- Never reference env vars that don't belong to HexBot just because they're in the ambient environment. Every
_envfield should be documented inconfig/bot.env.example. - Rotate secrets after migrating from inline JSON to
_env(the old values were in a plaintext file on disk).
The bot should be safe out of the box, without requiring the admin to harden it:
| Setting | Default | Why |
|---|---|---|
identity.method |
"hostmask" |
Works on all networks, no services dependency |
identity.require_acc_for |
["+o", "+n"] |
Privileged ops require NickServ verification when available |
services.sasl |
true |
SASL is more secure than PRIVMSG IDENTIFY |
services.sasl_mechanism |
"PLAIN" |
Falls back to EXTERNAL (CertFP) if configured |
| SASL PLAIN + plaintext | Refused | Bot refuses to start if SASL PLAIN is used without TLS |
irc.tls |
true |
Encrypted connection by default |
| IRCv3 STS | Enforced | Persisted per-host; prevents TLS downgrade on reconnect |
| Admin commands flag | +n |
Only owner can run admin commands |
.help flag |
- |
Help is available to everyone (no info leak risk) |
Plugin API permissions |
Read-only | Plugins can check flags but not grant them |
| Plugin API object | Frozen | Object.freeze() on the API and all sub-objects |
IRCv3 message tags carry metadata alongside messages. Their trust level depends on who set them:
| Tag type | Prefix | Trust level | Examples |
|---|---|---|---|
| Server tags | none | Server-verified — may be trusted | time, account, msgid |
| Client-only tags | + |
Completely untrusted — treat as user input | +draft/react, +typing |
Rule: Client-only tags (prefixed +) are relayed verbatim by the server without modification. An attacker can set any client-only tag to any value. Never use client-only tag values for security decisions.
Rule: The account server tag (when present) identifies the sender's services account. It may be treated as server-verified, but only when the server has enabled the account-tag capability. HexBot's IRC bridge reads the account tag from inbound events via extractAccountTag() and feeds it into the live account map (used by the dispatcher's ACC verification fast path) and into ctx.account for handler access.
// BAD: reading a client-only tag as authoritative
const userRole = ctx.tags?.['+role']; // attacker can set this to anything
// GOOD: read user flags from the permissions system
const record = api.permissions.findByHostmask(`${ctx.nick}!${ctx.ident}@${ctx.hostname}`);HexBot implements IRCv3 STS (src/core/sts.ts) — the IRC equivalent of HTTP HSTS. Once the bot receives a valid STS directive from a server (via CAP LS), it persists the policy in the _sts database namespace and enforces it on all subsequent connections:
- On TLS: The server advertises
sts=duration=<N>. The bot records the policy; it will refuse to downgrade to plaintext until the duration expires. - On plaintext: The server advertises
sts=port=<P>,duration=<N>. The bot immediately disconnects and reconnects on the TLS port. If the config later changes totls: false, the bot upgrades automatically or refuses to start if no port is known. - Policy expiry:
duration=0clears the stored policy. Non-zero durations are honored even across bot restarts (SQLite persistence).
Why this matters: Without STS, a MitM who intercepts DNS or performs a captive-portal downgrade sees every SASL PLAIN credential, every message, and every op action in cleartext. The SASL PLAIN + plaintext refusal (section 6) is the first defence; STS closes the reconnect-after-restart gap.
The dispatcher (src/dispatcher.ts) implements per-user sliding-window flood protection, configured via config.flood:
pub: limits channel commands (pub + pubm share one counter)msg: limits private message commands (msg + msgm share one counter)- Users with the
n(owner) flag bypass flood protection entirely - On the first blocked message per window, the bot sends a one-time NOTICE warning to the user
- Flood checking runs once per IRC message in the bridge, before the paired dispatch calls — if blocked, both dispatch calls are skipped
The IRC bridge rate-limits CTCP responses to 3 per sender per 10 seconds. The rate limit is keyed on ident@host (the persistent portion of the identity), not on the nick alone, so an attacker cannot bypass the limit by rotating nicks during a CTCP flood.
The message queue (src/core/message-queue.ts) enforces a configurable token-bucket rate limit on outbound messages (default: 2 msg/sec steady-state, burst of 4). Messages are queued per-target in round-robin sub-queues, preventing a single noisy channel from starving others. Long replies are automatically split by splitMessage() and capped at 4 lines per reply.
The bot link protocol (src/core/botlink-protocol.ts, src/core/botlink-hub.ts, src/core/botlink-leaf.ts) introduces a trusted TCP channel between bots. Security considerations:
Hub-authoritative. The hub is the single source of truth for permissions and executes all relayed commands. A compromised hub means total compromise of the botnet. Leaves trust frames from the hub unconditionally (permission syncs, command results, party line messages).
Leaf trust is limited. The hub validates leaf identity via password hash and enforces rate limits. Hub-only frame types (CMD, CMD_RESULT, BSAY, RELAY_*, PROTECT_ACK, ADDUSER, SETFLAGS, DELUSER, PARTY_WHOM) are never fanned out to other leaves — the hub processes them internally. Permission-mutation frames (ADDUSER, SETFLAGS, DELUSER) are hub-only by design: if a leaf could fan them out, a compromised leaf could inject owner-level permissions across the entire botnet.
- Passwords are never sent in plaintext. Leaves send
scrypt:<hex>hashes in theHELLOframe. - The hub compares against a pre-computed expected hash. Failed auth produces
AUTH_FAILEDand the connection is closed. - All bots in a botnet share the same password. Use a strong, unique password per botnet.
The hub tracks per-IP auth failures and temporarily bans IPs that exceed the threshold:
- After
max_auth_failures(default 5) withinauth_window_ms(default 60s), the IP is banned forauth_ban_duration_ms(default 5 minutes). - Ban duration doubles on each re-ban (5m → 10m → 20m → …), capped at 24 hours. The tracker entry never resets — persistent scanners stay at the 24h ceiling.
- Banned IPs are rejected before any protocol setup — no readline allocation, no scrypt, no timer. Zero resource cost.
- Per-IP
max_pending_handshakes(default 3) limits concurrent unauthenticated connections from the same source. - Handshake timeout is configurable via
handshake_timeout_ms(default 10s). Connections that don't sendHELLOin time are closed. auth_ip_whitelistaccepts CIDR strings (e.g.,["10.0.0.0/8"]) whose IPs bypass all auth rate limiting.auth:banevents are emitted on the EventBus with the IP, failure count, and ban duration.- Source IP is included in all auth-related log lines (failure, success, ban, timeout).
Defense in depth: Application-level protection complements but does not replace network-level controls. For production hubs exposed beyond localhost, use firewall rules or a VPN in addition to these settings.
- All string values in incoming frames are sanitized (stripped of
\r,\n,\0) viasanitizeFrame()before processing. - Frame size is capped at 64KB. Oversized frames are protocol errors and cause immediate disconnect.
- Rate limiting: CMD frames at 10/sec, PARTY_CHAT at 5/sec per leaf. Exceeding limits returns an error or silently drops.
When a DCC user runs .relay <botname>, their input is proxied to the remote bot. The remote bot trusts the originating bot's authentication — it does not re-verify the user's identity. This means:
- A relay session inherits the permissions of the user's handle on the hub's permission database.
- If the user is removed from the hub's permissions while relaying, the relay continues until explicitly ended.
PROTECT_TAKEOVER and PROTECT_REGAIN frames request cross-network channel protection from peers. The receiving bot verifies the requested nick exists in its local permissions database before acting. Protection frames cannot be used to op arbitrary nicks — only known users.
- Bot link connections are unencrypted TCP. For WAN deployments, use a VPN or SSH tunnel.
- The
listen.hostconfig should be set to a private IP or127.0.0.1when bots are co-located. Do not expose the link port to the public internet without transport encryption.
Use this checklist when reviewing any PR or code change:
- All IRC input is validated before use (nicks, channels, message text)
- No newlines (
\r,\n,\0) in values passed toraw()or interpolated into IRC protocol strings — usesanitize()fromsrc/utils/sanitize.ts - Database operations use parameterized queries (no string concatenation in SQL)
- Permissions are checked before privileged actions
- NickServ verification is awaited (not skipped) for flagged operations when configured — the dispatcher enforces this automatically via the
VerificationProvidergate - Plugin uses only the scoped API, no direct imports from
src/ - Plugin does not read
process.envdirectly — declare<field>_envin config and read fromapi.config - Long output is split and rate-limited
- Errors in handlers are caught and don't crash the bot
- No secrets in logged output — log env var names, not values
- Config examples contain no real credentials
- Hostmask patterns for privileged users are specific (not
nick!*@*) — prefer$a:accountnamepatterns where services are available -
stripFormatting()applied to user-controlled strings in security-relevant output (permission grants, op/kick/ban messages, log entries)