BurzBlog

Security model

BurzBlog is a private writing tool for a few trusted authors. Readers never talk to it; they read the website your build produces.

What is protected, and how

Accounts. Passwords are hashed with Argon2id (19 MiB, 2 passes), at least 12 characters. At most two hashes run at once, so a burst of sign-ins cannot exhaust a Pi's memory. A sign-in that cannot get a slot within two seconds is answered 503 with Retry-After instead of queueing, so a flood of junk usernames cannot build an unbounded queue, and one client address may have at most four sign-ins in progress. That per-address cap uses the connecting address only (X-Forwarded-For is ignored on purpose), so it separates clients only when they connect directly; behind a reverse proxy everyone shares the proxy's address and a flood is shed as a whole. It is load shedding, never a lockout: real sign-ins work again as soon as the flood stops. Unknown usernames take as long as known ones, and nothing is shed by whether an account exists. Failed sign-ins are limited per username, counted before the password is checked so parallel guesses cannot slip past (see API); expired counters are pruned as sign-ins arrive. There is no self sign-up; accounts are created on the host with burzblog user add, or once, in the browser, by first-run setup (below). Every account is an admin or an author (next paragraph).

Roles. An admin can do everything. An author writes and publishes posts and looks after their own password, two-factor sign-in and tokens. The first account made in the browser by first-run setup is an admin, so are the accounts that existed before roles did (migration 8), and so is the first account burzblog user add makes; later ones are authors unless you pass --role admin. Roles change only on the host: burzblog user role <name> admin|author, which refuses to demote the last admin so someone can always manage the server. The role is read from the database on every request, so a change applies at once to open browser sessions and to tokens: a token has the role of the account that owns it. The check is on the server; the panel hides what an author cannot do, but that is only a courtesy. A request an author may not make is answered 403 with the error code role before the request body is read.

Action Route Author Admin
Read, create and edit posts, tags, revisions, previews GET/POST/PUT /api/posts..., GET /api/tags, POST /api/preview yes yes
Publish, unpublish, archive, restore, schedule POST /api/posts/{slug}/{action}, PUT .../markdown yes yes
Set or clear a tag's display label PUT/DELETE /api/tags/{tag}/label no yes
Remove a slug redirect DELETE /api/posts/{slug}/redirects/{from} yes yes
Upload and list files POST /api/media, GET /api/media yes yes
Delete a post DELETE /api/posts/{slug} no yes
Delete an uploaded file DELETE /api/media/{id} no yes
Own password and two-factor sign-in /api/account/... yes yes
Own write tokens: create, list, revoke POST/GET /api/tokens, DELETE /api/tokens/{id} yes, own only yes
Export tokens (website builds): create POST /api/tokens with scope: "export" no yes
See and revoke other accounts' tokens GET /api/tokens, DELETE /api/tokens/{id} no yes
Settings (site, feeds, deploy status) GET /api/settings no yes
Newer-version notice GET /api/updates no yes
Rebuild the website POST /api/deploy no yes
Backup download GET /api/backup.tar.gz no yes
Export download GET /api/export.tar.gz no yes
Audit log GET /api/audit, burzblog audit no yes
Read published content GET /api/content/... yes yes
Accounts, roles, passwords, 2fa-reset burzblog user ... on the host host only host only

Authors are not limited to their own posts: any author can edit and publish any post, which suits a few people writing for one blog. Because a token has its owner's role, an author's export token cannot be created and an author's token cannot download the export; the website build needs an export token made by an admin. The content API serves only published posts, so a token of either role may read it.

Audit log. Sign-ins and sensitive changes are appended to the audit_log table: when, who (the account, and the token when the request used one), what, what it was done to and the connecting address (X-Forwarded-For is ignored, as everywhere). Recorded: sign-in successes, and failures against an existing account (a wrong password, or a wrong two-factor code); first-run setup and accounts created on the host; two-factor turned on or off and recovery codes used; token created or revoked; password changes; role changes; publish, unpublish, archive, restore, schedule and unschedule (including a scheduled post the server publishes itself, as actor scheduler); post deletion; tag label changes (tag.label); slug redirect removal; file uploads and deletions; a manual website rebuild; and backup downloads; full snapshots made and restored on the host. Changes made on the host with burzblog user and burzblog snapshot are recorded as actor host. Failed sign-ins for names that do not exist are not recorded, so repeating them cannot grow the log; rate-limited attempts are not recorded either. A row is never updated or deleted: database triggers refuse both and ids are never reused, so the log only grows (an entry is roughly 150 bytes). Anyone with the database file can of course rewrite it; the log guards against accounts, not against the host. A failure to write an entry is logged and does not undo the action it describes. Entries never hold secrets: no passwords, session or API token secrets, the deploy hook URL, two-factor secrets or recovery codes. Targets are post slugs, account names and token names and ids. Admins read the log in the panel, with GET /api/audit and with burzblog audit on the host.

