Skip to content

Security fixes, bug fixes, cleanup, tests and CI - #1

Merged
PhantomPixelDev merged 6 commits into
mainfrom
claude/loving-ritchie-vhqysq
Sep 23, 2026
Merged

PhantomPixelDev merged 6 commits into
mainfrom
claude/loving-ritchie-vhqysq

Conversation

@PhantomPixelDev

Copy link
Copy Markdown
Owner

This PR fixes the security holes and bugs found in a full review of the codebase, cleans up the code, and adds tests and CI. There is one commit per phase, so it can be reviewed a commit at a time.

Critical security fixes

  • Anyone could log in as admin. The JWT signing key was read into a package variable before the config file was loaded, so it was always empty. A token signed with an empty key got a 200 on /admin; I confirmed this against the old code. The key is now read when it is used. Tokens must be HS256 and carry an expiry. Production refuses to start without a real app.secret, and development falls back to a random secret. The unmaintained dgrijalva/jwt-go is replaced by golang-jwt/jwt/v5.
  • The response cache served admin pages to anonymous users. It was keyed on the path only and ran before any auth check. It is removed.
  • Admin routes without an auth check: POST /admin/post/add, POST /admin/post/edit, the post and custom-page editors, /search-{posts,tags,menu,categories}, and the shop's add_product. IsLoggedIn/IsAdmin also let a missing (nil) value through as allowed.
  • Anonymous requests could crash the server through nil type assertions. There is now recover middleware and the assertions are fixed.
  • Default admin/admin1234. The first admin's password now comes from ADMIN_PASSWORD or app.admin_password, or is generated at random and printed once in the log.
  • Committed database. data/database.sqlite (with password hashes) and the generated sitemap are no longer tracked.

Middleware order, CSRF and CORS

  • The 404 catch-all was registered before CSRF, CORS and the captcha middleware, so none of them ever ran on real routes. The order is now: recover → static → CORS → session → rate limiter → CSRF → auth/settings → routes → plugins → custom pages → 404.
  • CSRF uses the csrf_ cookie plus the X-Csrf-Token header, and views/main.html adds the header to every HTMX request.
  • CORS reads cors.allowed_origins, the key the example config actually uses. server.trusted_proxies is supported.
  • Cookies are marked Secure when app.url is https.
  • The plugin toggle is now a POST, because it changes state.

Bug fixes

  • Registration no longer requires a captcha when captcha is disabled (the default). Before, nobody could sign up on a fresh install.
  • Saving a post runs in db.Transaction. Early returns used to leave the SQLite database locked. The slug-uniqueness check could never trigger before; it now works on both add and edit.
  • The sitemap is built per request. It keeps /, /blog, /login and /register, no longer links to /user/:id pages that don't exist, and XML-escapes URLs.
  • Custom pages are served by one catch-all route, so they go live without a restart. Templates are restricted to page, page_sidebar and page_fullwidth.
  • Uploads are checked by their actual content, upper-case extensions are accepted, and upload.max_size_mb is honoured.
  • The comments table no longer drops its last page.
  • The escape template helper no longer turns encoded text back into live HTML, and truncate no longer splits characters.
  • Removed addForeignKeyConstraints, which logged errors on every start, and a no-op dbresolver.

Cleanup

  • routes.go is split into public.go, auth.go and admin.go.
  • Site settings are cached for 30s instead of queried on every request.
  • "Clear cache" no longer wipes the session store, which would log everyone out and invalidate every CSRF token.
  • Inline HTML moved out of Go code into views/partials.
  • Toast messages are shown as text rather than HTML, and error toasts now show as errors.
  • Shared pagination and sanitizing helpers.
  • Debug printlns removed. One of them printed the hCaptcha secret.
  • The logger plugin implements the Plugin interface again.
  • The shop plugin seeds 30 demo products instead of 30,000.
  • gofmt across the tree.

