This document describes the security boundaries luadch enforces, the trust assumptions it makes about its operating environment, and the design rationale behind the at-rest data protections introduced in the Phase 7 security audit.
It is the authoritative reference for operators deciding how to deploy
luadch and for contributors reviewing security-relevant changes. The
detailed audit findings live in
docs/phases/PHASE_7_FINDINGS.md.
The hub serves untrusted ADC clients on the public internet. The classes of attacker we defend against:
| Attacker | Capability | Defended against? |
|---|---|---|
| Network attacker | Can send arbitrary bytes to the listening ports | yes |
| Authenticated user | Has valid credentials, opens a normal session | yes |
| Backup thief | Reads cfg/, certs/ from a snapshot or unprotected backup, no host access |
yes |
| World-readable share | Hub data ends up on a misconfigured Samba / NFS / Dropbox mount | yes |
| Local file-write | Has filesystem write to cfg/ but no shell on the host |
partially (RCE eliminated; file-tamper still causes load-fail) |
| Host-level RCE / shell | Can read process memory, exec arbitrary code | no |
| Plugin author | Writes a malicious script and the operator installs it | no (see §2) |
The "host-level RCE" row is the one that matters most: ADC's password challenge protocol forces the hub to keep a password-equivalent secret in process memory while the hub runs (see §3). Anyone who breaks out of the host's OS-level isolation can read it. The same trade-off applies to Firefox's primary password, Telegram's local DB, and Apple Keychain in its unlocked state. We accept it as a protocol constraint.
Plugins under scripts/ are trusted by design.
The trust boundary is between "operator-installed plugin" and
"untrusted ADC client", not between "plugin" and "core".
core/scripts.lua builds each plugin's
_ENV from an explicit SANDBOX_GLOBALS whitelist (added in
#206). os and
io are curated shims: os exposes only time / date /
difftime; io exposes only a path-restricted open (relative
paths, no .. traversal) - io.popen, io.lines, package,
require, and debug are NOT reachable. Subprocess access lives
in a separate core/sysinfo.lua module
that exposes a small audited surface for the bundled
cmd_hubinfo plugin.
The same path-restriction gate covers the plugin-callable I/O
functions exported by core/util.lua
(checkfile, loadtable, savetable, savearray, maketable,
atomic_write) via the shared util.safe_path helper - closed
in #266 where
util had previously captured the unsandboxed io.open at module
load and bypassed _io_safe.
These mechanisms are defence-in-depth, not a hard boundary. A malicious plugin can still:
- Read any file under the hub's working directory via the
permitted relative-path range (e.g.
cfg/master.key,certs/serverkey.pem). - Write
.tblcontent to any permitted relative location. - Reach all hub-internal data: in-RAM cleartext of
user.tbl, the full plugin sandbox table, every other loaded plugin.
The gate raises the floor for accidental escapes by buggy
plugins; it does not protect against an actively malicious one.
cfg.no_global_scripting adds an error-on-undeclared-globals
metatable on top, also defence-in-depth.
Operator responsibilities:
- Audit every plugin you install. Read the source. Do not pull plugins from random forums.
- Treat the
scripts/directory like the rest of the hub binary: write-protect it from non-admin users on the host. - A compromised plugin trivially exfiltrates
cfg/master.key,certs/serverkey.pem, and the in-RAM cleartext ofuser.tbl. No in-hub mechanism prevents that.
The corresponding audit finding is F-SAND-1 (info-level; #206 / #213 / #266 are incremental hardening on top, not a "fix" - tightening the sandbox further would break the existing plugin ecosystem).
An admin-scope HTTP API token can do everything +masteruser can
do, including:
- Read its own and every other admin token's audit-log bodies via
GET /v1/log/api. Bodies for routes that opt into the §6.8 redact mechanism (audit_redact_body = true- currently the two password endpoints) log as[redacted]even to admin readers, but everything else is plaintext. - Issue
POST /v1/restart/POST /v1/shutdown/POST /v1/reload. - Toggle plugins (
PUT /v1/plugins/{name}/enabled). - Mutate any non-denylisted cfg key (
PUT /v1/config/{key}). - Bypass ADC
+unbanlevel checks (HTTP-created bans persist withby_level = 100).
Treat the admin token like the +masteruser password. Rotate it
on operator turnover; never embed it in a non-loopback-reachable
process; use comment to label tokens so audit lines are
traceable.
luadch implements the ADC BASE extension's HPAS challenge-response:
Server: IGPA <fresh_per_login_salt>
Client: HPAS base32(Tiger(password || salt))
Server: must compute Tiger(stored, salt) and match
For the server's hash to match the client's, the stored value must
equal the client's password input. Any one-way KDF (Argon2id,
bcrypt, scrypt, PBKDF2) makes the server unable to produce the same
Tiger output, so login breaks. This constraint is shared by the
entire ADC ecosystem (ADCH++ stores cleartext in XML, uHub in
users.conf, …) and there is no published ADC extension that lifts
it. The audit research is recorded in issue
#52.
- In RAM, while the hub runs: plaintext passwords are present as field values on each registered user's profile object. There is no way around this within standard ADC.
- On disk:
cfg/user.tblis AES-256-GCM encrypted under a host-bound master key (Phase 7f, F-AUTH-1).
offset bytes
0 4 magic "LDC1"
4 12 nonce (96-bit, fresh per write via OpenSSL RAND_bytes)
16 N ciphertext
16+N 16 GCM authentication tag
GCM authentication is the security-critical signal: a tampered file
fails the tag check and loadusers returns an error. The hub does
not silently accept tampered input.
- Default path:
cfg/master.key(set themaster_key_pathcfg key to override; see "Backup separation" below) - Size: 32 raw bytes (AES-256)
- Generation: automatic on first boot via OpenSSL
RAND_bytes - POSIX permissions:
chmod 600. The hub refuses to start if the existing key file has any other mode (modeled on OpenSSH's~/.ssh/id_rsastrict-mode check). - Windows permissions: see §4 below.
The default cfg/master.key location was chosen for first-boot
convenience and backwards compatibility, not for production
security. With the default, a routine
tar czf backup.tar.gz cfg/ bundles both the encrypted
user.tbl AND its decryption key into one archive. An attacker who
exfiltrates that backup decrypts everything offline; the at-rest
encryption provides zero protection in that scenario.
For production deployments, set the master_key_path cfg key in
cfg/cfg.tbl to an absolute path outside the install directory:
master_key_path = "/etc/luadch/master.key" -- POSIX
master_key_path = "C:/ProgramData/luadch/master.key" -- WindowsThen handle that path the same way you handle
certs/serverkey.pem:
- exclude it from the routine
cfg/backup, or - back it up to a separate destination (different host, different storage tier, or pass-phrase-encrypted archive).
The hub still enforces 0600 on the configured path on POSIX. On
Windows, apply icacls to the new path - see §4.
- Backup / snapshot exfiltration of
cfg/without the host - only ifmaster_key_pathpoints outsidecfg/per the section above - World-readable
cfg/user.tblfrom a default umask - File-system-only read primitive (read-only mount, share, lost laptop, …)
- On-host RCE / Lua-sandbox escape (see §1, §2)
- Plugin compromise (see §2)
- Master-key file theft. If the attacker exfiltrates both
cfg/master.keyandcfg/user.tbl, they have all the credentials.
OS-bound key wrapping (TPM, DPAPI machine-scope, libsecret, macOS Keychain) would harden the master-key-theft case and is tracked as a Phase 8+ candidate in #48.
Operator opt-out: encrypt_usertbl = false (#128)
Some deployments do not need disk-level confidentiality and prefer
the operational convenience of a plaintext user.tbl (custom backup
scripts, third-party admin UIs that read the file directly, ad-hoc
inspection with a text editor, recovery without the master key as a
hard requirement). For those, the cfg toggle encrypt_usertbl can be
flipped to false in cfg/cfg.tbl:
encrypt_usertbl = false,Default: true. New deployments and upgrades from earlier v3.1.x
keep encryption on.
What you give up by setting it to false:
- Backup confidentiality. A routine
tar czf cfg.tar.gz cfg/exfiltrates plaintext user passwords. ADC mandates the hub hold password-equivalents in RAM; with the toggle off, the same values land on disk inreturn { ... }Lua source. - Stolen-disk protection. An attacker who walks off with the
host's disk reads
user.tbldirectly. - The forced-confidentiality default that makes a casual
tar/scp/ cloud-sync transfer non-leaky.
What you keep regardless of the toggle:
chmod 600onuser.tblon POSIX (still set bysaveusers).- The atomic-write + always-fresh
.bakflow (closes upstreamluadch/luadch#189). - Sandboxed
loadtableon the plain-Lua-source path (theloadfile(path, "t", { })empty-_ENVfrom Phase 7e blocks RCE on a tampereduser.tblregardless of the encryption toggle).
Migration is automatic in both directions:
true->false: the nextsaveuserswritesuser.tblas plain Lua source. Until then, the encrypted file on disk still decrypts on read via the existingmaster.key(the key file is loaded as long as it exists on disk, regardless of the toggle).false->true: the nextsaveuserswrites an LDC1 blob usingmaster.key(auto-generated if missing).- Existing
user.tblfiles in either format auto-detect on load via the LDC1 magic prefix, so no operator action is required during the toggle flip itself.
Pick the toggle based on your threat model. Public-facing hub on a
shared host: keep the default (true). Single-user home hub on a
private host where the disk-level threat model is "if my disk
leaves my house I have bigger problems": false is reasonable. The
hub does not assume which one applies.
Password disclosure in command replies (#95)
The same cleartext-equivalent password the hub must hold for the ADC
BASE challenge (F-AUTH-1, above) used to be echoed back over the
chat / PM channel by four admin commands. On a plain-ADC listener that
exposes the value on the wire; even on TLS it lands in the client's
local chat log (DC++ keeps one by default).
Redacted (PR #119):
+accinfoand+usersearchshow<REDACTED>in the password column of every output format (the pre-existing permission gate is unchanged: a denied row still shows<Not allowed to view>).+setpassno longer echoes the new password back to the caller (who typed it, or is the target). The target of an admin+setpass nick <NICK> <PASS>still receives it via PM - they need it to log in.
+reg auto-generated password: delivered by design, not a defect.
+reg generates the password with util.generatepass() and it must
reach the new user for their first login. In a base DC hub the only
in-hub channel is a private message, so +reg PMs the value to the
new user (when online) and to the registering admin. The admin copy
is required for the offline-target case: if the registered nick is
not connected, the admin is the only party who receives the password
and conveys it out of band - dropping it would leave a freshly
registered user unable to log in. This is a directed disclosure to the
two parties who need the value, not a broadcast; the opchat reg-report
(report.send) never contains the password. The residual exposure
(the value sits in the admin's and the new user's local client logs)
is inherent to password-based ADC registration without an external
side channel, and is mitigated by the at-rest encryption above plus
the cmd.log redaction from
#96. A leak-free
delivery (SMTP invite via #100,
or a first-login token flow) is a Phase 8+ candidate that depends on
infrastructure luadch does not yet ship; until then the chat-delivery
behaviour is retained deliberately.
The hub automatically chmod 600s every secret file it writes on
POSIX (Phase 7b,
F-SEC-1). That covers:
cfg/user.tbl(registered-user database, encrypted blob)cfg/user.tbl.bakcfg/master.keylog/audit-YYYY-MM-DD.jsonl(#84 staff-action audit trail;etc_auditlogchmod_secrets the file on first write per daily path).certs/serverkey.pemandcerts/cakey.pemare 0600'd byexamples/certs/make_cert.shat generation time.
Existing deployments that pre-date Phase 7b should run once:
chmod 600 cfg/user.tbl cfg/user.tbl.bak certs/serverkey.pem certs/cakey.pem
# master.key is created by Phase 7f and ships pre-chmod'd. If you
# moved it via master_key_path, chmod that path too.NTFS does not have POSIX permission bits, so the hub does not attempt to enforce permissions automatically. After install, run:
icacls "cfg\user.tbl" /inheritance:r /grant:r "%USERNAME%:F"
icacls "cfg\user.tbl.bak" /inheritance:r /grant:r "%USERNAME%:F"
icacls "cfg\master.key" /inheritance:r /grant:r "%USERNAME%:F"
icacls "log" /inheritance:r /grant:r "%USERNAME%:F"
icacls "certs\serverkey.pem" /inheritance:r /grant:r "%USERNAME%:F"
icacls "certs\cakey.pem" /inheritance:r /grant:r "%USERNAME%:F"Replace %USERNAME% with the dedicated service account if the hub
runs as LocalService or similar. If master_key_path points to a
different location (e.g. C:\ProgramData\luadch\master.key), apply
the same icacls line there. The same recipe lives in
docs/BUILDING.md.
Audit log of staff actions (#84)
log/audit-YYYY-MM-DD.jsonl is the centralised, machine-readable
record of every staff action across both ADC chat (+ban, +reg,
+disconnect, ...) and the HTTP API (POST /v1/registered,
DELETE /v1/users/{sid}, ...). One JSON object per line; see
SCRIPTS.md etc_auditlog for the shape
and the full action vocabulary.
Append-only contract. scripts/etc_auditlog.lua opens the
file via io.open(path, "ab") exclusively. No code path in the
plugin truncates the active file. There is no
DELETE /v1/log/audit endpoint and no +clearauditlog ADC cmd.
Clearing is filesystem-level only (operator deletes the file
with explicit OS chain-of-custody). This is deliberate: a
mutable audit trail provides no compliance value.
chmod 0600. First write to each new daily path triggers
util.chmod_secret(path) (POSIX). Audit lines carry target IPs,
CIDs, and actor session metadata; world-readable defaults would
leak operator activity. Per-process tracking set ensures the
chmod fires once per file, not once per event.
Retention. etc_auditlog_retention_days (default 90). On
each UTC-midnight rollover (and once at boot) the plugin
unlinks any audit-*.jsonl whose embedded date is older. Set
0 to disable the sweep; operator-driven cleanup then. The
sweep probes by reconstructing the known filename pattern day
by day from now - retention_days - 1 back to now - retention_days - 365 (sandboxed plugins cannot enumerate the
directory; this approach handles retention shrinkage).
Plugin trust contract. Any admin-trusted plugin can call
audit.fire(audit.build(...)) with arbitrary actor / target /
reason fields. The audit log is therefore as trustworthy as the
plugin set in cfg.scripts. This is the same baseline as
§2 Plugin trust contract:
operators MUST review every third-party plugin before enabling
it. A malicious plugin could spoof audit entries; the file's
append-only contract bounds the damage to "addition only, no
deletion of legitimate entries". The four-fields-snapshotted-at-
fire-time invariant (core/audit.lua _snapshot_actor) means
even a misbehaving plugin cannot fabricate a different actor's
session metadata than its own at the moment of fire.
Field caps. audit_log_max_reason_chars (default 1000) and
audit_log_max_meta_value_chars (default 1000) bound per-string
length at audit.build time, applied to both the disk JSONL and
the /v1/events ringbuffer entries. Defense against a malicious
input ballooning a single log line.
Control-byte sanitization. Every string field that lands on
disk or in the events ringbuffer is util.strip_control_bytes'd
at build time (core/audit.lua _safe_str for user-object
snapshots, _normalize_str for flat-table input). INF-derived
strings are F-INF-2-clamped at parse time too, so this is
defense-in-depth.
Some cfg keys hold authentication material (HTTP API bearer tokens,
future plugin API keys) or paths that protect at-rest encryption.
These are tracked in a SINGLE registry at
core/secrets.lua - the same module that
GET /v1/config redaction consults and that plugin secrets-lookup
helpers go through.
Registry semantics. A cfg key registered via
secrets.register(cfg_key) is treated as sensitive for the
process lifetime:
GET /v1/configmasks its value as"<redacted>".PUT /v1/config/{key}returns403 E_FORBIDDEN(rotate via directcfg.tbledit + restart).- Future
+showcfg/+config showcmds (when added) will consult the same registry.
Baseline registrations are seeded by secrets.init():
http_api_tokens(HTTP API auth tokens, existed pre-arc).master_key_path(cfg_secret encryption key path, existed pre-arc).
Plugins register their own sensitive keys at onStart:
local secrets = use "secrets"
secrets.register( "etc_geoip_license_key" )
secrets.register( "etc_proxydetect_api_key" )
secrets.register( "etc_status_push_token" ) -- heartbeat bearer token
-- etc_webhook registers one HMAC secret PER configured endpoint,
-- dynamically named etc_webhook_<name>_secret, e.g.:
secrets.register( "etc_webhook_discourse_secret" )register() is exposed to every plugin via SANDBOX_GLOBALS and
takes effect for the process lifetime - the same trust assumption
as §2 applies. A hostile plugin can hide a live cfg key from
GET /v1/config by registering it, but the sandbox already grants
worse capabilities (file I/O, network, hub state). The registry is
defense-in-depth alongside the per-key chmod baseline above, not a
plugin-trust boundary.
Env-var-first lookup. Plugins that need an API key call
secrets.lookup(cfg_key) instead of cfg.get(cfg_key). The helper
checks LUADCH_<UPPER_CFG_KEY> (e.g. LUADCH_ETC_GEOIP_LICENSE_KEY)
first, then falls back to cfg.get. Empty strings in either
location are treated as unset, so an accidentally blank env var
does NOT mask a populated cfg value.
Use cases:
| Deployment | Where the key lives | Why |
|---|---|---|
| Docker / docker-compose | environment: section of the service |
Survives container restarts; never written to disk; trivial rotation via docker compose up -d after a value change |
| Bare-metal (Linux / Windows) | cfg/cfg.tbl (chmod 600 / icacls applied per the recipes above) |
Stable across reboots; no env var to forget |
| CI / staging | LUADCH_* env var injected by the orchestrator |
Separation between code-config (cfg.tbl in repo or volume) and secrets (injected per environment) |
Mixed deployments are allowed - any individual key can travel via env, via cfg, or both (env wins).
Why env-var-first and not encrypted-cfg. The hub-itself already
encrypts cfg/user.tbl (Phase 7c F-AUTH-1) but cfg.tbl itself is
plain on disk with chmod 600. Encrypting individual cfg keys
introduces an at-rest-secrets-management problem that the existing
master-key path already solves at the user.tbl scope. The env-var
fallback gives Docker operators a path that does not touch disk at
all (env vars live in /proc/<pid>/environ, readable only by the
process owner), and bare-metal operators a path that mirrors how
they already manage cfg.tbl (chmod 600 + backup separation).
Process-environ leak surface. os.getenv reads from the
process environment, which Linux exposes via /proc/<pid>/environ
to the process owner. Run the hub under its own dedicated user;
the per-OS hardening recipes above already cover this.
etc_webhook (operator guide: docs/WEBHOOKS.md) is the
first plugin to register an HTTP route with scope = "none" - the
router's bearer-token gate is skipped and the handler does its OWN
authentication:
- HMAC-SHA256 over the raw body. Each endpoint has a shared secret;
the handler recomputes
hmac.sha256(secret, req.raw_body)(the exact received bytes, before any re-encode) and compares it to the sender's signature header (X-Discourse-Event-Signature/X-Hub-Signature-256) withadclib.constant_time_eq. A missing or wrong signature returns 401 and nothing is announced. An endpoint with no resolvable secret is not even registered (it can never run unsigned). - Secret at rest. Per-endpoint secrets resolve env-var-first
(
LUADCH_ETC_WEBHOOK_<NAME>_SECRET), else theetc_webhook_<name>_secretcfg key, else inline in the chmod-600cfg/webhooks.tbl(which is NOT emitted byGET /v1/config). The value is never logged (debug lines name the key / env-var only). - Replay / dedup. Duplicate deliveries (sender retried) are dropped
by the delivery-id header (
X-Discourse-Event-Id/X-GitHub-Delivery), bounded todedup_maxentries. - Content safety. Every templated field is control-byte-stripped and length-capped before broadcast; a per-minute flood cap bounds chat spam. The announcement carries only the operator-authored template + public payload fields.
- Exposure. The HTTP listener is plain HTTP on
127.0.0.1by default and only binds once a token is configured; a webhook reachable from the internet must sit behind a reverse proxy terminating TLS. The 64 KiB request-body cap is global - an oversized signed delivery is rejected (a missed announcement, not a vulnerability).
| Defense | Where | Tunable via cfg |
|---|---|---|
| Per-IP parallel-socket cap | core/server.lua accept |
ratelimit_perip_max_conns (default 16) |
| Per-IP accept-rate cap | same | ratelimit_perip_conn_rate / _burst |
| TLS handshake wallclock deadline | same | ratelimit_handshake_timeout (default 10s) |
| Per-IP failed-auth tracking + sticky lockout | core/hub_dispatch.lua HPAS |
ratelimit_perip_authfail_*, ratelimit_authfail_lockout |
| Per-account bad-password lockout (independent of per-IP) | same | max_bad_password, bad_pass_timeout |
| Per-user mainchat rate-limit | core/hub_dispatch.lua BMSG |
ratelimit_user_msg_* |
| Per-user PM rate-limit (#80) | core/hub_dispatch.lua DMSG/EMSG |
ratelimit_user_pm_* |
| Per-user BINF-update rate-limit (#80) | core/hub_dispatch.lua BINF |
ratelimit_user_inf_* |
| Per-user CTM/RCM rate-limit (#80) | core/hub_dispatch.lua DCTM/DRCM |
ratelimit_user_ctm_* |
| Per-user search rate-limit | core/hub_dispatch.lua BSCH |
ratelimit_user_search_* |
| Per-userlevel tier overlay (#80) | core/ratelimit.lua init |
ratelimit_tiers, ratelimit_tier_for_level |
| Op-level bypass of per-user limits | core/ratelimit.lua |
ratelimit_bypass_level (default 60) |
| Parser-side message-size cap | core/adc.lua parse |
hardcoded 64 KiB |
| Connection read-buffer cap | core/server.lua |
hardcoded 1 MiB |
| INF-IP consistency check (kick on TCP-source vs INF-claim mismatch) | core/hub_dispatch.lua BINF + scripts/hub_inf_manager.lua |
kill_wrong_ips (default false since v3.2.x; see operator note below) |
The full DoS-hardening rationale is in Phase 7c (#56).
kill_wrong_ips operator note (#97)
The kill_wrong_ips default flipped from true (v3.1.4 through
v3.1.x) back to false in v3.2.x. The motivation for the v3.1.4
strict default was to prevent a client from broadcasting a spoofed
I4 / I6 value to other peers (DDoS-amplification risk: peers
would direct CTM / RCM connection attempts at the spoofed
victim address). Since v3.2.x that vector is closed at a lower
level - the
#214 Gap 2 fix
in core/hub_dispatch.lua overrides
any client-claimed mismatched IP with the authenticated TCP source
IP before broadcasting, regardless of kill_wrong_ips. The
gate is therefore no longer protecting anything by construction;
it only controls whether a mismatched-claim user is killed (loud)
or has their broadcast INF silently corrected (lenient).
The legitimate passive-mode I40.0.0.0 case is handled before
the gate (the hub fills in the real IP), so passive clients are
unaffected either way.
With kill_wrong_ips = false (new default):
- Clients whose INF-advertised IP does not match their TCP source - a VPN client with a stale cached IP, a client hand-set with the wrong External / WAN IP, a dual-stack client where the kernel picked a different outbound family than the configured advertise - stay connected instead of being kicked. Their broadcast INF is rewritten to the authenticated TCP source IP, so peers reach them correctly wherever that source IP is itself reachable (VPN-egress, single-NAT). This gate only concerns the advertised-vs-source mismatch; it is unrelated to how the hub blocks users - e.g. the Traffic Manager, which on 3.x decides on level / share / account-nick, not IP (#364).
- The edge case where the user's TCP source genuinely cannot reach their P2P listener (multi-WAN with policy routing, certain corporate setups) becomes a silent failure: user stays online, P2P connections from peers fail. Empirically this group is small in practice (such users typically either run passive mode or are CGNAT-blocked from active mode anyway).
With kill_wrong_ips = true (opt-in):
- Mismatched-IP clients get an explicit kick on login with an
actionable hint pointing them at their client's
External / WAN IPsetting (PR #331). Operator sees these kicks inlog/error.logfor diagnostics. - Picks a louder failure mode over a silent one: useful for hubs with a known-tightly-configured userbase where any mismatch is almost certainly an operator-fixable client problem.
Per-IP rate limits, GeoIP / unified blocklist matches, abuse logs,
and any plugin reading user:ip() operate on the TCP source IP
regardless of this toggle - none of those primitives depend on
the kill semantic.
Dual-stack secondary-address verification (#214, HBRI)
Since v3.2.x luadch accepts a BINF that carries BOTH I4 and I6
in one frame, so a dual-stack peer can advertise both
(#147 T3.1). The
hub can only authenticate the field matching the connecting TCP
source's family against the actual TCP source IP - it has no socket
on the other family through which to verify the secondary address.
Broadcasting an unverified secondary would be a DC++ DDoS-amplification vector: a dishonest client could advertise an arbitrary victim IP as its secondary, and other clients would then direct CTM / RCM connection attempts at that address (the historical DC++ DDoS pattern - Wikipedia). This is not an unavoidable trade-off; luadch closes it two ways:
-
Strip by default (Gap 1). For every client, the unverified secondary family's address (
I4/I6), UDP port (U4/U6) and transport SU flags (TCP4/UDP4/TCP6/UDP6) are stripped before the INF is stored or broadcast, incore/hub_dispatch.lua's BINF handler. Only the authenticated primary family is ever advertised to other users. A dishonest secondary claim never reaches the wire. -
Verify, then restore (HBRI, opt-in). With
hbri_enabledAND a listener on both families ANDhbri_advertise_v4/hbri_advertise_v6set, the hub advertisesADHBRIand validates a supporting client's secondary over a second-family side-channel (core/hbri.lua): it mints a CSPRNG token, sends anITCPpointer, and only commits + broadcasts the secondary once the client connects back on the other family and presents the token. The committed address is always the side-channel's authenticated TCP source - never a client-supplied value, and a connection from the claimed address is proof of reachability. A client may advertise either a concrete secondary or the spec placeholder (I6::/I40.0.0.0, the common auto-detect case): the placeholder makes the hub discover the address from the side-channel getpeername (#291); a concrete value is accepted only if it equals that source. On validation failure or ahbri_timeout-second timeout the user enters the hub normally with the secondary left stripped (the Gap-1 default). The side-channel rides the normal accept path, so it uses the advertised port's transport - a client connecting back to a TLS / autossl port does TLS on the side-channel too (matching its main connection). HBRI needs a listener (plain or TLS) on both families; a family with no listener disables it (#298).
Either path guarantees the broadcast INF only ever carries an address
the hub authenticated. A client that advertises its secondary only in
a post-login INF update (not the initial BINF) is handled the same
way (#286): the
unverified I4 / I6 is still stripped from that update before
broadcast (the #97 / #222 closeout in
scripts/hub_inf_manager.lua stays
in force), and only a side-channel-validated secondary is then
broadcast - the user is never removed from the normal state for the
re-validation. An unverified post-login I4 / I6 therefore still
never reaches the wire.
The primary-family sibling of this vector - kill_wrong_ips = false
letting a NAT-weird client's wrong primary claim broadcast - was
closed under #214 Gap 2: the mismatched primary claim is overwritten
with the authenticated user:ip() rather than forwarded.
Rate-limit and plugin contract (#80)
Per-user rate limits fire before the plugin listener chain
inside core/hub_dispatch.lua. When a
bucket is exhausted, the dispatcher returns from the handler with
true (handled), which suppresses both the rest of the dispatch
and the plugin onBroadcast / onPrivateMessage / onInf /
onConnectToMe / onRevConnectToMe listeners. Throttled messages
do not reach plugins at all.
For most plugins this is the correct semantic and matches the
pre-#80 behaviour for BMSG (which was already throttled). The
edge cases worth knowing about:
- Plugins doing count-based heuristics on per-user messages (e.g. "block after N suspicious CTMs from one user") see only the pre-throttle subset of traffic. Attackers exceeding the bucket hit the hub-level drop and never reach the plugin's counter. Operationally that's still a defence (hub drops the abuse) but the plugin's own logs / counters undercount.
- Bundled plugins audited for #80 are unaffected in practice:
etc_trafficmanagerdoes first-hit blocklist lookup (static, not cumulative);hub_inf_managerwrites user state on each BINF and just sees a slightly stale state for one bucket-cycle until the next legitimate BINF;usr_uptimeis timer-driven, not BINF- driven; the rest of theusr_*/etc_*plugins reading INF fields tolerate stale state until the next non-throttled update.
If you write a plugin and need exact message accounting, do not rely on the dispatcher's listener fan-out alone - it is rate- limited at the hub boundary by design.
onSearchResult contract widening for F-class (#147 T1.6)
Before #147 the onSearchResult listener only fired on D-class
(DRES) - single-recipient search results. Returning a truthy value
(return PROCESSED) from the listener suppressed delivery to the
one target SID.
After #147 T1.6 the same listener also fires on F-class (FRES) -
feature-filtered fan-out where the message is delivered to any
client matching a feature mask. Returning truthy on the F-class
path suppresses delivery to the entire set of matching
recipients, not just one.
Plugins differentiate the two cases by checking the targetuser
arg: nil = F-class fan-out (wide impact), non-nil = D-class single
recipient.
hub.setlistener( "onSearchResult", { },
function( user, targetuser, adccmd )
if not targetuser then
-- F-class. Returning PROCESSED here drops the whole
-- feature-filtered fan-out. Use with care.
else
-- D-class. Returning PROCESSED drops one delivery only.
end
return nil -- let it through
end
)The bundled hub_cmd_manager.lua only reads user:level() and
returns PROCESSED unconditionally on level mismatch; it tolerates
the new arg shape but operators using it should be aware that
unauthorised F-class results are now dropped for the whole
recipient set instead of per-recipient.
luadch supports plain ADC and TLS-wrapped ADCS in parallel. Default
TLS configuration in core/cfg_defaults.lua:
- Protocol: TLS 1.3 (
tlsv1_3) - Cipher list:
"HIGH" - Disabled: SSLv2, SSLv3
- Peer-cert verify: off (correct for the server-side ADC role - clients are unauthenticated at the TLS layer; auth happens at the ADC HPAS layer)
The ADC KEYP extension lets clients pin the hub's TLS certificate
fingerprint; operators can publish their fingerprint via the
+hubinfo command and clients that support KEYP will reject
mismatching certs.
Recommendation: leave zlif_over_tls = false on production hubs.
The bandwidth saving stacking compression UNDER TLS is usually not
worth the residual CRIME-class risk. Plain-ADC connections see
ZLIF unconditionally when zlif_enabled = true; the flag below
only matters for ADCS / TLS connections.
Phase 8 S4b adds optional ADC-EXT ZLIF stream compression. ZLIF is
off by default (zlif_enabled = false); when an operator enables
it, a separate flag (zlif_over_tls, also default false) gates
whether ZLIF activates on TLS-wrapped connections in addition to
plain ADC.
The rationale for the second flag is the CRIME-class chosen-plaintext-length leak that applies to any scheme of "compress then encrypt". In the luadch + ZLIF + TLS deployment the shape is:
- An attacker on the same hub PMs a victim chosen plaintext.
- The hub forwards the PM on the victim's TLS-wrapped connection, mixed with whatever else that connection carries (broadcast chat, user lists, PMs from other peers).
- The hub deflates the per-connection stream BEFORE TLS encrypts it, so the ciphertext length depends on the compressed length, which depends on the dictionary similarity between the attacker's chosen plaintext and the victim's other contents.
- A wire-level eavesdropper (LAN/ISP) observing length deltas can in principle infer whether the chosen plaintext matched something else in the victim's stream.
In practice the exploit is weak: broadcast traffic adds noise, the
attacker needs eavesdropper access on the victim's network, and
distinguishing 1-bit length deltas in a busy hub is hard. But the
mitigation cost is one cfg flag, so the safe default is false -
operators who want the bandwidth saving and accept the residual
risk set zlif_over_tls = true. Plain-ADC connections see ZLIF
unconditionally when zlif_enabled = true; only TLS is gated.
ZLIF also has two transport-level hardening properties enforced by
the binding (zlib_stream/zlib_stream.c):
- Decompression-bomb cap. Each inflate call caps decompressed
output at 4 MiB. Exceeding the cap raises a Lua error which the
inbound inflate stage propagates as the pipeline's overflow
signal, and
core/server.lua's read loop closes the connection. A 1 KB compressed payload that expands to GiB on the wire cannot drive runaway memory usage on the hub. - Malformed-input close. zlib
Z_DATA_ERROR/Z_NEED_DICTon a corrupted compressed stream is also surfaced as overflow; the hub closes rather than continuing on poisoned state.
Plugins that make outbound HTTPS requests via
core/http_client.lua (hublist
announce, future external feed pulls, future proxy/VPN detection
API calls) authenticate the remote against a bundled Mozilla CA
root snapshot:
| Aspect | Default |
|---|---|
verify |
"peer" (was "none" before Precursor 0b of the #78 arc) |
cafile |
certs/ca-bundle.pem (bundled, 121 trusted roots; renamed from cacert.pem in Precursor 0d to avoid the path collision with cert_bootstrap.lua's inbound TLS cafile) |
| Missing cafile behaviour | Fail-closed - request is rejected with a clear error rather than silently falling back to unauthenticated |
| First-boot / upgrade | cacert_bootstrap.lua copies the bundled file from lib/luadch/ca-bundle.pem (immutable system path) into the operator-facing path on first boot; SHA-256 mismatch logs WARN and leaves the operator-managed copy alone unless ca_bundle_auto_update = true |
| Operator opt-out | Pass verify="none" explicitly per call |
| Response size cap | RAM mode caps the whole response at 1 MiB. Stream-to-disk mode (download_to_file, Precursor 0a of the #78 arc, for large external feeds) caps the body at 50 MiB and the response headers separately at 64 KiB, so a server that never terminates its headers cannot grow the RAM buffer without limit. Integrity of the on-disk feed: only a 2xx response is written; a Transfer-Encoding: chunked response is rejected (not de-chunked); a Content-Length short read is treated as a truncation and discarded; the body streams to <path>.tmp and replaces <path> only after a complete, in-cap download (atomic rename on POSIX; a move-aside via <path>.bak with rollback on Windows keeps the last good file if the swap fails). A partial, non-2xx, or corrupt download therefore never replaces the last good file, and concurrent downloads to the same path are refused. The module owns the .tmp / .bak siblings. A close-delimited response (no Content-Length, no chunked) cannot have its completeness verified and is committed best-effort - point download-mode feeds at a Content-Length endpoint. The caller owns the target path (same trust boundary as the URL - never derive it from untrusted input). |
| Redirect following | Opt-in via max_redirects (default 0 = a 3xx is returned/failed as-is, unchanged behaviour). Needed because MaxMind's GeoLite2 download endpoint 302-redirects to a signed Cloudflare-R2 URL on a different host. Guards on every hop: Authorization / Cookie / Proxy-Authorization are dropped when the redirect crosses origin (scheme/host/port) so a credential never reaches the third-party host; an https -> http downgrade is refused; the target is re-validated by parse_url; hops are capped (max_redirects, hard ceiling MAX_REDIRECT_CEIL = 10) and share ONE req.timeout. Only those three header names are stripped - a credential in a custom header (e.g. X-Api-Key) would survive a cross-origin hop, so callers must not combine one with max_redirects. |
The bundle's provenance, license, refresh recipe, and threat model
live in docs/CACERT.md. Refresh quarterly or when
the Mozilla NSS root store publishes a new release.
Redirect trust boundary (SSRF). Following a redirect widens the
outbound trust boundary: the initial URL is caller-supplied (operator
cfg), but the redirect target is chosen by the server that answers.
A compromised or malicious upstream can therefore point the hub at an
arbitrary host. This is bounded by the opt-in default, the hop cap, the
https-only + no-downgrade rules, and the verify="peer" default (an
https:// internal target would still need a publicly-trusted cert),
but there is no RFC1918/link-local denylist - so max_redirects must
only be enabled for operator-configured endpoints, never for a URL
derived from untrusted (ADC-client) input. Same rule as the base
http_client SSRF note.
The TLS protocol floor is held at TLS 1.2 (SSLv3 / TLS 1.0 / TLS
1.1 disabled via LuaSec options) for outbound calls regardless of
verify mode, so a downgrade attack cannot land on a broken
protocol even when verify="none" is the operator's choice.
luadch bundles all native dependencies as source. Operators should subscribe to upstream releases:
- Lua - currently 5.4.8
- LuaSec - currently 1.3.2
- LuaSocket - currently 3.1.0
- aiq/basexx - vendored from v0.4.1, upstream essentially abandoned
- OpenSSL - linked dynamically;
find_package(OpenSSL 3.0 REQUIRED)enforces the floor - zlib - linked dynamically;
find_package(ZLIB REQUIRED). Used by thezlib_streamADC-EXT ZLIF binding (Phase 8 S4b); only matters at runtime whenzlif_enabled = true
Quarterly checklist: query
osv.dev and the GitHub Advisory Database for each
of the above. Record the bundled SHA / version of every dep in
docs/BUILDING.md so audits compare to a written
truth, not to greps.
Open a private security advisory at https://github.com/luadch-ng/luadch/security/advisories/new rather than a public issue, especially for issues that:
- enable RCE without prior authentication
- bypass auth or login throttling
- leak
master.key, plaintext credentials, or TLS keys
Public issues are fine for findings that are already documented in
docs/phases/PHASE_7_FINDINGS.md or
that require operator misconfiguration to exploit (e.g. a
world-readable master.key because the operator skipped §4).
| Phase | Scope | Doc |
|---|---|---|
| Phase 7a | Read-only audit of every surface listed in CLAUDE.md §5 Phase 7 |
docs/phases/PHASE_7_FINDINGS.md |
| Phase 7b - 7g | Each finding either fixed or filed with a documented disposition | docs/phases/PHASE_7_FINDINGS.md §5 |
A future phase may re-audit. Until then, this file plus
PHASE_7_FINDINGS.md is the security baseline for v3.0.x.