Security defaults & production hardening
The defaults are designed so a fresh install isn't trivially
compromised, but production deployment requires a few extra steps.
Read this whole file before exposing the BBS to the internet.
What's enabled by default
- Random initial admin password. First start generates a 16-byte
URL-safe token, hashes it for theadminuser, writes it plaintext to
data/admin_password.txt(mode0600), and prints it once at startup.
No moreadmin/admin123. Change it on first login. - CSRF on all WTForms POSTs. Every
FlaskForm-backed route gets a
CSRF token automatically. AJAX endpoints (/api/vote,/imsg/*)
read the token from a<meta name="csrf-token">tag injected into
every page. - Open-redirect guard on
/auth/login. The?next=parameter is
rejected unless it's a same-origin path (urlparse().netloc == '',
not protocol-relative, not Windows-path-tricks). - Rate limits (sliding window, in-memory):
/auth/login— sysop-configurable via Admin → IP Bans (default 10
attempts / 5 min / IP); exceeding it auto-bans the source IP
for a sysop-configurable duration (default 1 hour, 0 = permanent)- Telnet/SSH/rlogin/PETSCII login — the same IP-ban list and
auto-ban threshold above apply here too, not just the web login.
Found missing entirely in a full auth-security audit; every
terminal transport shares oneUserManager.authenticate()call, so
this closes it for all of them in one place, including SSH (whose
ownvalidate_password()always accepts by design, to capture the
password for the "client sent credentials with the connection"
convenience flow — it was never a real auth boundary on its own). - FTP login — same models, its own
ftp_login:<ip>bucket. Found
missing in the same audit's follow-up pass over the FTP server; the
FTP login path also only checkedUser.is_active, never
is_locked/is_verified(unlike every other login surface) — a
locked-out or not-yet-approved account could still fully
authenticate over FTP. Both fixed. - SSH public-key login attempts — same IP-ban/auto-ban check as
every other login path above, applied before a candidate key is
even evaluated against the database. /auth/forgotand/auth/forgot/verify(10 / 5 min / IP each) —
the security-question recovery flow. Also found and fixed in the
same audit: a wrong guess no longer lets the same question be
retried indefinitely (capped at 5 attempts per recovery session),
and the flow no longer reveals whether an account exists via its
redirect target — every submission lands on the same verify page,
with a random, unanswerable decoy question for an account that
doesn't exist (or has no security questions on file)./imsg/send— 30 messages / hour / user/api/vote— 60 votes / min / user/auth/registerrate limit (3 attempts / hour / IP) — unlike the
in-memory limits above, this one is backed by a DB table
(RegistrationAttempt), not the sharedrate_limitdecorator. It
persists across restarts — the in-memory-only caveat under Known
limitations below does not apply to registration.X-Forwarded-Foris untrusted by default. IP bans, country
blocking, and every rate limiter above key off the real
request.remote_addr, not a client-supplied header — found in a full
auth-security audit that the header was previously trusted
unconditionally, letting a direct connection spoof it to dodge a ban
or (worse) make the login auto-ban land on an arbitrary victim IP
instead of the attacker's own. If you run behind your own reverse
proxy (e.g. the nginx configinstall.shsets up), set
TRUST_PROXY_HEADERS=truein.envso the real visitor IP is used
instead of the proxy's own loopback address — only enable this if
Flask is never directly reachable from the internet, since it tells
the app to trust whatever the nearest hop claims.- Path traversal mitigated — uploads stored under UUID filenames; the
downloadroute usessend_from_directoryagainst the configured
uploads dir. - Per-area file upload permission — each
FileAreacarries a
upload_permissionofusers/sysop/none. The upload route
enforces it before writing. The FTP server now enforces the same
permission onDELE/RNFR/RNTO/MKD/RMDtoo, not justSTOR—
found in a full FTP-server audit that regular FTP users had a flat
read/write grant over their whole session, letting them delete/rename
inside areas they had no upload permission to. - FTP uploads are virus-scanned too, matching every web upload
route — found in the same audit that this was the one upload path
that skipped it entirely. - QWK node public-form path traversal closed. The public,
unauthenticated network-join application form'sqwk_packet_idfield
used to accept any 8-character string with no charset check, and that
value flows straight into the FTP server's per-node home directory
path once a sysop approves the request —"../../.."would have
escapeddata/qwk-hub/outward with full read/write/delete
permission. Now regex-validated at both the public form and the
approval handler (defense in depth). - Message-board access control now enforced on writes and interactions,
not just reads. A full audit foundreply_post()had no board-access
check at all (any authenticated user could reply into a restricted
board's thread by guessing a post_id), and the same missing-check
pattern onsubscribe(),react(), saved-message bookmarking, the
/api/voteendpoints,@mentionnotifications, and/sitemap.xml.
Terminal (telnet/SSH/rlogin/PETSCII) board posting also checked
neither the board's configured posting level nor a moderator-locked
thread at all. All now consistently gated the same way the read paths
already were. - SECRET_KEY guard. If
SECRET_KEYis the dev default and the app
is started in production mode (FLASK_ENV=productionor
config_name='production'), it raisesRuntimeErrorand refuses to
boot. In development it just logs a loud warning. - Session cookie
HttpOnly+SameSite=Laxby default.Secure
(cookie only sent over HTTPS) is not set automatically by
production mode — it defaults to off everywhere, HTTP-only installs
included, and only turns on when you explicitly set
SESSION_COOKIE_SECURE=truein.env(see "What you MUST do for
production" below). Deploying behind HTTPS without setting this
leaves the session cookie sendable over a plain HTTP connection too. - Optional virus scan on uploads if
clamav-daemonis installed —
infected files are deleted before the DB row is created. - Anonymous visitors default to access_level 0. Found and fixed in a
full auth-security audit: the sharedevaluate_access()gate (boards,
echomail areas, QWK, RSS, file areas — everything with a configurable
min_access_level) fell through to level 10 ("registered") for a
logged-out visitor instead of 0, silently granting anonymous access to
anything gated at the standard "registered users only" level. - Admin
lock_user()now refuses to lock the acting admin's own
account, matching the self-lock guardedit_user()/delete_user()/
toggle_ban()already had — found missing specifically fromlock_user()
in a security audit; an admin could otherwise lock themselves out with
no separate account to undo it from. - Door-game session ownership is now checked, not just presence.
An IDOR found in a security audit let a user interact with another
user's in-progress door-game session by guessing/supplying its id —
every session-scoped action now confirms the session actually belongs
to the requesting user first. - Private netmail no longer leaks via the public echomail RSS feed.
/feed/echomail.xmlused to apply only an area's ordinary
min_access_levelcheck, which a NETMAIL-tagged area (QWK-routed
1-on-1 private mail, not broadcast echomail) typically leaves
permissive — any visitor meeting that level could read any user's
private netmail subject/body/sender. NETMAIL-tagged areas are now
excluded from that feed entirely. See
16-rss-reader.md
for the full writeup of ANetBBS's outbound feeds. - Terminal-output escape-sequence sanitization, closed across ~8
surfaces in one audit pass. User-supplied text that ends up echoed
into another user's or the sysop's terminal — oneliners, MRC chat
identity fields, wall posts, echomail/netmail sender fields shown in
the terminal UI, the RSS reader and ebook reader's CP437 rendering,
ZMODEM upload filenames, PETSCII fields, and IRC nick/channel fields
— is now stripped of raw ANSI/terminal control sequences before
display, closing a recurring terminal-escape-injection bug class
found independently in each of those surfaces (a malicious sender
field could otherwise rewrite another user's screen, hide/spoof
text, or manipulate their terminal's title/clipboard depending on the
client). A CRLF-based real IRC command-injection variant of the same
bug class was also closed in the MRC↔IRC bridge specifically (a
crafted MRC-side message could otherwise inject a second raw IRC
command via an embedded CRLF). - Inbound rlogin handshake is now bounded. The rlogin listener used
to read the handshake's variable-length fields with no upper bound —
a connection that never sent the expected terminator could hold a
slot indefinitely, an unauthenticated denial-of-service. Now capped
at a sane maximum with a hard timeout. - Multinode chat's per-node message queue is now bounded. Found in
the same audit pass: a node that stopped reading its own chat output
(a stalled/hung client) let its queue grow without bound, an
in-process memory-exhaustion path. Now capped, with the oldest
buffered lines dropped once a node falls behind rather than the
queue growing forever. - BinkP echo-area subscriptions are isolated per downstream node,
as doc 6 — Echomail
already describes — a cross-tenant leak found in a security audit
let one hub-hosted node's AreaFix subscription change affect another
node's subscriptions under some conditions. Fixed to match the
per-node isolation the feature was always meant to have. - BinkP inbound file receiving is now bounded per session, not just
per file. A single anonymous, unauthenticated peer connection could
previously offer an unlimited number of files each just under the
existing per-file size cap, accumulating without any overall limit
for that session — an in-process memory-exhaustion path reachable
with zero authentication (an unrecognized peer is accepted as
ordinary anonymous crash-mail, per real FTN convention, before this
point is ever reached). Now capped by total session bytes as well. - FREQ (file request) rate-limiting is now keyed on the real
connecting IP, not a self-reported address field an anonymous peer
controls — closing a gap where the per-IP throttle could be trivially
bypassed by claiming a different address on every connection. FREQ
request parsing is also now bounded by request-line count, matching
every other peer-suppliable command parser in this codebase. - QWK network packet downloads are now size-capped, matching the
cap BinkP's own inbound file receiving already had — an admin-
configured QWK hub's response at poll time is still remote,
foreign data, and was previously buffered fully into memory with no
limit. - Disconnecting a door-game session now works correctly even when it's
running in a different ANetBBS process than the one you're viewing
it from. ANetBBS runs web, telnet, and SSH as separate systemd
services, each tracking its own live sessions in memory; every admin
surface that lists sessions to disconnect reads the shared database
instead, so a sysop routinely disconnects a session actually owned by
a different process. This used to silently fail to kill the real
subprocess while still marking the session finished and freeing its
node number for immediate reuse by a new session — found and fixed in
a security/performance audit; disconnecting now signals the real
process directly regardless of which service owns it. - Door-game per-node working-directory paths are now sanitized
against the same class of path-traversal issue found and fixed in
ANetCraft's own save-path handling — a sysop-templated per-node path
could otherwise be walked outside its intended directory via
unsanitized characters in a player's display name. - A background network-health thread now has the same start/stop
guard every sibling background thread in that package already had —
found missing from one specific module in a security/performance
audit; without it, re-initializing the app in one process could spawn
an extra, un-stoppable daemon thread with no way to tell the
duplicates apart. - Background polling loops (MRC bridge, SYSTAT responder) are now
protected against a misconfigured near-zero interval turning them
into a busy loop that hammers an upstream service, and a real N+1
query pattern in the SYSTAT responder (reachable by any
unauthenticated UDP peer) was closed so its per-request cost no
longer scales with the number of online users. - A maintenance tool no longer prints a live door password to its own
output, and a user-migration tool's report file (which intentionally
contains plaintext temporary passwords, by design, for the sysop to
relay to migrated users) is now created already-locked-down rather
than world-readable at birth — both found in a security/performance
audit.install.sh/update.shalso close a race window where the
generated sudoers fragment briefly sat world-readable before its
final permission lockdown, the same class of gap already fixed
elsewhere in those same scripts for.envand the database file. - Several unguarded, sysop- or peer-suppliable URL fields rendered as
clickable links have been hardened againstjavascript:-URI and
script-injection payloads across the profile, RSS, peer-directory,
ANSI/postcard editor, and IRC web-client pages — found in a security
audit; one of these was reachable by any registered user against a
reviewing sysop's own browser session. Fixed at render time with the
same scheme-allowlist pattern already used elsewhere in the codebase. - A revoked account's already-open web session now loses access
immediately, matching the existing behavior for a banned or locked
account — previously only checked at the moment of login. - Two data-integrity/performance gaps found in a security/performance
audit were closed: a missing foreign-key constraint on one
message-tracking column, and missing database indexes on two
columns queried on every leaderboard view and door-game launch
attempt, with an automatic backfill for existing installs. - Dependency vulnerability scanning now covers every requirements
file this project ships, not just the runtime one — closing a gap
where the Docker-image and developer-tooling dependency sets were
unaudited by CI. - The MRC web chat client now caps the size of an inbound message it
will parse, matching the same cap already applied server-side to
the BinkP network listener — the browser-side half of the same
unbounded-network-buffer class this project's audits have
repeatedly closed elsewhere. - The web app now sends standard hardening response headers on every
page —Content-Security-Policy,X-Frame-Options,
X-Content-Type-Options,Referrer-Policy, andPermissions-Policy
— found missing entirely in a security/performance audit. The public
"Watch It Live" embed page (/watch) is deliberately exempted from
the framing restriction, since its whole purpose is to be embeddable
off-site.Strict-Transport-Securityis sent only when a request
actually arrives over HTTPS, so it never locks out an install that
hasn't set up TLS yet. The CSP currently allows inline
scripts/styles ('unsafe-inline') rather than fully blocking them —
a real sweep found this in wide, legitimate use across the template
tree, and migrating every one of those to a nonce scheme is a much
larger, separate undertaking. Even with that allowance, the policy
still blocks a future XSS payload from pulling in a remote script or
exfiltrating to an unexpected origin, and restricts framing/object
embedding — real defense-in-depth over having no CSP at all, with
room to tighten further in a future round. - A few more real database/query gaps found in a systematic FK/index
audit were closed: one more missing index on a column filtered on
a routine per-user page view, plus an eager-loading fix for a file
gallery listing that was issuing one extra query per row instead of
a single joined query. - Response compression and static-asset cache headers were added
where they were missing — nginx-level gzip for the reference deploy
template, and a Flask-levelCache-Controlfallback for installs
running the app's own static file serving directly.
What you MUST do for production
- Set
SECRET_KEY. Generate once, set in your systemd unit's
Environment=orEnvironmentFile=:
python -c 'import secrets; print(secrets.token_urlsafe(48))' - Change the admin password from the random one — and
rm data/admin_password.txtonce you've memorized / vaulted it. - Front the web app with TLS. nginx + Let's Encrypt is the easy
path. Thedeploy/anetbbs-nginx.conf.templateis a starting point.
Without a TLS terminator, every login posts plaintext over HTTP. - Set
FLASK_ENV=productionso the prod config kicks in
(stricter SECRET_KEY check). Separately, also set
SESSION_COOKIE_SECURE=truein.envonce you're behind HTTPS —
production mode does not turn this on for you. - Pick one privileged-port option for MSP/SYSTAT (see
docs/INSTALL.md§6). Leaving the default ports unbound silently
degrades inter-BBS messaging — peers will getconnection refused. - Run as a non-root user (the systemd templates already do —
User=anetbbs). UsesetcaporAmbientCapabilitiesfor the
privileged ports rather than running the whole process as root. - Back up
data/— that's where the SQLite DB, uploads,
configuration, and admin-password-on-first-install all live.
Known limitations
- Rate limiter is in-memory and resets on restart. ANetBBS's web
app runs as a single eventlet process (deploy/serve.py—
socketio.run(), not gunicorn or any multi-worker WSGI server; see
docs/00-overview.md), so there's noN × workersbypass to worry
about. The real caveat: every process restart (deploy, crash,
systemctl restart anetbbs-web) zeroes all counters, and if you
ever ran more than one instance behind a load balancer — not a
supported or documented topology today — each instance would keep
its own independent counters. For durability across restarts,
replace with a Redis- or DB-backed store (therate_limitdecorator
is the only call site to change). This does not affect
/auth/register, which is already DB-backed and correctly shared
across restarts (see above). - No 2FA. Single password for both web and terminal logins.
- The site-wide Content-Security-Policy allows inline scripts and
styles ('unsafe-inline') rather than fully blocking them, since a
real sweep found both in wide, legitimate use across the template
tree. Migrating to a nonce-based CSP that blocks inline script/style
entirely is real future work, not done in this pass. - No per-account lockout — the auto-ban above works on the source
IP, not the targeted username, so a distributed brute-force attempt
from many IPs against one account isn't slowed by this mechanism. - Uploads are not sandboxed beyond ClamAV if you have it installed.
Don't run the BBS on a host where executable uploads matter. - Synchronet
.jsdoors without realjsexecget a Node.js
shim that is best-effort — a malicious door script can do anything
Node.js can. Same trust model as any door game: only install ones
you trust.
Reporting issues
Security reports: please email the sysop privately rather than open a
public issue.