Bludit Pro 3.22.0: A Production Stability Audit

I run a few sites on Bludit — a flat-file CMS that stores content as HTML files and configuration as JSON on disk. No database, no PHP-FPM gymnastics, no attack surface that scales with every plugin I install. It's a quiet platform, which is why I like it. There's a free version and a Pro build on Patreon — the patches in this article were applied to the Pro release, but every finding targets code that's shared between both.

But "quiet platform" and "production-ready" aren't the same thing. I didn't set out to audit Bludit. I was building themes and plugins for it, and the available documentation wasn't always lining up with what the code actually did. To figure out how things worked, I had to trace the request lifecycle myself — how a URL becomes a page, how authentication gets checked, how the scheduler fires, how the database handles concurrent writes. If something didn't sit right along the way, I dug deeper.

This is my accountability routine. I do it because the server I'm hardening with nftables and fail2ban deserves software that's been looked at with the same scrutiny I give my Nginx config. And I share these findings because other administrators running the same software deserve to know what I found — not as a cautionary tale, but as a reference. Here's what I changed. Here's why. If it applies to your install, use it. If it doesn't, at least you know what questions to ask.

These patches were applied to the BrownBear build of Bludit Pro 3.22.0 — the upstream release maintained by the Bludit developer — before it ever served a page on this domain. They've been running in production since May 2026.

A quick note on file paths: the Pro and free builds may have slightly different directory layouts, but the function names, class methods, and logic paths are identical where these patches apply. If a file path listed below doesn't match your install, search for the function name — the surrounding code will be the same.

I'll follow up with a separate developer reference covering the Bludit 3.22.0 API — hooks, helpers, page objects, and the template layer — all source-verified against this same build. The audit below is about stability. The reference will be about building on top of it.


What Was Found and Fixed

Eight issues surfaced during the review: one concerning, several that could break pages or corrupt data under the right conditions, and a few code-hygiene fixes that cost nothing and prevent edge cases from becoming real problems. I've grouped them by what they affect.


Content Protection

Predictable preview token — information disclosure

Files: bl-kernel/functions.php (function buildThePage()), bl-kernel/boot/init.php (constant DB_SITE)

Bludit's preview mechanism lets you view draft, scheduled, and autosave pages outside the admin panel via a token in the query string: ?preview=<token>. The token is generated by hashing the page's UUID with a secret key.

The problem: the secret key is the constant DB_SITE, which resolves to PATH_CONTENT . 'databases/site.php' — a file path that's identical across every default Bludit installation. It's not a secret. Anyone who knows or guesses your site's directory structure can compute valid preview tokens for any page whose UUID they can discover (UUIDs appear in sitemaps, RSS feeds, and the API).

An unauthenticated visitor who constructs the right ?preview= URL can read draft content, scheduled posts that haven't been published yet, and autosaves — all content that was explicitly marked private.

Fix: Replace DB_SITE with a per-installation random salt stored in the site configuration that's never exposed through any API or error message.

// bl-kernel/functions.php — buildThePage()
// Before:
hash_hmac('sha256', $page->uuid(), DB_SITE)

// After:
hash_hmac('sha256', $page->uuid(), $site->previewSalt())

The previewSalt value is generated once at installation and stored alongside the site's other configuration. It never appears in HTML output, API responses, or error messages. An attacker who knows the page UUID can't compute the token without also knowing this value.

Status: The Bludit developer accepted this finding and has merged a fix that will ship in an upcoming release. Once that release is available, this patch won't be necessary — the upstream code will handle it correctly. Until then, the manual fix above covers the gap.

Admin quick search leaks draft titles between users

File: bl-kernel/ajax/clippy.php

The dashboard quick-search endpoint (clippy.php) scans all pages — including drafts, autosaves, and scheduled posts — and returns titles and types to populate the search dropdown. It doesn't filter by user role.

A user logged in as an author (the restricted role, meant to only manage their own content) can see the titles of drafts owned by other users simply by typing matching terms into the quick-search box. The content itself isn't exposed — just the title — but draft titles are often descriptive and may reveal upcoming content, internal notes, or unpublished announcements.

Fix: Add a role check that mirrors the content controller's permission logic. In the loop that builds search results, add:

// bl-kernel/ajax/clippy.php — inside the results loop
if (checkRole(array('author'), false) && $page->username() !== $login->username()) {
    continue;
}

Authors only see their own content in quick-search results. Administrators and editors see everything, which is the expected behavior for those roles.


Request Integrity

Broken <title> tag on non-existent category and tag pages

File: bl-kernel/helpers/theme.class.php — method metaTagTitle()

When a visitor requests a tag or category URL that doesn't exist — /tag/nonexistent or /category/void — Bludit attempts to instantiate a Tag or Category object. The constructor throws an exception when the key isn't found. The catch block that handles this doesn't assign a fallback value to the $format variable, which remains uninitialized.

The code then passes $format directly to Text::replace(), triggering a PHP Undefined variable warning and producing an empty <title> tag. If display_errors is on, the warning itself appears in the page output — a bad look and a potential information leak about your server path.

Fix: Assign a fallback in the catch block:

// bl-kernel/helpers/theme.class.php — metaTagTitle()
} catch (Exception $e) {
    $format = $site->titleFormatHomepage();
}

Non-existent tag and category pages now get a proper <title> tag using the site's homepage title format instead of an empty string.

Missing argument in scheduler's afterPageCreate hook

File: bl-kernel/boot/rules/69.pages.php (scheduler block) — compare with bl-kernel/functions.php (createPage()) for correct usage

The scheduler auto-publishes scheduled pages on a cron beat and then fires the afterPageCreate hook so plugins can react — re-indexing content, sending notifications, updating caches. The problem: it fires the hook without any arguments.

When a page is created manually through the admin panel, the same hook receives an array containing the page key so plugins know exactly which page was created. The scheduler gives them nothing. Plugins that depend on the key will silently do nothing or throw errors, and the administrator won't know unless they're watching logs.

Fix: Collect newly published page keys during the scheduler loop and pass them to the hook:

// bl-kernel/boot/rules/69.pages.php — inside the scheduler loop
Theme::plugins('afterPageCreate', array($key));