Security fixes, bug fixes, cleanup, tests and CI - #1
Merged
Merged
Conversation
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
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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
/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 realapp.secret, and development falls back to a random secret. The unmaintaineddgrijalva/jwt-gois replaced bygolang-jwt/jwt/v5.POST /admin/post/add,POST /admin/post/edit, the post and custom-page editors,/search-{posts,tags,menu,categories}, and the shop'sadd_product.IsLoggedIn/IsAdminalso let a missing (nil) value through as allowed.admin/admin1234. The first admin's password now comes fromADMIN_PASSWORDorapp.admin_password, or is generated at random and printed once in the log.data/database.sqlite(with password hashes) and the generated sitemap are no longer tracked.Middleware order, CSRF and CORS
csrf_cookie plus theX-Csrf-Tokenheader, andviews/main.htmladds the header to every HTMX request.cors.allowed_origins, the key the example config actually uses.server.trusted_proxiesis supported.Securewhenapp.urlis https.Bug fixes
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./,/blog,/loginand/register, no longer links to/user/:idpages that don't exist, and XML-escapes URLs.page,page_sidebarandpage_fullwidth.upload.max_size_mbis honoured.escapetemplate helper no longer turns encoded text back into live HTML, andtruncateno longer splits characters.addForeignKeyConstraints, which logged errors on every start, and a no-opdbresolver.Cleanup
routes.gois split intopublic.go,auth.goandadmin.go.views/partials.printlns removed. One of them printed the hCaptcha secret.Plugininterface again.gofmtacross the tree.Tooling
go.mod/go.sumare committed (Go 1.26).go vet, staticcheck andgo test -race.govulncheckruns as a separate, non-blocking job.Makefileand an updated README.Testing
go test -race ./...,go vet,gofmtandstaticcheckpass locally.The integration tests cover:
alg:none, no expiry, expired);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:
Production mode exits when
app.secretis empty.I could not run
govulnchecklocally because the vulnerability database was unreachable.Deviations and follow-ups
admin/admin1234. Any install that already created that account should change the password.app.secretis now required in production. Changing it logs everyone out.X-No-Cachetemplate 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