Tooling

  • go.mod/go.sum are committed (Go 1.26).
  • CI runs gofmt, go vet, staticcheck and go test -race. govulncheck runs as a separate, non-blocking job.
  • A Makefile and an updated README.

Testing

  • go test -race ./..., go vet, gofmt and staticcheck pass locally.

  • The integration tests cover:

    • every admin route rejecting anonymous users and regular users;
    • forged JWTs (empty key, wrong key, alg:none, no expiry, expired);
    • admin pages not leaking through the cache;
    • CSRF enforcement;
    • login;
    • registration with captcha off;
    • duplicate slugs;
    • live custom pages and the template allowlist;
    • sitemap contents;
    • upload content checks;
    • unpublished posts returning 404 without a crash.

    Run against the old code, they fail on the auth bypasses, the cache leak and the crash.

  • I ran the real server and checked it with curl:

    • login with and without the CSRF token;
    • anonymous access to admin pages;
    • the forged token;
    • custom pages;
    • static files, the sitemap and registration.

    Production mode exits when app.secret is empty.

  • I could not run govulncheck locally because the vulnerability database was unreachable.

Deviations and follow-ups

  • Old password hashes stay in git history. The committed database is only untracked, not removed from past commits. If it held real accounts, change those passwords or purge the file from history.
  • Existing installs keep admin/admin1234. Any install that already created that account should change the password.
  • app.secret is now required in production. Changing it logs everyone out.
  • Slug indexes are plain, not unique. A unique index would stop the app from starting on any existing database with duplicate slugs. The app itself now enforces uniqueness.
  • About 70 X-No-Cache template headers are left in place. They do nothing now that the cache is gone.

🤖 Generated with Claude Code

https://claude.ai/code/session_01Nnrh7igQnxNpBMxtDAUKeU


Generated by Claude Code

go.mod and go.sum were gitignored, so every checkout had to run
`go mod init` and resolved different dependency versions. The
development database (with user password hashes) and the generated
sitemap were committed despite being listed in .gitignore.

- commit go.mod/go.sum
- untrack data/database.sqlite and static/sitemap.xml
- deduplicate and tidy .gitignore

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Nnrh7igQnxNpBMxtDAUKeU
- JWT: the signing key was captured in a package variable before the
  config file was loaded, so every token was signed with an empty key and
  anyone could forge an admin session. The key is now read at use time,
  tokens must be HS256 with an expiry, and production refuses to start
  without a real app.secret (development falls back to a random one).
  Switch from the unmaintained dgrijalva/jwt-go to golang-jwt/jwt/v5.
- Remove the global response cache. It keyed on path only and ran before
  auth, so admin pages cached for an admin were served to anonymous users.
- IsLoggedIn/IsAdmin no longer treat a missing local as "allowed"; IsAdmin
  also checks login on its own. AuthStatusMiddleware always sets both flags.
- Require admin on POST /admin/post/add, POST /admin/post/edit, the post
  edit page, custom page add/edit pages, /search-{posts,tags,menu,categories}
  and the shop plugin's add_product route.
- The plugin toggle is now a POST and escapes the plugin name in its HTML.
- Add recover middleware and remove the nil type assertions that let
  anonymous requests panic the server (unpublished post, comments).
- Stop seeding admin/admin1234: use ADMIN_PASSWORD / app.admin_password
  or generate a random password and log it once.
- Add role constants and tests covering the admin route matrix, forged
  JWTs, cache leakage and the unpublished-post panic.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Nnrh7igQnxNpBMxtDAUKeU
The 404 catch-all was registered with app.Use before the CSRF, CORS and
captcha middleware, so none of them ever ran for real routes.

- Reorder setupFiberApp: recover -> static -> CORS -> session -> rate
  limiter -> CSRF -> auth/settings -> routes -> plugins -> 404 last.
  Static files no longer pay for auth and settings DB queries.
