LazyBlog is single-author. The threat model is "drive-by web attacker" + "someone scans the open internet". Not multi-tenant, not high-value-target. Defenses below are scoped to that.
.envis never committed. Web root ispublic/;.envlives one dir upcontent/is fully gitignored — posts stay on the host- Caddy +
Dockerfile.prodboth block/.env*,/Dockerfile,/docker-compose.yml, dotfiles → 404 - CommonMark
allow_unsafe_links: falseblocksjavascript:URLs - All variables from frontmatter or URL params reach views via
Http::e()(htmlspecialchars(ENT_QUOTES, 'UTF-8')) - Path traversal blocked: URL slugs are never used as filesystem paths.
PostRepository looks up entries via the index cache (closed set from
glob()) - Atomic writes: FileWriter uses
tempnam+LOCK_EX+rename. Crash mid-write leaves the previous file intact - RSS XML safety: FeedBuilder uses DOMDocument; never string-concatenates
html_input: 'allow' is set so the admonition placeholder bridge works.
This means raw HTML inside .md files is rendered as-is.
Acceptable because posts are author-only. If multi-author writing is added
later, switch to 'escape' and re-implement admonition reinjection on the
escaped form.
/admin/upload is a state-changing endpoint guarded by:
- Auth + CSRF:
Auth::requireAuth+Csrf::requireValid(token viaX-CSRF-Tokenheader — body is multipart, not form-encoded) - MIME whitelist via
finfomagic-byte check (PNG / JPEG / WebP only). Client-sentContent-Typeis ignored - Raw byte cap: 25 MB on the upload itself (
$_FILES['size']) - Pixel-count cap: 40 megapixels max before GD decode — bounds RAM usage and prevents decompression bombs
- Metadata strip: source is decoded into a GD truecolor buffer, then re-encoded as WebP. EXIF, GPS, ICC profile, and any vendor chunks are dropped because GD only writes the pixel data
- Filename is randomized (
bin2hex(random_bytes(8))) so uploads aren't guessable from a slug - Original never persisted: PHP's temp file is auto-cleaned at end of request; only the cleaned WebP lives on disk
- Caddy serves
/uploads/*directly fromcontent/uploads/— PHP isn't in the hot path, so an attacker can't trick the server into executing uploaded content as code (the dir contains only.webp) - Edge-layer extension blocks (in
Caddyfile.exampleand the installer's generated Caddyfile):- Any request whose path matches
*.php/*.phtml/*.phar(or*.phpNvariants) AND starts with/uploads/*,/posts/*,/plugins/*,/plugin-assets/*, or/content/*returns 403 at the edge. Defends against future code paths or operator misconfig that might accidentally expose a content-derived URL prefix - Any request to
/uploads/*whose extension is NOT one of.webp,.png,.jpg,.jpeg,.gif,.svg,.avifreturns 403. Even if the upload pipeline ever drops a non-image (it shouldn't), Caddy refuses to serve it
- Any request whose path matches
- CSRF: every state-changing POST (login, logout, save, delete) is gated
by
Csrf::requireValid()—random_bytes(32),hash_equalsverify - Open redirect:
safeRedirectTarget()validates?next=(must start with/, not//, no CRLF/tab/NUL).Http::redirect()strips control chars too - Session hardening:
session.use_strict_mode = 1,session.use_only_cookies = 1,HttpOnly,SameSite=Lax,SecurewhenSESSION_SECURE=true. Session ID regenerated on login. 500ms delay on failed password attempts - Preview DoS:
/admin/previewreads at most 256KB from the request body
Passwordless admin login with hardware keys (Yubikey, SoloKey) or platform
Passkeys (Touch ID, Face ID). Opt-in via WEBAUTHN=true in .env.
Quick summary:
- Phishing-resistant: assertions are bound to the Relying Party ID; a cloned login page at the wrong domain cannot harvest a working assertion
- No shared secret on the server: only public keys are stored, the credentials file leaking does not enable login
- Replay defense: signature counter verified monotonic on every login
- Password endpoint is hard-disabled server-side when
WEBAUTHN=trueAND ≥ 1 key is registered — leakingADMIN_PASSWORD_HASHdoes not let an attacker brute-force through/admin/login - Bootstrap fallback:
WEBAUTHN=truewith 0 keys still renders the password form so the operator can sign in to register their first key - Same per-IP throttle as password login (10 fails / 15 min,
TRUST_CF_CONNECTING_IP=trueto honour Cloudflare's edge header) - Body cap: WebAuthn endpoints reject request bodies over 64 KB
- Sanitised errors: lib internals stay out of HTTP responses (mapped to coarse buckets like "Malformed request" / "Replay detected"); full exception traces still land in the PHP error log for diagnosis
Full operator guide — installation, daily use, recovery, troubleshooting,
threat model — lives in docs/webauthn-passwordless-login.md.
Posts can be individually locked behind a bcrypt-hashed password. Unlock state is session-flagged, not cookie-based.
Storage and secrecy:
- Hash lives only in the post frontmatter YAML as
password_hash:(bcrypt cost 12). Never indexed by search, never served via.mdroutes to unlocked/admin, never sent in feeds or llms.txt. PostController::raw()strips thepassword_hash:line before serving plaintext markdown to authenticated visitorscontent/.index.jsonrecords"protected": boolonly — the hash stays in the file itself
Visitor flow:
PostController::show()gates render: locked posts serve theviews/post-password.phpunlock form instead of body untilAuth::isPostUnlocked($slug)returns true- Correct password calls
Auth::markPostUnlocked($slug)and redirects back; session ends when browser closes - Wrong password fails silently after incrementing the per-IP counter
Rate limiting (per-IP, separate file):
- 10 failures within a 15-minute sliding window throttle the IP and disable input until the window clears. Throttle honours browser F5.
- 500ms timing burn on every failure (same as admin login)
- Rate-limit state stored in
/tmp/lazyblog-post-unlock-attempts.json— ephemeral, cleared on reboot (acceptable for a single-server blog) - Anonymous
.mdfetches return 404 for protected posts to avoid a timing oracle
Listings, search, and feeds:
- Locked posts are excluded from
/llms.txtand/feed.xml— title, URL, body all omitted - Home, tags, archive: title +
🔒 LOCKED/🔒 UNLOCKEDbadge (unlocked only if visitor's session is flagged) - Search: title + tag matches surface; body terms return zero hits with a
🔒 protected postplaceholder
When deployed behind Cloudflare or another edge proxy, the reverse proxy
replaces REMOTE_ADDR with its own IP, so rate-limit counters are shared
across all visitors and become useless.
TRUST_CF_CONNECTING_IP env knob (default false):
false(recommended): rate-limits apply perREMOTE_ADDRonly. If you accept direct traffic, this is correct. If ALL traffic flows through Cloudflare, rate-limits won't work.true: switch toCF-Connecting-IPheader (validated as a real IP viafilter_var(FILTER_VALIDATE_IP)). Only enable when origin traffic actually flows through Cloudflare — see threat model below.
Threat model when enabling TRUST_CF_CONNECTING_IP=true:
- The header is spoofable by any client that connects directly to the origin IP (bypassing Cloudflare). If your origin accepts direct HTTP, an attacker can spoof any IP to reset rate-limit counters.
- Fix: lock the origin to Cloudflare ranges only. Use a Cloudflare Tunnel (origin-less), a reverse-proxy allowlist (IP ranges from https://www.cloudflare.com/ips/), or a cloud firewall (AWS SG, Azure NSG) to block non-Cloudflare direct connections.
- Multi-server scope: if you load-balance across multiple servers,
/tmp/*.jsonis per-host, so an attacker can distribute requests across servers to bypass the limit. Migrate rate-limit storage to a shared Redis or database if needed.
Enabled plugins (configured via PLUGINS= env) may make outbound HTTP
requests. For example, the stalk plugin fetches remote RSS feeds to
aggregate friend-blog posts. It includes SSRF guards that block requests to:
IPv4 ranges:
- Loopback:
0.0.0.0/8,127.0.0.0/8 - Private:
10.0.0.0/8,172.16.0.0/12,192.168.0.0/16 - Link-local + cloud metadata:
169.254.0.0/16
IPv6 ranges:
- Loopback:
::/1,::1 - Unique Local:
fc00::/7 - Link-local:
fe80::/10
The blocklist is applied twice — pre-fetch (on operator-provided URL) and
post-redirect (via CURLINFO_EFFECTIVE_URL to close the "innocent.example
302 → metadata" pivot). Review plugin source before enabling, especially if
it makes network calls.
X-Content-Type-Options: nosniff
X-Frame-Options: SAMEORIGIN
Referrer-Policy: strict-origin-when-cross-origin
Permissions-Policy: geolocation=(), camera=(), microphone=(), payment=()
Content-Security-Policy: default-src 'self';
script-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net;
style-src 'self' 'unsafe-inline' https://fonts.googleapis.com https://cdn.jsdelivr.net;
font-src 'self' https://fonts.gstatic.com https://cdn.jsdelivr.net data:;
img-src 'self' data: https:;
connect-src 'self';
frame-src https://www.youtube-nocookie.com https://www.youtube.com;
frame-ancestors 'self';
base-uri 'self';
form-action 'self';
X-Powered-By is removed so the PHP version doesn't leak.
Items done in code (verify on your specific deploy):
- Non-root user in
Dockerfile.prod(lazyblogUID 1000) - Non-root user in
scripts/install-vps.sh(lazyblogsystem user, FPM pool isolated) -
Content-Security-Policyset at PHP layer (and at Caddy layer too via Caddyfile.example) - PHP
display_errors=Off,log_errors=On,expose_php=Off,opcache.validate_timestamps=0baked intoDockerfile.prod+ FPM pool - Session strict-mode + cookie-only + HttpOnly + SameSite=Lax
Items still on the human-operator:
-
Strict-Transport-Securityuncommented in Caddyfile (after TLS proves stable for a week) -
SESSION_SECURE=truein.env -
.envmode640, ownerlazyblog:www-data -
content/posts/writable by php-fpm; rest read-only - Daily backup cron + at least weekly restore test
- Fail2ban or Caddy rate-limit plugin on
/admin/loginif exposed publicly - DNS CAA record locking certs to LetsEncrypt