Back to blog

A Guestbook, a Flask API, and Three OpenBSD rc.d Bugs

Building a guestbook meant real security decisions and three genuinely obscure OpenBSD rc.d bugs along the way. Here's the full stack, start to finish.

Post

Introduction

I wanted a guestbook. A real one, the old-net kind, where someone leaves a name and a line of text and it just stays there. Simple in concept. The build turned into a small full-stack project with a security-conscious backend, and getting the actual service running on OpenBSD taught me more about rc.d than I expected to learn from something this small.

The Stack

Static frontend, no framework, just raw JS for interactive and dynamic elements, matching the rest of the site it lives on. A small Flask API behind it, SQLite for storage, gunicorn serving it, all kept deliberately outside the actual webroot with its own dedicated system user. Nothing here needed to be complicated, so I kept it from becoming complicated.

Security, Kept Proportionate

This isn't a high-value target. It's a personal guestbook. But "low stakes" isn't an excuse to skip the fundamentals, so here's what's actually in place.

A password gate, borrowed from an existing easter egg. The site already had a string hidden somewhere on the site, meant for anyone curious enough to inspect the page. I reused it as a lightweight submission gate. I should be forthcoming about what this actually does: it's obscurity, not real security. It stops generic spam bots that fill and submit every form they find, since none of them are going to read HTML source looking for a hidden fragment. It would do nothing against someone specifically targeting the site. For this threat model, that's a reasonable trade.

A strict character allowlist. Name and message fields are validated against the regex ^[a-z0-9\s]{1,300}$ server-side, rejecting anything outside lowercase letters, digits, and whitespace. Tested this directly against both SQL injection and XSS payloads sent straight to the API, bypassing the frontend entirely. Both were rejected identically to how the form would reject them, since the validation lives entirely server-side and doesn't care what client sent the request.

Parameterized queries are the actual defense. The regex is a second layer, not the first. Every SQLite query uses parameter placeholders rather than string concatenation, which is what genuinely prevents injection regardless of what slips past validation.

Hashed IPs, not stored plaintext. Rate limiting needs to track submitters somehow, but there's no reason to keep raw IP addresses sitting in a database indefinitely. Each one gets SHA-256 hashed with a salt before storage.

One submission per IP per day. Tight enough to keep it a genuine guestbook rather than a chat log, loose enough that a real visitor can actually sign it.

A reserved names list. Nobody gets to sign as the site's own handle, or as "unknown," or "null," the values the rest of the site already uses for its own unidentified-visitor states. Keeps a clear line between the site's own vocabulary and whatever a visitor writes.

Where It Got Interesting

The application itself came together quickly. Getting it running reliably as a proper OpenBSD service is where the real debugging happened, and where I learned something genuinely useful about a gap in rc.subr's documentation.

Bug one: an empty rcexec. OpenBSD's rc.subr is supposed to default rcexec based on daemon_user, dropping privileges appropriately when a service isn't meant to run as root. Mine came back empty. Confirmed it by echoing the variable directly inside rc_start before the actual exec call, watched it print nothing, and set it explicitly instead of relying on the default.

Bug two: a permission error nobody would expect. Gunicorn tried to write a control socket to /var/empty/.gunicorn and failed. /var/empty is the standard, intentionally immutable home directory OpenBSD assigns to system accounts created without a real home, exactly the account I'd set up for this service. Fixed by explicitly setting HOME to the actual working directory before gunicorn ran.

Bug three: the one that actually taught me something. rcctl check and rcctl stop both silently failed to find the running process, even though it was clearly alive. The service runs as a Python daemon, which means the actual process, as the OS sees it, is python3 /path/to/gunicorn ..., not gunicorn .... rc.subr's internal process check doesn't match against the full command line by default, only the bare process name. Manually running pgrep -f against the full command line found it instantly. The same search without -f found nothing, which is exactly what rc.subr does internally. Any interpreted-language daemon, Python, Ruby, anything invoked through a shebang, hits this same gap, and it isn't documented anywhere obvious.

The actual fix dropped pattern matching entirely. Gunicorn writes its own PID file via --pid, and the rc.d scripts rc_check and rc_stop read that file directly, using kill -0 to test whether the process is still alive. Signal 0 doesn't actually signal anything; it just checks whether a process with that PID exists and whether you have permission to touch it. Faster too, since there's no more failed retry loop happening internally before rc.subr gives up.

Testing It For Real

Writing input validation is one thing. Actually trying to break it is another, so once it was deployed, I did.

A full port sweep against the VPS came back clean, only 80 and 443 visible, nothing leaking from the API's internal port. Checking the response headers directly turned up one real finding: Server: gunicorn was leaking straight through Caddy's proxy, quietly disclosing the backend framework to anyone who checked. Small thing, easy fix, stripped it at the proxy level with header_down -Server.

Then the actual attempts. Sending SELECT * FROM entries as a message got rejected outright, the asterisk alone fails the character allowlist. More interesting was sending drop table entries, three lowercase words and a space, nothing the regex has any reason to reject. It went through. It got stored. And it sat there afterward as an inert string in a text column, because parameterized queries don't care what English words happen to be inside the data they're storing. The table was still there. The guestbook still worked. I deleted the entry a minute later, mostly so it didn't confuse the next person who signed it.

None of this is a real pentest, and nobody's actually targeting a personal guestbook. But there's a difference between assuming something works and trying to break it yourself, and only one of those is worth calling secure.

Wrapping Up

Small project, real full stack: a frontend with no framework, a backend with real input validation and injection protection, a properly isolated systems deployment, and three genuine, non-obvious debugging problems along the way. Worth documenting for the same reason the printer assessment was, not because any of it was dramatic, but because working through it properly is where the actual learning happens.

If you want to see the guestbook itself, it's live at zer0rae.dev/guestbook.html. Signing it requires finding something hidden on the site first. Consider that part of the fun.

Sources

  1. rc.subr(8) - OpenBSD manual pages
  2. pgrep(1) - OpenBSD manual pages
  3. Flask documentation
  4. SQLite documentation