- Use Fiber's CSRF middleware with the token in the csrf_ cookie and the
  X-Csrf-Token header; views/main.html adds the header to every HTMX
  request. Drop the hand-rolled csrf cookie from login/logout.
- CORS now reads cors.allowed_origins (the key in the example config)
  and defaults to app.url instead of "*".
- Trusted proxies come from server.trusted_proxies.
- Session, CSRF and JWT cookies are Secure when app.url is https.
- Remove the duplicate /static filesystem handler.
- Fix server.body_limit in the example config (it is in MB).
- Tests for CSRF enforcement and the login flow.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Nnrh7igQnxNpBMxtDAUKeU
- Captcha: one captchaPassed helper for login, register and comments.
  Registration no longer demands a captcha when captcha.enabled is false
  (the default), which made sign-up impossible out of the box. The secret
  is no longer printed, and verification has a timeout.
- Posts: add/update run in db.Transaction, so an early error can no
  longer leave a transaction (and the SQLite database) locked. The slug
  uniqueness check was unreachable and never rejected duplicates; it now
  runs on both add and edit. Index post and custom page slugs.
- Sitemap: built per request, keeps /, /blog, /login and /register
  (previously wiped), drops links to non-existent /user/:id pages, and
  XML-escapes URLs.
- Custom pages: served by one catch-all route registered after all other
  routes, so new and edited pages are live without a restart. Templates
  are restricted to page, page_sidebar and page_fullwidth. Edits check
  slug uniqueness. Drop app.hotload_custom_pages.
- Uploads: sniff content instead of trusting the Content-Type header,
  accept upper-case extensions, honour upload.max_size_mb, use
  crypto/rand for name suffixes and create the upload directory.
- Comments table: last partial page was lost to integer division.
- Remove addForeignKeyConstraints (logged errors on every start) and the
  no-op dbresolver. Default prefork to false in the example config.
- Tests for each of the above.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Nnrh7igQnxNpBMxtDAUKeU
- Split routes/routes.go into public.go, auth.go and admin.go. Admin
  routes use handlers.IsAdmin alone, which now also checks login.
- Cache the site settings used by every page (30s TTL, reloaded on
  update) instead of querying them per request, and reuse
  MapSettingsToMap instead of a second hand-written copy.
- "Clear cache" reloads settings and templates. It used to reset the
  session store, which now also holds every user's CSRF token.
- Move the post/comment status buttons, plugin toggle button and
  post-created alert out of Go string concatenation into
  views/partials, shared with the admin tables. The post status button
  no longer reflects the raw form id into HTML.
- Toasts: ShowToastError now shows the error toast (it was identical to
  ShowToast), success messages use ShowToast, and toast text is set with
  textContent instead of innerHTML.
- Template helpers: truncate cuts runes instead of bytes, and escape
  returns plain text that the template escapes (it used to decode
  entities back into live HTML). Templates strip before truncating.
- Shared pagination helpers for the admin search handlers.
- One SanitizeText helper for comments and the shop; regexes compiled
  once.
- Replace debug println calls with log or remove them; the shop plugin
  seeds 30 demo products in one batch instead of 30,000 one by one.
- LoggerPlugin now implements the Plugin interface (checked at compile
  time); drop the unused MenuRepository.
- gofmt the whole tree (userHandlers.go had CRLF line endings).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Nnrh7igQnxNpBMxtDAUKeU
- GitHub Actions: gofmt, go vet, staticcheck and go test -race on push to
  main and on pull requests; govulncheck as a separate non-blocking job.
- Makefile with run/build/test/lint/fmt targets matching CI.
- README: quick start no longer runs `go mod init`, covers app.secret and
  how the first admin password is set, plus development and config notes.
  Drop the finished "CSRF Token" TODO.