First-run setup. POST /api/setup is the one unauthenticated write, and it exists so a fresh install does not need a shell to get its first account. It is not a content route and is gated three ways: it only works while the store has zero accounts; it needs a one-time token that burzblog serve prints in its own log and nowhere else; and it needs the same Origin as sign-in. The token is 256 random bits (crypto/rand, base64url), held only as a SHA-256 digest in memory (a restart forgets it and prints a new one), valid for one hour, compared in constant time and consumed by the account it creates. It dies as soon as any account exists, including one made with burzblog user add while the server runs. The link carries it in the URL fragment, so it never reaches a proxy, an access log or a Referer header, and the panel removes it from the address bar at once. The account is created in a transaction that re-checks for zero accounts, so two concurrent requests with a valid token make exactly one account (the other gets 409). Hashing uses sign-in's two slots and per-address cap, and wrong tokens count against a per-address limit of ten per 15 minutes. A wrong, expired or used token gets the same 403. GET /api/setup answers {"needed": true|false} without sign-in; that reveals only whether any account exists, which the sign-in page's behavior would show anyway. Anyone who can read the service log (journalctl, docker logs) while setup is open can create the first account, so treat the log as sensitive until you have.

Two-factor sign-in (TOTP). An account can require a code from an authenticator app after its password (RFC 6238: HMAC-SHA1, 6 digits, 30-second steps, one step either side accepted). It is off until someone turns it on in Settings, where enrolling needs the current password and then one valid code before it takes effect. For an account that uses it, a correct password creates no session: the server answers {"second_factor": true, "challenge": ...}, and only POST /api/login/totp with the challenge and a code starts one. The challenge is 256 random bits, valid for five minutes, usable once, tied to the account that gave the password, and stored as a SHA-256 digest; asking for a new one replaces the old. Codes and passwords share the per-username limit (ten attempts per 15 minutes), and a right password does not reset the count until the code is right too, so knowing the password does not buy fresh guesses. A step that was accepted is remembered, so a code cannot be used twice, even inside its window, and an older step is refused after a newer one. Enrolment creates ten recovery codes (80 random bits each), shown once, accepted in the code field and deleted when used. They are stored as plain SHA-256 digests: unlike a password they are not guessable, so a slow hash adds nothing. The authenticator secret (160 bits) is stored as-is in the database, like every other secret there (BurzBlog has no key to encrypt it with), so the 0600 file and its backups need the same care as the password hashes; anyone who can read the file can generate codes. Turning it off needs the password and a code and ends every other session. A locked-out person is recovered on the host with burzblog user 2fa-reset <name>, which turns it off and ends their sessions. Write tokens from burzblog login are not touched by turning it on, off or resetting it: the token is the credential. A new burzblog login needs the code, so a stolen password alone cannot mint one.

Browser sessions. A random 256-bit session id in an HttpOnly, SameSite=Strict cookie, Secure and __Host- prefixed over HTTPS. Only its SHA-256 digest is stored. Every change also needs the session's CSRF token in a header and an Origin header equal to the configured origin; sign-in requires the origin too, so another site cannot sign a browser into a different account. Changing a password (in Settings or with burzblog user passwd) ends every other session and revokes the account's write tokens in the same transaction, so a credential minted by someone who knew the old password does not outlive the reset.

