Dev.to Security ๐Ÿ” Cybersecurity ๐Ÿ‘ 0 ๐Ÿ“– 8 min read

A static blog with the fediverse as its comment system

I wanted a blog with comments and without a comment system. Comment forms attract spam the moment a page is indexed; a comment database is one more thing to back up and patch; third-party widgets bring tracking. Meanwhil

I wanted a blog with comments and without a comment system. Comment forms attract spam the moment a page is indexed; a comment database is one more thing to back up and patch; third-party widgets bring tracking. Meanwhile I already run a single-user Mastodon instance where the conversation would happen anyway.

So the design: publish on my own site, announce on the fediverse, and let the announcement be the comment thread. The post page fetches the replies to its own toot (and its Bluesky post) and shows them underneath. Readers reply where they already have an account; I moderate with the tools those networks already have. This post is how it is built and what went wrong on the way.

The site

Hugo with the PaperMod theme, markdown posts in a git repository on a box at home. The output is plain files served by nginx in a small, isolated container that only the reverse proxy can reach. Nothing dynamic. GET and HEAD only; everything else is a 405.

Publishing is a git push. A cron job on a build box fetches the repository every five minutes and, when the commit changed, builds twice: the public site, and a preview that includes drafts and scheduled posts behind basic auth. The public build goes through a redaction lint (below) and is then copied into the web container atomically: unpack next to the live directory, swap, delete the old one.

Scheduling is free: a post with a future date and draft: false is skipped by Hugo until the time has passed. The build job keys its "anything changed?" check on the commit plus the list of still-future posts, so the first run after the time rebuilds, publishes and announces.

One more gate, added after a near miss: a scheduled post only goes out if its reviewed content has been approved, recorded as a hash of the file. Edit the post after approving it and the build holds it, with a line in the log, until someone approves the new version. The first post missed its own slot exactly like that: three small edits after the morning approval, and 15:00 came and went. Annoying for ten minutes, and exactly what the gate is for.

The announcement

After each deploy, a script reads the site's RSS feed. For every post it has not announced yet:

  1. Mastodon: one public toot from my account: title, summary, link, up to eight hashtags from the post's tags. The toot's id is saved against the post's path. An Idempotency-Key makes a retry safe.
  2. Bluesky: a post from my account there, with a link card, the social-card image uploaded as the thumbnail, and tag facets.
  3. Lemmy: if the post's front matter names a community, the toot mentions it, and Lemmy turns that into a post there. Replies federate back as replies to the toot. One catch: the mention only resolves if my instance has seen that community before. The first time it had not, the mention stayed plain text and no Lemmy post appeared, until I looked the community up once in the web UI and edited the toot; Lemmy then created the post from the edit within seconds. The publisher now asks the instance to look the community up itself before it posts, which needs a token that is allowed to search. Since then it just works.
  4. Search engines: an IndexNow ping with the new URL.
  5. dev.to: the full article via its API with a canonical link back here. Three things its API does not tell you: it refuses the default Python user agent outright ("Forbidden Bots"), it renders every single newline as a line break, so hard-wrapped markdown arrives as a poem, and it drops SVG images. The publisher now sends a descriptive user agent, unwraps paragraphs, swaps an SVG for its PNG twin when one exists, and picks the four tags from the post that dev.to readers actually follow.

The saved ids go into a syndication.json on the site. That file is the only state the comments need.

The comments

A small script on the post page reads syndication.json, finds the toot id for the current path, and calls the Mastodon instance's public context endpoint for that status. No token: public statuses and their replies are public. Bluesky's public API gives the same for the post thread.

The rendering rules are the security story:

  • Everything is inserted as text. Reply HTML from Mastodon is converted to plain text (paragraph breaks kept); Bluesky gives text already. Nothing from either API is ever parsed as HTML on my page.
  • Links in replies become plain anchors with rel="nofollow ugc noopener noreferrer".
  • Avatars load only from the two known hosts, as small thumbnails; a missing one is dropped instead of showing a broken image.
  • Only public and unlisted replies; at most 200.
  • The page sends nothing anywhere. Reading is the only direction.

Moderation is zero extra work: block or mute an account on Mastodon and its replies disappear from the API response, and therefore from the page.

The headers

Because nothing on the page is dynamic, the Content Security Policy can be strict: default-src 'none', scripts and styles from the site only (the theme needs inline for its dark-mode toggle), connect-src limited to the Mastodon instance and Bluesky's public API, img-src the same two hosts plus self, no framing, no forms, no base tag. Add nosniff, a referrer policy, cross-origin isolation headers, rate limits per client, and an access log with the last part of every address removed. No cookies, so no banner.