- Unit tests for the pagination helpers.
- Remove dead code in the shop product page flagged by staticcheck.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Nnrh7igQnxNpBMxtDAUKeU
@PhantomPixelDev
PhantomPixelDev merged commit a852c6d into main Sep 23, 2026
1 of 2 checks passed
PhantomPixelDev added a commit that referenced this pull request Sep 27, 2026
… the session

SQL injection (3 sinks). GORM treats a non-numeric string passed to First() as
a raw SQL fragment, and an *empty* string as no condition at all. Three admin
endpoints passed a raw request value straight through:

- POST /toggle-post-status  id="1 OR 1=1" became the WHERE clause verbatim
- DELETE /delete-tag?id=      no id at all deleted tag #1
- DELETE /delete-category?id= no id at all deleted category #1

All three now parse the id to a uint and answer 400 otherwise
(handler.parseIDParam). Regression tests cover injection payloads, absent ids
and the valid case.

SQLite foreign keys were never actually enforced.
DisableForeignKeyConstraintWhenMigrating:false only makes GORM *create* the
constraints; SQLite ignores them unless the PRAGMA is set, and mattn/go-sqlite3
only issues it when the DSN asks. The code comment claimed a guarantee that did
not exist. database.InitDB now appends _foreign_keys=on, _journal_mode=WAL,
_busy_timeout=5000 and _synchronous=NORMAL to the DSN (skipping anything the
operator already set), bounds the connection pool, and refuses to start if
PRAGMA foreign_keys is not 1. WAL plus busy_timeout also stop readers and the
writer blocking on each other.

Unpublished custom pages were served to the public. The catch-all filtered on
slug only, and AddCustomPage/EditCustomPage never set Published, so every page
an admin saved went live immediately while correctly staying out of search and
the sitemap. The route now filters on published, both forms gained a Published
switch, and the admin table shows a Draft/Published badge.

CSRF token is now bound to the session. It used to sit in a flat store keyed by
token value, so any valid token was accepted on any request and it never
rotated. It is now session-backed with a 12h expiry matching the JWT, which
also means it rotates on login because Login calls sess.Regenerate(). A
pre-login token is therefore dead after login; a test asserts exactly that, and
another asserts a token from one session is refused by another.

Login throttle gained an un-spoofable bucket. c.IP() is taken from
X-Forwarded-For for any address inside server.trusted_proxies, so rotating the
header reset the attempt budget. The throttle now also keys on the socket peer,
which no header can forge, and the default trusted_proxies in the generated
config is [] instead of all RFC1918.

Also:
- Cache-Control: no-store + Vary: Cookie on /admin*, /account, /login and
  /register; previously every admin screen was heuristically cacheable, so a
  shared proxy could retain and re-serve HTML containing usernames and e-mails.
- Added Cross-Origin-Opener-Policy and script-src-attr 'none'. No template or
  first-party script uses an inline handler, so this turns a future regression
  into a hard CSP failure instead of a silent hole.
- Rate limiter is on by default in code (it was off, while only the two config
  generators enabled it), keyed on the socket peer, and exempts /healthz and
  /static so an orchestrator probe cannot lock itself out.
- Public list routes read c.Params("page") and only rejected < 1, so
  /blog/99999999 became an offset of 999999980 and forced a full table scan.
  All three now go through clampPage.
- The blog list had no ORDER BY at all: with LIMIT/OFFSET that lets rows repeat
  or vanish as the visitor pages. Added created_at DESC.
- /search required at least 2 characters; a one-character query was an
  unthrottled full scan of every content blob.
- AuthStatusMiddleware selected the whole user row, including the bcrypt hash,
  on every request carrying a jwt. It now selects only what it reads.
- Registration no longer returns the raw validator error to an anonymous
  client, and login no longer reflects the username in the response body.

Tests: SQL injection/absent-id rejection, cross-session CSRF rejection, CSRF
rotation on login, draft page 404, public pagination clamping, search minimum
length, admin no-store + new headers, live PRAGMA foreign_keys, and DSN
construction including operator overrides.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants