Status: canonical. Updated 2026-09-04. Full audit:
../ROBUSTNESS_REVIEW.md(Phase 0-5, 43 findings, 61 Python + 54 PHP regression tests).
bbsengine6's security posture is documented in two layers:
../ROBUSTNESS_REVIEW.md) — 43
findings across Python runtime, Python security, the PHP web
layer, and the Python I/O / UI layer. Every finding is paired
with the file/line that triggered it, the fix that was applied,
and a regression test. This is the authoritative record.If a security detail is in dispute, the audit wins.
The Python half of the audit (Findings 2.1 – 2.9) covered nine classes of issue. Highlights:
$scrypt$n=<N>,r=<R>,p=<P>$<salt-b64>$<hash-b64> with
BBSENGINE_SCRYPT_N/_R/_P defaults (n=2**14, r=8, p=1) chosen
to stay under the sandbox's 64 MiB envelope. Verification uses
hmac.compare_digest. Legacy SHA-256 hashes are still
recognised on read. 10 regression tests in
py/tests/test_password_hash_scrypt.py.module.runcallback() no longer calls eval() (Finding
2.2). The dotted-callback path used eval(f"{modpath}.{fname}")
— replaced with explicit
importlib.import_module(...) + getattr(...). A hostile
configuration file could no longer execute arbitrary Python.SELECT ... FOR UPDATE (Finding
2.3). The old code did a balance read in Python, computed the new
balance, and wrote it back — a TOCTOU race that let two
concurrent transfers double-spend. The fix performs the
increment inside the locked statement
(balance = balance + ?).util.get_safe_path rewrites (Finding 2.9). The old
os.path.commonpath containment check could be bypassed when the
base was a relative path (sibling-prefix collision). The fix
resolves both sides with os.path.abspath(...) and asserts the
relative path doesn't start with ... 9 regression tests.Packet.decode bounds check (Finding 2.7). A peer could send
payload_len = 0xFFFFFFFF (4 GB) and the decoder would either
allocate gigabytes or raise OverflowError. Now rejects payloads
larger than MAX_PAYLOAD_SIZE and truncated payloads.blurb.py path containment + psycopg3 migration (Finding
2.6). Path is checked with pathlib.Path.resolve() +
Path.is_relative_to(). Approval now persists to the DB
(UPDATE engine.__blurb SET approved = TRUE).pgrole.py uses psycopg.sql.Identifier (Finding 2.5).
Schema and table names are no longer interpolated raw.verifyMemberFound whitelist (Finding 2.4). The column name
was interpolated raw into WHERE <column> = ?; now restricted to
moniker, email, loginid.folder.py returns DB connection to the pool (Finding 2.8).The full Python side is in
../ROBUSTNESS_REVIEW.md.
The PHP half of the audit (Findings 3.1 – 3.14) covered fourteen classes of issue. Highlights:
redact_secrets() everywhere (Finding 3.1). engine.php had
var_export($args, true) in traceback handlers, dumping
plaintext passwords from $_POST["password"] into logs and HTTP
responses. The new helper walks arrays and replaces any key
matching /password|passwd|repeat|secret|token|api[_-]?key|credential|hash/i
with "***". All var_export / echo_traceback calls now go
through it. 11 regression tests.PDOException to clients (Finding 3.2).
echo_traceback logs the full exception to syslog and emits a
generic error to the client — SQLSTATE codes and raw messages
are scrubbed.lifetime = 0 was deleted from the cookie on every request
(literal 0 made the browser expire it immediately). Now
time() + $expire.Secure auto-derived from $_SERVER['HTTPS'] and
HTTP_X_FORWARDED_PROTO (works behind reverse proxies).HttpOnly = true, SameSite = Lax.setcookie() (otherwise the rotation was lost on the
next request).^[A-Za-z0-9,\-]{1,128}$ before DB lookup.autoExecute safe WHERE clause (Finding 3.8). Old signature
accepted a raw $where string concatenated into the SQL. New
signature: autoExecute($dbh, $table, $data, $mode, ?string $where = null, array $whereParams = []). Empty $where is rejected for
UPDATE/DELETE; callers are forced to bind values.libmember.checkflag scalar fix (Finding 3.7). Old code
subscripted $stmt->fetchColumn() like an array and always
returned null — every flag check silently failed. Now fetches the
scalar.accesspost("add", ...) auth check (Finding 3.9). Unauthed
callers could insert rows. Now requires a matching member id
and feature flag.engine/router.php was
rendering user Markdown with raw HTML enabled (<script> from a
post would execute). Now $Parsedown->setSafeMode(true) +
setMarkupEscaped(true).$_SESSION and unset it but never called
session_destroy(); reachable via GET (CSRF). Now POST-only,
requires a CSRF token, calls session_destroy(), sets an
expired setcookie(...).smarty/modifier.linkurl preg_replace /e removed
(Finding 3.12). The /e modifier was removed in PHP 7 and is a
fatal error on PHP 8.4 — every page render broke. Now uses
preg_replace_callback. 11 regression tests across the NONE,
SIMPLE, GET, POST modes.php/folder.php safe path (Finding 3.13). User-supplied path
is now validated with bbsengine6\util\safe_path_web."now()"
was PostgreSQL-specific; replaced with
date("Y-m-d H:i:s", time()) in PHP.The full PHP side is in
../ROBUSTNESS_REVIEW.md.
The csrfCheckRequest() helper (php/util.php) is the single
entry point for state-changing requests. Default behavior is
backward-compatible (GET requests without a token are accepted).
For new endpoints, pass requireOnGet: true:
csrfCheckRequest(requireOnGet: true);
The consolidated CSRF doc — token format, helper usage, every endpoint that requires a token — lives at ./csrf/README.md.
The PHP session cookie is Secure, HttpOnly, SameSite=Lax. The
session id is format-checked before any DB lookup. Session
rotation on validation failure sends the new id back to the
client.
For the WebSocket / TUI auth flow (the auth → bank request
authorization model), see ./specs/auth-bank.md.
For the per-member PostgreSQL ident auth (every approved member
gets a l_<loginid> PG role), see ./specs/pg-ident-auth.md.
engine/router.php validates every user-supplied URI through
router_safe_path_web() (a thin wrapper around
\bbsengine6\util\safe_path_web() that no-ops to false when
the underlying helper isn't loaded) before any filesystem
access. A path traversal attempt returns 404. See
./ROUTER.md for the full handler chain.
The audit shipped 61 Python + 54 PHP regression tests pinning every
fix. The full test inventory is in
../ROBUSTNESS_REVIEW.md
and ../SPEC.md. Run them locally:
# Python unit tests (no DB)
cd py && python -m pytest tests/test_*.py -p no:cacheprovider -q
# PHP regression tests
cd .. && for f in tests/test_*.php; do php "$f" 2>&1 | tail -3; done
# Lint
cd py && python -m ruff check src/bbsengine6 --no-cache
Open an issue on the project tracker. For sensitive disclosures, contact the maintainers directly.