Security defaults & production hardening

This is the alpha. 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 the admin user, writes it plaintext to
    data/admin_password.txt (mode 0600), and prints it once at startup.
    No more admin/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 one UserManager.authenticate() call, so
    this closes it for all of them in one place, including SSH (whose
    own validate_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 checked User.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.
  • /auth/forgot and /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/register rate limit (3 attempts / hour / IP) — unlike the
    in-memory limits above, this one is backed by a DB table
    (RegistrationAttempt), not the shared rate_limit decorator. It
    persists across restarts — the in-memory-only caveat under Known
    limitations below does not apply to registration.
  • X-Forwarded-For is 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 config install.sh sets up), set
    TRUST_PROXY_HEADERS=true in .env so 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
    download route uses send_from_directory against the configured
    uploads dir.
  • Per-area file upload permission — each FileArea carries a
    upload_permission of users / sysop / none. The upload route
    enforces it before writing. The FTP server now enforces the same
    permission on DELE/RNFR/RNTO/MKD/RMD too, not just STOR
    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's qwk_packet_id field
    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
    escaped data/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 found reply_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 on subscribe(), react(), saved-message bookmarking, the
    /api/vote endpoints, @mention notifications, 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_KEY is the dev default and the app
    is started in production mode (FLASK_ENV=production or
    config_name='production'), it raises RuntimeError and refuses to
    boot. In development it just logs a loud warning.
  • Session cookie HttpOnly + SameSite=Lax by default. Production
    config also sets Secure (cookie only sent over HTTPS).
  • Optional virus scan on uploads if clamav-daemon is 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 shared evaluate_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.

What you MUST do for production

  1. Set SECRET_KEY. Generate once, set in your systemd unit's
    Environment= or EnvironmentFile=:
    python -c 'import secrets; print(secrets.token_urlsafe(48))'
  2. Change the admin password from the random one — and
    rm data/admin_password.txt once you've memorized / vaulted it.
  3. Front the web app with TLS. nginx + Let's Encrypt is the easy
    path. The deploy/anetbbs-nginx.conf.template is a starting point.
    Without a TLS terminator, every login posts plaintext over HTTP.
  4. Set FLASK_ENV=production so the prod config kicks in
    (Secure cookies, stricter SECRET_KEY check).
  5. 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 get connection refused.
  6. Run as a non-root user (the systemd templates already do —
    User=anetbbs). Use setcap or AmbientCapabilities for the
    privileged ports rather than running the whole process as root.
  7. 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 no N × workers bypass 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 (the rate_limit decorator
    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.
  • 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 .js doors without real jsexec get 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.