API tokens. bzb_ plus 256 random bits, shown once, stored as a SHA-256 digest. Write tokens (from burzblog login) can do what a signed-in user can. Export tokens can only read published content (the export and the GET /api/content/* routes, which show the same data); the full backup (/api/backup.tar.gz, drafts and archived posts included) needs a session or a write token. Both are listed in Settings with their last use and can be revoked there.

Export tokens are kept when a password changes: they cannot write, and revoking them would silently break the website build. Revoke one in Settings if it leaks. Outside the export and the content API an export token is refused with 403 scope, and GET /api/session treats it as anonymous, so it cannot read the username.

Content API. GET /api/content/* serves published posts as JSON, Markdown and sanitized HTML to a website. It needs a session or any token, so it is not an unauthenticated content route, and it adds no data the export does not already give an export token. Old slugs redirect (308) and are listed only while their post is published, so a redirect is not a way to find unpublished posts. A draft, scheduled or archived post answers exactly like a missing slug, so the API cannot be used to learn which exist. The feeds and sitemap (feed.xml, feed.atom, feed.json, sitemap.xml) are part of it and of the export: the same published data behind the same gate, with links built from the operator's --site-url, never from request headers. Its responses are Cache-Control: private, max-age=60 with an ETag instead of no-store: the website fetches the same posts often, and revalidating with If-None-Match keeps that cheap on a small host. private keeps shared caches and proxies from storing a response meant for one authorized caller, Vary: Authorization, Cookie keys it to the credential, and the worst case is a post shown up to a minute after an edit or an unpublish. Errors (including the 404s) stay no-store, and the other security headers and the default-src 'none' policy still apply. The HTML route renders through the preview path, so it has the same input limits and render slots. CORS is not enabled: a website calls the API from its server or build, not from a visitor's browser.

The panel. Served with a Content Security Policy that allows only the panel's own scripts (inline bootstrap scripts by hash), no framing, no <base> and no plugins; plus nosniff, no-referrer, a restrictive Permissions-Policy, and HSTS over HTTPS. API responses are no-store and default-src 'none'.

Previews. Markdown is rendered without raw HTML and then sanitized with an allowlist (no scripts, event handlers, styles or javascript: URLs). The result is shown in a sandboxed iframe that cannot run scripts at all, with its own policy. Images in previews load from their https:// URLs; that is the author's own content.

Preview cost. Rendering is the one request whose cost depends on what is in it, and any session or write token can ask for it every 250 ms. So POST /api/preview is bounded in four ways. Before parsing, one linear pass refuses constructs whose cost grows faster than their size (very wide or numerous tables, deep nesting, huge numbers of lines or headings, paragraphs with thousands of unmatched emphasis, code or link markers); the limits are in the API reference. Output is cut off at 8 MiB. Only two renders run at once and a third waits at most 5 seconds before getting a 503. The body is still allowed up to the size of a post (2 MiB), so an author can preview anything they can save. The largest input these limits admit takes well under a second and allocates under 160 MiB in total (see internal/render/limits_test.go); a typical 2 MiB post takes about 0.15 s.

Input. Request bodies are capped at 4 MiB and JSON rejects unknown fields. Frontmatter must be a plain mapping: YAML anchors, aliases, merge keys (<<, !!merge) and any tag outside the YAML core schema (!!str, !!int, !!float, !!bool, !!null, !!timestamp, !!map, !!seq, !!binary) are refused at every nesting level, so exports parse the same way in every YAML reader. Extra frontmatter saved before these checks that still holds such syntax is left out of exports (the rest of the post is exported) until it is edited. Slugs are limited to lowercase letters, digits and hyphens, so they are always safe file names in exports. Titles, descriptions, cover URLs and alt text, token names and usernames are single-line text: they may not contain control characters (U+0000 to U+001F, U+007F, U+0080 to U+009F), tabs included, because terminals act on them. The body keeps tabs and line breaks and only refuses NUL, so code samples with escape sequences still save. Existing data is never rewritten: a post that already has such a character still loads, and saving it again asks you to fix the named field.

Uploaded files. POST /api/media is a change like any other (session with Origin and CSRF, or a write token; export tokens get 403). The body is capped at the upload limit (10 MiB by default) plus 1 MiB of form framing, and is held in memory, so the limit is also the most memory one upload takes. What a file is comes from its leading bytes (http.DetectContentType, plus a check for AVIF's ftyp box), never from the client's Content-Type or file name: only JPEG, PNG, GIF, WebP, AVIF and PDF are stored, and JPEG, PNG and GIF must decode, so HTML, scripts and archives renamed .png are refused. SVG is refused outright. It is an XML document that can carry script and external references, and an image served from the panel's own origin that runs script would act as the signed-in author. Sanitising SVG safely is a project of its own, so authors who need vector art export a PNG or host the SVG elsewhere. The stored name is <32 hex of SHA-256><extension>; the client's file name is kept only as a label and never reaches the file system, so path traversal has nothing to grip. Files live in media/ inside the data directory (0700, files 0600), behind a small media.Storage interface so another backend can replace the disk without touching the handlers. Uploads are not scanned for malware.

The one public file route. GET /media/{name} answers without a sign-in. This is an exception to "never add an unauthenticated content route", and is deliberate: images in published posts must load in readers' browsers and on the website, which cannot carry a session. The route serves immutable assets, not posts: it reveals nothing about drafts or about which post uses a file, only a file whose 128-bit hash name someone already has, and only what the author chose to upload. It does not list anything (listing is GET /api/media, authenticated). A file uploaded for a draft is reachable by anyone who learns its address, so do not upload what must stay private. The name is matched against a strict pattern before any file is opened, so no other file can be read. Responses carry X-Content-Type-Options: nosniff, the type derived from the extension, Content-Security-Policy: default-src 'none'; script-src https://umami.weburz.com; connect-src https://umami.weburz.com; sandbox, Cache-Control: public, max-age=31536000, immutable, an ETag (the name; content never changes under a name), Cross-Origin-Resource-Policy: cross-origin so other sites can embed the images, and Content-Disposition: inline for images but attachment for PDFs.

Terminal client. burzblog never follows an HTTP redirect, because it could carry your password or token to plain http:// or to another host; it stops and prints the address to run burzblog login with instead. Everything the server sends that is printed (titles, names, error messages) has control characters replaced with U+FFFD, and pull -o - does the same when writing to a terminal.

Scheduled posts. A scheduled draft publishes as it is at the due time, including edits made after scheduling, and still publishes if the session or token that scheduled it was revoked: revoking access does not cancel a schedule. Unschedule the post to stop it.

Data at rest. One SQLite file, created 0600 in a 0700 directory. Revisions keep every earlier version of every post; they are only removed when an archived post is deleted on purpose. Snapshots can be encrypted (see Snapshots); the live database is not.

Deploy hook. The URL is secret: it is read from the environment, never returned by the API, and left out of error messages.

Outbound requests. BurzBlog makes none of its own accord. The deploy hook is called only when you set one. Update checks are opt-in (--check-updates): one GET to the GitHub releases API with a User-Agent that holds the BurzBlog version, with a timeout and a response size cap, and nothing about posts, accounts or the instance address. The result is shown only to admins through GET /api/updates; failures never affect anything else. See self-hosting.

Snapshots

burzblog snapshot runs on the host only: there is no HTTP route that makes, downloads or restores one, so the API cannot hand out password hashes or two-factor secrets. Admins see only the time and file name of the newest snapshot.

A snapshot holds the whole database, password hashes and two-factor secrets included, so it needs the care the database does. It is written 0600 through a temporary file and renamed into place. Its working copy of the database goes in a 0700 folder in the data directory and is removed when the command ends, also on Ctrl-C or a service stop; one left by a killed run is removed by the next run a day later, as are half-written .partial files. A plain snapshot is not authenticated: anyone who can change the file controls the accounts it restores, so keep it where only you can write, or encrypt it.

An encrypted snapshot (BURZBLOG_SNAPSHOT_PASSPHRASE, --passphrase-file or --encrypt) starts with a header holding the scrypt cost (N = 2^15, r = 8, p = 1, about 32 MiB), a random 16-byte salt and a 16-byte check value. scrypt turns the passphrase and salt into a 256-bit key for this file only, plus the check value, which tells a wrong passphrase apart from a damaged file without decrypting anything. The content is sealed in 64 KiB chunks with ChaCha20-Poly1305; each chunk's nonce is its number with a flag on the last one, and the header is authenticated with every chunk. A changed, reordered, missing or extra chunk, a file cut short and an edited header all fail. A header asking for more than N = 2^16 with r = 8 and p = 1 (64 MiB, one pass) is refused before scrypt runs, so a crafted file cannot exhaust a small server's memory. Passphrases need at least 12 characters; they are read from the environment, a file or a terminal, never from a command line argument that other users could see. Nothing can restore an encrypted snapshot without its passphrase.

Restoring checks the whole snapshot before it changes anything: only a database, uploads with valid names and the manifest are accepted, each once, as regular files, so no entry can write outside the data directory; every size and SHA-256 checksum must match the manifest, the database must pass SQLite's integrity check, and its schema must not be newer than the binary's. A running server, and every host command that opens the database, holds a shared lock on <data>/burzblog.lock (on Linux, macOS and the BSDs); restore needs it exclusively, so it refuses while any of them runs, and they refuse to start during a restore. Restore keeps the lock until the restored database is upgraded and the restore is recorded. Replaced data is moved aside, never deleted. Restored files are synced to disk before they are renamed into place.

What is out of scope

  • Per-post ownership. Any author can edit, publish and archive any post; roles separate writing from running the server, not authors from each other.
  • TLS. Terminate HTTPS in a reverse proxy (see self-hosting). BurzBlog ignores X-Forwarded-* headers.
  • Quotas. There is no limit on the number or total size of uploads; every account is trusted, and the disk is the limit.
  • Untrusted authors. Authors control their post content, including image URLs in previews.

Reporting a vulnerability

Email the maintainers listed in the repository rather than opening a public issue.

Edit this page on GitHub