The redaction lint

The biggest risk on a blog about your own network is you. Project notes are full of internal addresses, hostnames, identifiers and the odd token. So the public build is grepped before every deploy for private IP ranges, the public IP, MAC addresses, the street and the town, phone numbers, e-mail aliases, anything that looks like a bearer token or API key, serial numbers, internal hostnames, and names of things I have decided never to mention. One hit and the deploy stops with a notification. An allow-list handles deliberate mentions. It has already caught a home-page title.

Two bugs a real browser found

Before the first post I ran the preview through headless Chrome on a VM, with the comments pointed at a test thread. Unit-level checks had all passed. The browser found two things that would have shipped:

  1. The preview never showed comments. The page's lookup key kept the /preview prefix, so it never matched the keys in syndication.json. Trivial, invisible without a browser.
  2. Nobody at home would ever see comments. My other public hostnames resolve to the reverse proxy's LAN address when you are on the LAN (split DNS); the new blog name did not yet. To Chrome the blog was therefore a public page trying to read a private address, and its private-network-access rule blocked the request. Visitors on the internet were fine; the author was not. One DNS override fixed it, and "every public name gets the override" is now a rule.

That check is now part of publishing: load the draft in Chrome, collect console errors, failed requests, broken images and links, verify the comments widget, take phone and desktop screenshots, and put it all on a review page behind the preview login.

The bug a browser could not find

The announcement toot for the first post went out fine: text, hashtags, link. No link card. Bluesky's card was there, every other crawler had fetched the page, and the Mastodon instance (mine) had not even tried. Nothing in its logs, no failed job, no retry.

The cause was the fix from bug 2. Split DNS makes the blog's name resolve to the reverse proxy's LAN address inside the house, and the Mastodon server lives inside the house. Mastodon's HTTP client refuses to fetch private addresses (a sensible guard against server-side request forgery), and the link-card service swallows that refusal silently. So every link to one of my own sites, posted from my own instance, would never get a card. The clean fix was not to loosen the guard but to give the Mastodon containers public DNS: my names then resolve to the public address and the fetch loops back in through the firewall, like any outside visitor. Running FetchLinkCardService by hand on the toot produced the card a second later.

Lesson 2 therefore comes with a footnote: split DNS is right for the clients, and wrong for any service on the LAN that is supposed to behave like a stranger.

After the first post

  • Replies are fetched every ten minutes and each new one arrives as a push notification with its text and a link to answer it. Answering within the hour instead of the next morning makes the fediverse feel like a comment system.
  • Where the readers came from: the Lemmy community mention did more than everything else combined. Within an hour of the post, a few hundred fediverse servers had fetched the page for their link previews, almost all of them subscribers of that community. Page views were a fraction of that, in the low hundreds on the first day and two thirds of them within two hours of the announcement; previews are reach, not readers.
  • The comments came in the way I had hoped. The first one was from an account that describes itself as an AI agent; it asked a precise question about the matching rule and followed up with a sharper one. It is a public thread, so I answered it like any other reader. A few days later a human reader on another Mastodon server asked what the weekly spot-check actually catches, and in passing described their own convention for records they are not sure about yet, which I am borrowing. Two networks, two conversations, one page showing both.
  • Identity: every page carries a rel="me" link to my Mastodon profile, which gives the profile its verified tick, and each post names my account as its author (fediverse:creator), so shared links show "More from" on servers that support it.

What I would tell someone starting the same thing

  • Static plus "comments live elsewhere" is a very comfortable place to be. The attack surface is a web server serving files.
  • The toot id is the only state. Save it before anything else happens; never re-announce.
  • Render third-party content as text. Always.
  • Put your own address names behind split DNS consistently, or Chrome will teach you about private network access. Then give the services that must look like outsiders (your fediverse server) public DNS, or they will silently refuse to fetch your own pages.
  • Lint the output for your own secrets. You will leak something otherwise.
  • Test in a real browser from the network your readers are not on: your own.

Tools: Hugo, PaperMod, nginx, Nginx Proxy Manager, Mastodon, Lemmy, Bluesky's AT Protocol API, dev.to's API, IndexNow, Playwright with Chrome, Python 3, cron. The scripts are specific to this setup and not published; ask in the comments if you want more detail on a step.

๐Ÿ“ฐ Read the original article on Dev.to Security

Originally published by Dev.to Security. Aggregated on AIWithGhost for educational purposes โ€” full credit and traffic to the original publisher.