diff options
| author | grm <grm@eyesin.space> | 2026-09-18 13:47:49 +0300 |
|---|---|---|
| committer | grm <grm@eyesin.space> | 2026-09-18 13:47:49 +0300 |
| commit | 3eeaa6d9a33f9294ade64c8ff26144fba86a379e (patch) | |
| tree | 1c4bf44884ea59f7e3d9ca12008846f08bf72d16 | |
| parent | 254733b0566830a46e5a44c2d3127f57bfaceb65 (diff) | |
| download | blogspace-3eeaa6d9a33f9294ade64c8ff26144fba86a379e.tar.gz blogspace-3eeaa6d9a33f9294ade64c8ff26144fba86a379e.tar.bz2 blogspace-3eeaa6d9a33f9294ade64c8ff26144fba86a379e.zip | |
Security: Add HTTPS and TRUST_PROXY settings
The app only ever speaks plain HTTP, so it could not know the site was
served over TLS: generated links were http:// (the /webadmin bounce
from a blog host sent the login page over http), the session cookie
was never Secure and nothing sent HSTS. HTTPS=true fixes all three;
ClearSessionCookie now uses the same attributes as the set, since a
browser only replaces a cookie whose Secure flag matches.
TRUST_PROXY=true makes the client address the last X-Forwarded-For
entry — the one our proxy appended — so throttling and the log see
real addresses instead of the proxy's; earlier entries are whatever
the client sent and are ignored. Production startup warns when HTTPS
is off.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Sd8UPWrvyYCLj97JexNw3A
| -rw-r--r-- | .env.example | 4 | ||||
| -rw-r--r-- | AGENTS.md | 5 | ||||
| -rw-r--r-- | README.md | 5 | ||||
| -rw-r--r-- | cmd/blogspace/main.go | 5 | ||||
| -rw-r--r-- | internal/auth/cookie.go | 27 | ||||
| -rw-r--r-- | internal/config/config.go | 20 | ||||
| -rw-r--r-- | internal/web/handlers_auth.go | 12 | ||||
| -rw-r--r-- | internal/web/ratelimit.go | 20 | ||||
| -rw-r--r-- | internal/web/server.go | 7 | ||||
| -rw-r--r-- | internal/web/web_test.go | 28 |
10 files changed, 108 insertions, 25 deletions
diff --git a/.env.example b/.env.example index ee6d25f..5d6908d 100644 --- a/.env.example +++ b/.env.example @@ -9,5 +9,9 @@ SUPERADMIN_USERNAME=admin SUPERADMIN_PASSWORD=change-me POSTGRES_PASSWORD=change-me-too MAX_UPLOAD_MB=10 +# The proxy in front terminates TLS: https:// links, Secure cookie, HSTS. +HTTPS=true +# The proxy sets X-Forwarded-For; use it for the client address (rate limits, log). +TRUST_PROXY=true # Only when running the dashboard on a non-standard port (dev): appended to generated blog links. #PUBLIC_PORT=8080 @@ -45,6 +45,8 @@ table and deployment notes. and `HttpOnly`, management POSTs need the HMAC `_csrf` token, and the base-domain blog is only editable by the superadmin. - App runs plain HTTP behind a reverse proxy; auth is a JWT cookie. + `HTTPS=true` tells it the proxy has TLS (https links, `Secure` cookie, + HSTS); `TRUST_PROXY=true` that `X-Forwarded-For`'s last entry is the client. - Stdlib `net/http` ServeMux with method+pattern routes. No router library. ## Layout @@ -138,7 +140,8 @@ internal/web/ server.go (host router, middleware, render helpers) minute each), answered with 429 by `s.throttle` and a log line; failed logins are logged with the username and address, and the login body is capped at `maxLoginBody` (64 KB) since it is the one POST outside - `guardPOST`. Flood + `guardPOST`. `s.clientIP` is the peer address, or the last + `X-Forwarded-For` hop with `TRUST_PROXY` (earlier entries are forgeable). Flood control for everything else stays at the reverse proxy (`limit_req`). - **Templates**: each page file is parsed together with its layout (`layouts/dashboard.html` or `layouts/blog.html` for `blog/*`) and all @@ -90,6 +90,8 @@ Go changes need a restart. | `JWT_SECRET` | — | **Required** outside dev; long random string (`openssl rand -hex 32`) | | `SUPERADMIN_USERNAME` / `SUPERADMIN_PASSWORD` | `admin` / — | Created on first start if no superadmin exists | | `MAX_UPLOAD_MB` | `10` | Per-file upload limit; the superadmin can override it per blog in `/admin/` | +| `HTTPS` | `false` | The proxy terminates TLS: generated links are `https://`, the session cookie is `Secure`, HSTS is sent. **Set it in production.** | +| `TRUST_PROXY` | `false` | Take the client address from the last `X-Forwarded-For` entry (the one your proxy wrote) for rate limiting and the log. Set it when the app is only reachable through your proxy. | | `DEV` | `false` | Hot-reload templates, allow missing secrets | Migrations run automatically at startup, for the control database and for every blog database. @@ -103,7 +105,8 @@ docker compose up --build -d The app listens on `127.0.0.1:8080` (see `APP_PORT`); put a reverse proxy in front that terminates TLS and forwards **both** the root domain and the wildcard -with the original `Host` header. DNS needs two records: `A example.com` and +with the original `Host` header. Keep `HTTPS=true` and `TRUST_PROXY=true` in +`.env` for that setup (the sample has them). DNS needs two records: `A example.com` and `A *.example.com` (or CNAMEs) pointing at the proxy. nginx example: diff --git a/cmd/blogspace/main.go b/cmd/blogspace/main.go index 20e6ffe..26689bc 100644 --- a/cmd/blogspace/main.go +++ b/cmd/blogspace/main.go @@ -172,7 +172,10 @@ func serve(ctx context.Context, cfg *config.Config, st *store.Store) error { } errc := make(chan error, 1) go func() { errc <- srv.ListenAndServe() }() - log.Printf("listening on %s — dashboard at %s, blogs at http://<name>.%s (dev=%v)", cfg.Addr, cfg.RootURL(), cfg.HostWithPort(), cfg.Dev) + if !cfg.Dev && !cfg.HTTPS { + log.Printf("warning: HTTPS is off — the session cookie is not Secure and links are http://; set HTTPS=true behind a TLS proxy") + } + log.Printf("listening on %s — dashboard at %s, blogs at %s://<name>.%s (dev=%v)", cfg.Addr, cfg.RootURL(), cfg.Scheme(), cfg.HostWithPort(), cfg.Dev) select { case err := <-errc: return err diff --git a/internal/auth/cookie.go b/internal/auth/cookie.go index 9fd6b6e..8fa394d 100644 --- a/internal/auth/cookie.go +++ b/internal/auth/cookie.go @@ -7,17 +7,26 @@ import ( const CookieName = "session" -func SetSessionCookie(w http.ResponseWriter, token string) { - http.SetCookie(w, &http.Cookie{ +// SetSessionCookie sets the session cookie; secure marks it for https only, +// which the app cannot tell on its own behind a plain-http proxy. +func SetSessionCookie(w http.ResponseWriter, token string, secure bool) { + http.SetCookie(w, sessionCookie(token, int(SessionTTL/time.Second), secure)) +} + +// ClearSessionCookie expires the cookie with the same attributes it was set +// with; browsers only replace a cookie whose Secure flag matches. +func ClearSessionCookie(w http.ResponseWriter, secure bool) { + http.SetCookie(w, sessionCookie("", -1, secure)) +} + +func sessionCookie(value string, maxAge int, secure bool) *http.Cookie { + return &http.Cookie{ Name: CookieName, - Value: token, + Value: value, Path: "/", HttpOnly: true, + Secure: secure, SameSite: http.SameSiteLaxMode, - MaxAge: int(SessionTTL / time.Second), - }) -} - -func ClearSessionCookie(w http.ResponseWriter) { - http.SetCookie(w, &http.Cookie{Name: CookieName, Value: "", Path: "/", HttpOnly: true, MaxAge: -1}) + MaxAge: maxAge, + } } diff --git a/internal/config/config.go b/internal/config/config.go index a239aa2..c231860 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -19,6 +19,12 @@ type Config struct { SuperadminPassword string MaxUploadBytes int64 // default per-file limit; the superadmin can override it per blog Dev bool // reload templates/static from disk + // HTTPS says the proxy in front terminates TLS: generated links are + // https://, the session cookie is Secure and HSTS is sent. + HTTPS bool + // TrustProxy says X-Forwarded-For was written by our own proxy, so its + // last entry is the client address (for throttling and the log). + TrustProxy bool } func Load() (*Config, error) { @@ -31,6 +37,8 @@ func Load() (*Config, error) { SuperadminUsername: env("SUPERADMIN_USERNAME", "admin"), SuperadminPassword: env("SUPERADMIN_PASSWORD", ""), Dev: envBool("DEV", false), + HTTPS: envBool("HTTPS", false), + TrustProxy: envBool("TRUST_PROXY", false), } mb, err := strconv.Atoi(env("MAX_UPLOAD_MB", "10")) if err != nil || mb <= 0 { @@ -62,12 +70,20 @@ func (c *Config) BlogURL(sub string) string { if sub == RootSubdomain { return c.RootURL() } - return "http://" + sub + "." + c.HostWithPort() + return c.Scheme() + "://" + sub + "." + c.HostWithPort() } // RootURL returns the public URL of the management site. func (c *Config) RootURL() string { - return "http://" + c.HostWithPort() + return c.Scheme() + "://" + c.HostWithPort() +} + +// Scheme is the one the public reaches the site by. +func (c *Config) Scheme() string { + if c.HTTPS { + return "https" + } + return "http" } func (c *Config) HostWithPort() string { diff --git a/internal/web/handlers_auth.go b/internal/web/handlers_auth.go index 69b7b98..6c76d81 100644 --- a/internal/web/handlers_auth.go +++ b/internal/web/handlers_auth.go @@ -63,7 +63,7 @@ func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) { } // Both buckets must have a token: one address guessing many accounts and // many addresses guessing one account are throttled alike. - if !s.throttle(w, r, s.loginLimit, "ip:"+clientIP(r)) || !s.throttle(w, r, s.loginLimit, "user:"+strings.ToLower(username)) { + if !s.throttle(w, r, s.loginLimit, "ip:"+s.clientIP(r)) || !s.throttle(w, r, s.loginLimit, "user:"+strings.ToLower(username)) { return } u, err := s.st.UserByUsername(r.Context(), username) @@ -73,12 +73,12 @@ func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) { return } auth.CheckPassword("$2a$10$invalidinvalidinvalidinvalidinvalidinvalidinvalidinval", password) // constant-ish time - logf("login failed for %q from %s", username, clientIP(r)) + logf("login failed for %q from %s", username, s.clientIP(r)) fail() return } if u.Disabled || !auth.CheckPassword(u.PasswordHash, password) { - logf("login failed for %q from %s", username, clientIP(r)) + logf("login failed for %q from %s", username, s.clientIP(r)) fail() return } @@ -87,12 +87,12 @@ func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) { s.serverError(w, err) return } - auth.SetSessionCookie(w, tok) + auth.SetSessionCookie(w, tok, s.cfg.HTTPS) http.Redirect(w, r, s.landing(r, u, next), http.StatusSeeOther) } func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) { - auth.ClearSessionCookie(w) + auth.ClearSessionCookie(w, s.cfg.HTTPS) http.Redirect(w, r, "/webadmin", http.StatusSeeOther) } @@ -149,7 +149,7 @@ func (s *Server) handlePassword(w http.ResponseWriter, r *http.Request) { s.serverError(w, err) return } - auth.SetSessionCookie(w, tok) + auth.SetSessionCookie(w, tok, s.cfg.HTTPS) redirectOK(w, r, "/dashboard", s.tr(r, "Password changed.")) } diff --git a/internal/web/ratelimit.go b/internal/web/ratelimit.go index b019a4f..8f5fc94 100644 --- a/internal/web/ratelimit.go +++ b/internal/web/ratelimit.go @@ -3,6 +3,7 @@ package web import ( "net" "net/http" + "strings" "sync" "time" ) @@ -66,8 +67,21 @@ func (l *limiter) fill(b *bucket, now time.Time) float64 { return b.tokens } -// clientIP is the address requests are throttled by: the peer's. -func clientIP(r *http.Request) string { +// clientIP is the address requests are throttled by: the peer's, or with +// TRUST_PROXY the last X-Forwarded-For entry — the one our proxy appended +// (the README's nginx sets the header to $remote_addr alone); anything +// before it is whatever the client sent and could be forged. +func (s *Server) clientIP(r *http.Request) string { + if s.cfg.TrustProxy { + if xff := r.Header.Get("X-Forwarded-For"); xff != "" { + if i := strings.LastIndex(xff, ","); i >= 0 { + xff = xff[i+1:] + } + if ip := strings.TrimSpace(xff); ip != "" { + return ip + } + } + } host, _, err := net.SplitHostPort(r.RemoteAddr) if err != nil { return r.RemoteAddr @@ -81,7 +95,7 @@ func (s *Server) throttle(w http.ResponseWriter, r *http.Request, l *limiter, ke if l.allow(key) { return true } - logf("throttled %s %s from %s", r.Method, r.URL.Path, clientIP(r)) + logf("throttled %s %s from %s", r.Method, r.URL.Path, s.clientIP(r)) s.fail(w, r, http.StatusTooManyRequests, s.tr(r, "Too many requests. Try again in a minute.")) return false } diff --git a/internal/web/server.go b/internal/web/server.go index 12440de..f768ebd 100644 --- a/internal/web/server.go +++ b/internal/web/server.go @@ -43,6 +43,9 @@ func NewServer(cfg *config.Config, st *store.Store) *Server { // site plus the superadmin's root blog, one label below it is a blog, anything // else is a 404. func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { + if s.cfg.HTTPS { // every subdomain is ours, so the whole site may pin https + w.Header().Set("Strict-Transport-Security", "max-age=31536000; includeSubDomains") + } host := hostname(r.Host) switch { case host == s.cfg.BaseDomain, host == config.RootSubdomain+"."+s.cfg.BaseDomain: @@ -178,13 +181,13 @@ func (s *Server) session(next http.Handler) http.Handler { } claims, err := auth.ParseToken(s.cfg.JWTSecret, c.Value) if err != nil { - auth.ClearSessionCookie(w) + auth.ClearSessionCookie(w, s.cfg.HTTPS) next.ServeHTTP(w, r) return } u, err := s.st.UserByID(r.Context(), claims.UserID) if err != nil || u.Disabled || u.TokenVersion != claims.TokenVersion { - auth.ClearSessionCookie(w) + auth.ClearSessionCookie(w, s.cfg.HTTPS) next.ServeHTTP(w, r) return } diff --git a/internal/web/web_test.go b/internal/web/web_test.go index 49915f4..42d3b3a 100644 --- a/internal/web/web_test.go +++ b/internal/web/web_test.go @@ -975,3 +975,31 @@ func TestLoginBodyCapped(t *testing.T) { t.Errorf("got %d, want 400", rec.Code) } } + +// With HTTPS on, links are https, the cookie is Secure and HSTS is sent; with TRUST_PROXY the client is the last X-Forwarded-For hop. +func TestHTTPSAndTrustProxy(t *testing.T) { + cfg := &config.Config{BaseDomain: "example.com", JWTSecret: []byte("x"), HTTPS: true, TrustProxy: true} + if cfg.RootURL() != "https://example.com" || cfg.BlogURL("a") != "https://a.example.com" { + t.Errorf("urls: %s %s", cfg.RootURL(), cfg.BlogURL("a")) + } + s := NewServer(cfg, nil) + rec := httptest.NewRecorder() + req := httptest.NewRequest("GET", "/webadmin", nil) + req.Host = "example.com" + req.AddCookie(&http.Cookie{Name: "session", Value: "garbage"}) // a bad cookie is cleared, with Secure + req.Header.Set("X-Forwarded-For", "203.0.113.9, 10.0.0.2") + s.ServeHTTP(rec, req) + if h := rec.Header().Get("Strict-Transport-Security"); !strings.Contains(h, "includeSubDomains") { + t.Errorf("HSTS = %q", h) + } + if c := rec.Header().Get("Set-Cookie"); !strings.Contains(c, "Secure") || !strings.Contains(c, "Max-Age=0") { + t.Errorf("cleared cookie = %q", c) + } + if ip := s.clientIP(req); ip != "10.0.0.2" { + t.Errorf("clientIP = %q, want the last hop", ip) + } + cfg.TrustProxy = false + if ip := s.clientIP(req); ip != "192.0.2.1" { + t.Errorf("clientIP without TRUST_PROXY = %q, want the peer", ip) + } +} |
