Releases: bulletinbored/bulletinbored-core
Release list
0.9.6
Release Notes
0.9.6
Added
- New
admin_sidebar_itemshook, emitted inside the Extensions group of the admin sidebar (views/admin_header.php). Plugins can use it to add their own menu entries by echoing<li>items that match the existing sidebar markup. The hook takes no arguments and only fires for administrators on admin pages.
Changed
- Repository installs now use the same pipeline as ZIP installs.
installFromRepo()for plugins and themes downloads the package into a staging directory, runs the same package validation (verifyExtractedPackage()/verifyInstalledFiles()), and only then commits it through the shared backup →recordInstalled→ lifecycle hooks → rollback flow. Reinstalling over an existing plugin also runsplugin_updated/on_updateand rolls back both files andinstalled.jsonon failure. UpdateManageraccepts an optional root directory override, so its extension update path can be exercised in isolated integration tests.
Fixed
- Repository install no longer removes the previously installed plugin/theme before the replacement has been downloaded, extracted and validated.
- The
file_get_contents()download fallback inrepo_install.phpnow setsverify_peer/verify_peer_nameexplicitly (and disallows self-signed certificates), matching the cURL branch instead of silently relying on PHP defaults. - Added integration tests covering
UpdateManager→PluginManager/ThemeManagerdelegation, version tracking and rollback whenon_updatefails or a theme package is invalid. - Added an end-to-end
installFromRepo()test (local fixture, no network) that reinstalls a plugin over an existing one with a failingon_update()and asserts the previous files,manifest.jsonandinstalled.jsonrecord are restored with no leftover backup or staging directory.PluginManager::fetchRepoPackage()is a small protected seam so this path is testable without git or a remote.
0.9.5
Release Notes
0.9.5
Discussion navigation & scroll behaviour
- Opening a discussion by its title now consistently lands on the opening post (page 1). Previously the viewport could sometimes jump to the bottom: the editbored reply editor auto-focuses on load and dragged the page down, and the corrective scroll used
behavior: 'auto', which followshtml { scroll-behavior: smooth }and therefore animated back to the top (visible down-then-up flicker). The thread view now neutralises the editor's focus-induced scroll and pins to the top with a genuinely instant scroll; anchor navigation is left intact. (views/thread.php) - The "last activity" excerpt link in the discussion list now reliably reaches the last post. It derives the correct
post_pagefromceil(reply_count / POSTS_PER_PAGE)before appending the#post-<id>anchor, so it no longer depends on the last post happening to be on page 1. (views/partials/thread_list.php) - Single source of truth for the posts-per-page size. Added a
POSTS_PER_PAGE(15) constant, now used by both the thread query and the pagination maths so they cannot drift apart. (src/helpers.php,src/actions/posts-thread.php)
Fixes
- Sidebar category counts no longer always show 0. The sidebar reads a
thread_countvalue thatsidebar_categories()never provided (SELECT * FROM categorieshas no such column), so every category displayed 0. The query now attaches a per-category count using the same visible statuses as the thread listing (visible,sticky,locked), so the number matches the category page. (src/Helpers/Data.php) render_site_name()no longer appliesucfirst()to the site name; the configured name is rendered as-is. Previously the capitalisation was skipped only for CJK names. (src/Helpers/Text.php)
0.9.4
Release Notes
0.9.4
Security/hardening, a consistent update pipeline, installer UX and test/doc housekeeping. No database schema changes beyond the idempotent hardening of the existing foreign-key migration.
Security & hardening
data/is now denied at the web root too. In addition todata/.htaccess(Apache),nginx.conf(location ^~ /data/),web.config(<hiddenSegments data>) androuter.php, the root.htaccessnow blocks/data/via bothRewriteRuleandRedirectMatch. Defense-in-depth so the protection no longer depends on a single subdirectory file.- Package names are validated. Plugin/theme names taken from external input (
expectedName,name) are checked against^[a-z0-9][a-z0-9_-]*$before they are used to build a path underplugins/orthemes/.
Repository / package installs are non-destructive
PluginManager::installFromRepo()andThemeManager::installFromRepo()no longer delete the existing installation before downloading. The current directory is moved to_old_<name>_<uniqid>, the new package is downloaded/extracted/validated, the backup is deleted on success, and restored on any failure (including a failed download or an invalid package).- Fixed
ThemeManager::installFromRepo()calling a non-existent$this->deleteDir()method on the failure/overwrite path.
Update Manager unified with the package managers
UpdateManager::applyExtensionUpdateFromZip()now delegates instead of running its own simplified pipeline:- plugin →
PluginManager::updateFromZip() - theme → new
ThemeManager::updateFromZip()
- plugin →
- As a result, the admin "Update All" button and manual updates share one path with the same backup, manifest/core/PHP constraint checks,
filesintegrity verification, lifecycle hook (plugin_updated/on_update) execution,installed.jsonhandling and rollback guarantees. applyUpdate()normalises the$typevalue (plugins/themes→ singular), so admin ZIP uploads for extensions no longer fall through to the core path.- The version recorded in
data/updates.jsonis now read from the updated package'smanifest.json; removed the olddetectVersionFromPackage()/syncVersionMetadata()helpers that rewrote package files. - Fixed a theme install/update bug: the verify callback returned
['success' => true, 'message' => 'ok'], whichPackageInstaller::install()treated as an abort — the staged package was deleted while the operation was reported as successful. The callback now maps success tonull.
MySQL foreign-key migration
migrations/20260904_add_foreign_keys.phpis now idempotent and self-healing: constraints that already exist are skipped (so a re-run completes a previous partial run), and if anALTER TABLEfails, every constraint created during that run is dropped before the error is rethrown.SET FOREIGN_KEY_CHECKS = 1is always restored infinally.down()no longer usesDROP FOREIGN KEY IF EXISTS(invalid on MySQL) and drops only constraints that actually exist.- Corrected the migration header comment, which previously over-claimed atomicity that MySQL DDL cannot provide.
Installer
- Added a filesystem preflight with actionable error messages instead of the generic "check the server error log": web root,
data/, and the SQLite database directory must be creatable/writable; a failedconfig.jsonwrite is now reported explicitly. PDOExceptionnow produces a distinct "Database error…" message.
Admin diagnostics
- The Diagnostics page now shows
display_errorsandexpose_php(with a warning when they are On) and adds a recommendation to turn them off in production.
Packaging
- Stopped tracking
plugins/helloboredas a gitlink: it was recorded in the index as a submodule (mode 160000) with no.gitmodules, so any clean checkout/export produced an emptyplugins/helloboreddirectory while the actual files were untracked. The stray entry was removed from the index (the plugin is distributed via its own repository/catalog, not bundled). - Aligned the
coreentry indata/catalog.jsonfrom0.2.4to0.9.4.
Plugin sort hooks
thread_sort_optionsandthread_order_byfilters insrc/Helpers/Data.php, letting plugins add and implement their own thread sort options (e.g. a "Votes" sort).
Tests
- New regression tests:
test_plugin_repo_install_preserves_existing_on_failuretest_theme_repo_install_preserves_existing_on_failuretest_theme_update_from_zip_replaces_and_cleans_backuptest_theme_update_from_zip_rolls_back_on_failuretest_plugin_lifecycle_on_update_runs_hook
tests/run.phpargument parsing is now portable on Windows (tests\run.phpis no longer treated as a filter).- Renamed the
harness.phpdoc example (test_foo→my_test) so it no longer looks like an unregistered test.
0.9.3
Release Notes
0.9.3
- Update Manager: added an "Update All" button to apply all available updates (plugins, themes and core) with a single click.
- Updates are applied sequentially with core last, so a core failure does not block already-updated plugins and themes.
0.9.2
0.9.1
0.9.0
Release Notes
Version: 0.9.0
Summary
A large hardening + correctness release. It adds rate limiting to nearly every state-changing action, closes several information-disclosure and authorization gaps, wires up mention/reply notifications (in-app + email), fixes the "latest posts" ordering bug, hardens the installer and the shipped server configurations, and reworks the test suite to be behavioural rather than static.
Highlights:
- Direct access to
config.json,config.php,bb.phpandrouter.phpis now blocked on Apache, Nginx, IIS and the PHP built-in server. - The installer can no longer be re-run, abused to write outside the app directory, or CSRF'd; its re-run guard no longer fatal-errors.
@mentionsnow create in-app notifications and send email; replies can also email recipients.- The "latest / last activity" listing is deterministic and no longer shows a stale or duplicated last post after a reply.
- File-based rate limiting extended from auth + posting to edits, deletes, uploads, profile/avatar changes, watch/unwatch and all admin operations.
Security Fixes
Direct file access (P0)
config.json/config.phpwere publicly readable. A plainGET /config.jsonreturned DB credentials, SMTP settings and update-server
configuration on Apache and Nginx. Now denied on every supported server:- Apache: new rewrite rule + defence-in-depth
<FilesMatch>in.htaccess. - Nginx: new
locationblock innginx.conf. - IIS: new rewrite rule in
web.config. - PHP built-in server:
router.phpnow only serves allow-listed static
asset extensions and refuses to expose secrets.
- Apache: new rewrite rule + defence-in-depth
CLI and dev entrypoints
bb.phpis CLI-only again. It now exits with HTTP 403 when reached over the web (PHP_SAPI !== 'cli'), instead of running with undefined$argv.router.phpno longer serves thedata/oruploads/private/directories in development, matching production.
Installer (install.php, install2.php, install3.php)
- Re-run guard fixed. The guard called
log_security_event(), which was never loaded in the installer context — an already-installed forum returned HTTP 500 instead of the intended 403, and theinstaller_access_deniedaudit event was never written. The installers now requiresrc/Security.php. is_installed()fails closed. It no longer returns "not installed" when the database is temporarily unreachable; the presence of aconfig.jsonwith adb_driveris authoritative, so a DB error can no longer re-open the installer.- CSRF protection added to all three installer steps (hidden token + validation on POST); previously the pre-auth installer forms had none.
- Arbitrary filesystem write removed. The
db_pathPOST value is now confined to the application directory before anymkdir()/SQLite file creation, closing a pre-authmkdir -p/file-create primitive. - Error output no longer leaks
PDOExceptionmessages; failures are logged server-side and show a generic message.
Content and downloads
Content-Dispositionheader injection fixed. The download handler no longer usesaddslashes()on the user-supplied filename; control characters (\x00-\x1F,\x7F, CR/LF) are stripped and an RFC 5987filename*=UTF-8''form is emitted.- Orphan uploads return 404 for everyone. Previously any authenticated user could download an upload that had no associated thread. Orphan uploads (and uploads whose thread cannot be viewed) now raise
NotFoundException, avoiding both access and existence disclosure.
Headers
- Added
Permissions-Policyon every response. - Added
Strict-Transport-Security(HSTS) — emitted only over a real HTTPS connection.
Admin API
api/install.phpwas non-functional (never loadedPluginManager/ThemeManager→ fatal "Class not found"). It now loads the managers and validates the user-suppliedtagagainst a safe character set.
Open redirect / host injection
handle_watch()/handle_unwatch()no longer redirect to$_SERVER['HTTP_REFERER']; redirects are deterministic (url('thread', ...)).- Absolute URL generation (
src/Helpers/Url.php) and the HTTPS redirect insrc/bootstrap.phpnow strip control characters and slashes from the Host header.
send_email()validatesmail_fromwithFILTER_VALIDATE_EMAILand uses the validated value in the SMTP envelope.- SMTP
STARTTLSnow fails loudly (connection closed,falsereturned) instead of silently continuing unencrypted when stream_socket_enable_crypto()` fails.
Rate Limiting (new coverage)
The dependency-free file-based rate limiter (rate_limit()) is now applied to state-changing endpoints that previously had none:
| Area | Actions |
|---|---|
| Content | edit_post, delete_post, edit_thread, delete_thread, watch, unwatch |
| User | upload_image, edit_profile, remove_avatar |
| Admin | settings (dashboard + SMTP), site image upload, catalog, categories (create/delete/reorder), languages, moderation (moderate/front-moderate/split/merge), plugins, themes, updates (check/apply), roles, user edit/create/delete/ban/unban/suspend |
Limits are per-user where a session exists, otherwise per-IP, and fail-closed for sensitive actions.
Notifications
- Mentions now notify.
notify_mentioned_users()was dead code (never called and it only emailed). It now:- creates an in-app
mentionnotification row, and - sends an email when the mentioned user has an address,
- excludes the author, and
- ignores
foo@bare-mail patterns (negative look-behind).
It is invoked from bothhandle_reply_post()andhandle_new_thread().
- creates an in-app
- Reply notifications can email recipients.
notify_thread_reply()still creates in-app rows for the thread author and all watchers, and now also emails them when an address is present. Disable with"email_notifications": falseinconfig.json(default: enabled). - Private-message (
pm) and vote (vote) notifications created by thetextmeboredandupdownboredplugins continue to work; the bell display already maps all types.
Bug Fixes
- "Latest posts" ordering.
fetch_threads()determined the last post withMAX(created_at), which could match several posts written within the same second, producing duplicate rows / a non-deterministic last author and leaving a just-replied thread in the wrong position. Replaced with a correlatedORDER BY created_at DESC, id DESC LIMIT 1join, and addedidtie-breakers to every sort order. - Undefined variable in
handle_watch()—$threadIdwas used in the CSRF-failure redirect before being computed. - Default avatars restored. The fallback avatar (initial in a coloured circle) was rendered with an inline
style="..."attribute, which the
tightenedstyle-src(no'unsafe-inline') blocked — leaving only the bare letter. It is now an inline<svg>(circle + text drawn with presentation attributes, unaffected bystyle-src). In addition, the CSP now allowsstyle-src-attr 'unsafe-inline'so the remaining legitimate inline style attributes (admin icon circles, conditionaldisplay:none, logo sizing) keep working, while injected<style>blocks and non-allow-listed stylesheets stay blocked. api/install.phpfatal (see above).- Upload authorization ordering.
handle_upload_image()now verifies the target thread/post exists and is viewable before moving the uploaded file, rejects mismatchedpost_id/thread_id, rate-limits uploads, and cleans up the file if the DB insert fails.
Database & Migrations
- MySQL foreign-key migration safety (
migrations/20260904_add_foreign_keys.php):SET FOREIGN_KEY_CHECKS = 0/1is now wrapped intry/finally, so FK checks are always restored even if anALTER TABLEfails. Note: MySQL DDL is still not transactional — a partial run must be re-run/reconciled. - SQLite rollback fixed:
DROP INDEX IF EXISTS idx ON table(invalid SQLite syntax) corrected toDROP INDEX IF EXISTS idx. Migrator::rollback()now acquires the migration lock before reading the last batch, removing a TOCTOU window between concurrent runners.- SQLite still does not enforce foreign keys at the DB level (by design); application-level checks and indexes remain in place.
Developer / Quality
- Typed PDO wrapper: added return types to
BbPdo::exec()/query()/prepare();HttpExceptionconstructor accepts a nullable
previous throwable (PHP 8.4 deprecation). - Test suite reworked to be behavioural.
tests/SecurityFixesTest.php,tests/UploadSecurityTest.php, endpoint/download tests now execute the real handlers and assert runtime outcomes instead of grepping source. New sharedtests/bootstrap.php, refreshedtests/harness.phpandtests/run.php. - CI: PHP 8.5 added to the SQLite, MySQL and MariaDB matrices; the redundant duplicate full-suite run in the DB jobs was removed.
- Documentation refreshed: route map/version references and the
README.mddev-server command; the former repo-localdocs/inventory was merged into the external documentation site (docs.bulletinbored.net). - Nginx config no longer blocks
install*.phpunconditionally (that prevented fresh installs); the installer now self-guards viaconfig.json. - Database regression tests for the two driver-sensitive queries:
test_thread_listing_latest_join(correlated last-post join, verifies one row per thread with tied timestamps) andtest_vote_upsert(atomic upsert with a unique index). Validated against SQLite and MariaDB 10.4 locally, and in CI. - Bootstrap integration tests (
tests/BootstrapIntegrationTest.php): a subprocess smoke test that boots the realsrc/bootstrap.php(session, UTC timezone, autoloader, i18n) and optional HTTP tests (PHP built-in server + `rou...
0.8.13
Release Notes - v0.8.13
Summary
Release v0.8.13 addresses critical security vulnerabilities in thread watch/unwatch operations and improves the test suite's reliability.
Changes
Critical Security Fix
Problem: Users could add themselves as watchers to hidden/pending threads they couldn't view, enabling information leakage about thread existence.
Root Cause: handle_watch() and handle_unwatch() performed database mutations before verifying thread visibility.
Solution: Added authorization checks before any database mutation.
Files Modified:
src/actions/posts-thread.phptests/EndpointAuthorizationTest.php
Changes in src/actions/posts-thread.php:
handle_watch(): Now selects thread status and callscan_view_thread()before INSERT. ThrowsForbiddenExceptionif user cannot view the thread.handle_unwatch(): Now selects thread status and callscan_view_thread()before DELETE. ThrowsForbiddenExceptionif user cannot view the thread.
Impact:
- Prevents information leakage about hidden/pending threads
- Prevents unauthorized users from watching hidden threads
- Prevents state mutation before authorization check
Test Suite Improvements
EndpointAuthorizationTest.php:
- Fixed tests to use valid CSRF tokens instead of invalid ones that short-circuited the handler before reaching authorization logic
- Tests now properly catch
ForbiddenExceptionto verify authorization failures - Added
test_endpoint_moderator_unwatch_hidden_thread()to cover moderator positive case for unwatch - Renamed helper functions for clarity:
setup_schema_endpoint(),create_user_endpoint(),create_post_endpoint()
Test Runner (tests/run.php):
- Fixed argument parsing so
--listand--verboseare not interpreted as test filters
Testing
All test files load and register correctly. The critical security fix ensures that hidden threads cannot be watched or unwatched by unauthorized users.
Affected Components
src/actions/posts-thread.php-handle_watch()andhandle_unwatch()functionstests/EndpointAuthorizationTest.php- Authorization matrix teststests/run.php- CLI test runner argument parsing
Backward Compatibility
- No breaking changes introduced
- Existing functionality preserved for visible/normal threads
- Only restricts access to hidden threads (which was already implicitly restricted by authorization logic)
Security Impact
- Prevents: Information disclosure of hidden threads via watch/unwatch operations
- Prevents: State mutation before authorization checks
- Maintains: All existing authorization rules for visible threads
0.8.12
0.8.11
Security Fixes
Fix #1: Attachments in hidden threads publicly accessible
- Upload attachments now stored in
uploads/private/instead ofuploads/ - New download endpoint
/download/{id}with authorization checks .htaccessinuploads/private/denies all direct access (Apache)nginx.confblocks/uploads/private/withlocation ^~directive (Nginx)- Files can only be accessed through the authorized download endpoint
- Affects:
src/actions/posts-thread.php,src/actions/content.php,nginx.conf
Fix #2: SMTP Header Injection
send_email()now validates email addresses withFILTER_VALIDATE_EMAIL- CRLF injection prevention: rejects email headers containing
\ror\n - Affected functions:
src/Helpers/Mail.php
Fix #3: Reply to hidden thread - Authorization bypass
- Added
can_view_thread()check inhandle_reply_post() - Users can no longer reply to hidden/pending threads
- Affects:
src/actions/posts-edit.php
Fix #4: Session invalidation on password change
- Added
session_versioncolumn to users table is_logged_in()now verifies session version matches- Password reset increments version, invalidating old sessions
- Important: Sessions created before this migration (lacking
session_version) are now invalidated for security - New migration:
20260904_add_session_version.php - Affects:
src/Helpers/AuthHelpers.php
Fix #5: Referrer-Policy too permissive
- Changed from
no-referrer-when-downgradetostrict-origin-when-cross-origin - Affects:
src/csp.php
Fix #6: XSS in edit_post.php
- Added
escape()call on textarea content - Affects:
views/edit_post.php
Fix #7: PluginManager hook processing
- Changed
continuetoreturn falsein hook callbacks - Ensures hooks don't accidentally skip subsequent handlers
- Affects:
lib/PluginManager.php
Fix #8: Rate limiter fail-closed for auth actions
- Rate limiter now fails closed (denies) for login/register actions
- Previously failed open, potentially allowing unlimited attempts
- Affects:
src/actions/users.php,src/Security.php
Fix #9: Migration race condition
getPending()called after acquiring lock, not before- Prevents race condition in concurrent migration scenarios
- Affects:
lib/Migrator.php
Fix #10: Database foreign keys
- Added foreign key constraints via migration for MySQL
- For SQLite: enables
PRAGMA foreign_keys = ONand creates indexes (SQLite cannot add FK constraints via ALTER TABLE - table recreation would be required) - Added orphan record integrity check before applying FK constraints
- Migration aborts with clear error message if orphans are found
- New migration:
20260904_add_foreign_keys.php
Fix #11: Host header URL generation
- Absolute URLs now use configured
base_urlinstead of$_SERVER['HTTP_HOST'] - Prevents host header injection via spoofed headers
- Affects:
src/Helpers/Url.php
Test Suite Improvements
New Test Framework (harness.php)
Assertions added:
assertThrows()- verifies callable throws an exceptionassertNotThrows()- verifies callable does NOT throwassertThat()- verifies with custom predicate
Test registration pattern:
Test files now use register_tests('test_foo', 'test_bar') instead of creating a local $suite and calling $suite->run(). This allows the test runner to load all test files and run them together.
New Test Files
tests/EmailSecurityTest.php- SMTP injection and email validation teststests/EndpointAuthorizationTest.php- HTTP endpoint authorization matrixtests/SessionSecurityTest.php- Session invalidation teststests/UploadSecurityTest.php- Upload security and direct access prevention
Converted Test Files (27 files)
The following test files were converted to the new register_tests() pattern:
tests/AuthTest.phptests/AuthHardeningTest.phptests/ContentCrudTest.phptests/ContentHardeningTest.phptests/DatabaseIntegrityTest.phptests/DatabaseMatrixTest.phptests/DbQueryTest.phptests/E2eFlowTest.phptests/E2eIntegrationTest.phptests/HelpersTest.phptests/InstallerTest.phptests/MarkdownTest.phptests/MigratorTest.phptests/ModerationHandlerTest.phptests/ModerationTest.phptests/PluginManagerTest.phptests/PluginRouterTest.phptests/PluginThemeTest.phptests/RegistrationTest.phptests/RendererTest.phptests/ResponseTest.phptests/SecurityHardeningTest.phptests/SecurityTest.phptests/SuggestedTest.phptests/UpdateFailureModeTest.phptests/UpdateManagerTest.phptests/UpgradeTest.php
Test Runner Improvements
tests/run.phpnow usesget_test_suite()global instead of local$suite- Added
--listflag to list registered tests without running - Test files no longer call
exit()- runner handles exit code
Database Migrations
Two new migrations added:
migrations/20260904_add_session_version.php- Addssession_versioncolumn to usersmigrations/20260904_add_foreign_keys.php- Adds foreign key constraints