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}`);- Strip control characters (IRC formatting codes: bold, color, underline) before parsing commands
- Validate argument counts before accessing array indices
- Reject arguments that contain newlines (
\r,\n) — these can inject additional IRC commands - 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 and \n from all interpolated values first.
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 |
|---|---|---|
*!*@specific.host.com |
Good | Static host, hard to spoof |
*!ident@*.isp.com |
Moderate | Ident can be faked on some servers |
*!*@user/account |
Strong | Network-verified cloak (Libera, etc.) |
nick!*@* |
Dangerous | Anyone can use any nick. Never use for privileged users |
Rule: Warn when an admin adds a nick!*@* hostmask for a user with +o or higher flags. Log a [security] warning.
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 nick - Response arrives asynchronously
If the bot ops on join without waiting for ACC verification, an attacker can get ops by using an admin's nick before NickServ identifies them.
Rule: When config.identity.require_acc_for includes a flag level, the bot MUST wait for the ACC response (with timeout) before granting that privilege. Never skip verification for convenience.
- 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 (no requirement) is the only case 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 uses a passive handshake: the bot opens a TCP listener and tells the user which port to connect to via CTCP. The first TCP connection to that port is accepted as the session, regardless of source IP. An attacker who can observe the CTCP exchange and reach the bot's IP could race to connect before the legitimate user, obtaining a session with that user's permissions.
This is an inherent limitation of the DCC protocol — the token mechanism correlates the CTCP offer but does not authenticate the TCP connection.
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 and (optionally) NickServ verification are checked before the port is offered
- Session limits cap the total number of concurrent DCC sessions
Rule: Administrators should understand this risk before enabling dcc.enabled in config. DCC CHAT is best used on networks where the bot's IP is not widely known, or where the CTCP exchange happens via private message rather than a public channel.
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 and the scoped API object is frozen (Object.freeze on nested objects where practical). Database namespace isolation is enforced at the Database class level, not by convention.
- 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
[plugin:name]prefix - 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- 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.
- Add rate limiting between multi-line replies to avoid flood disconnects
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, italic \x1D, underline \x1F, strikethrough \x1E, monospace \x11, reset \x0F, reverse \x16) including color code parameters. 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)
- 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. - 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 |
irc.tls |
true |
Encrypted connection by default |
| 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 |
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 dispatcher uses the live account map from account-notify / extended-join rather than reading this tag directly.
// 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}`);The bot link protocol (src/core/botlink.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, RELAY_*, PROTECT_ACK) are never fanned out to other leaves — the hub processes them internally.
- 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) in values passed toraw()or interpolated into IRC protocol strings - 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
- Plugin uses only the scoped API, no direct imports from
src/ - Long output is split and rate-limited
- Errors in handlers are caught and don't crash the bot
- No secrets in logged output
- Config examples contain no real credentials
- Hostmask patterns for privileged users are specific (not
nick!*@*)