aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--.env.example4
-rw-r--r--AGENTS.md5
-rw-r--r--README.md5
-rw-r--r--cmd/blogspace/main.go5
-rw-r--r--internal/auth/cookie.go27
-rw-r--r--internal/config/config.go20
-rw-r--r--internal/web/handlers_auth.go12
-rw-r--r--internal/web/ratelimit.go20
-rw-r--r--internal/web/server.go7
-rw-r--r--internal/web/web_test.go28
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
diff --git a/AGENTS.md b/AGENTS.md
index 406c621..4f5e893 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -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
diff --git a/README.md b/README.md
index ec14555..ba79190 100644
--- a/README.md
+++ b/README.md
@@ -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)
+ }
+}