BREAKING CHANGE: PASSWORD is gone and AUTH and CERTRESOLVER are required. The server's compose.yaml and .env must be updated in the same deploy — the new image ignores PASSWORD, and the old one refuses to start without it. Authelia now sits in front of Traefik, so the app was asking for a second password at the same door. Two prompts, and the weaker of the two was the one holding a single shared secret with no sessions, no MFA and no revocation. Deleting it is the whole change: Authelia already does this properly, once, for every service on the host. Gone: auth(), challenge(), the whole of throttle.go and its tests, and golang.org/x/time with them. routes() returns the bare mux, /healthz is an ordinary route on it, and the smoke script drops sixty -u flags. Roughly 230 lines removed and nothing written to replace them. What holds the app up now, both asserted in compose.yaml: - The router names the Authelia middleware through AUTH. Traefik takes a router out of service when its middleware does not resolve, so a typo or an unset variable fails shut rather than serving the app open. - The container still publishes no ports, so the proxy is the only thing that can reach it. Publishing 8080 would now bypass authentication outright, not merely TLS — the comment there says so. certresolver replaces the bare tls=true, parameterised as CERTRESOLVER: the server had been carrying that label by hand since the first deploy. Naming a resolver implies tls=true, so it stays one label. TestAuth and TestHealthzSkipsAuth are replaced by one test asserting every route answers without credentials — a 401 from here would now mean auth had crept back in.
This commit is contained in:
+12
-53
@@ -7,8 +7,6 @@ package main
|
||||
import (
|
||||
"cmp"
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"crypto/subtle"
|
||||
"database/sql"
|
||||
"embed"
|
||||
"errors"
|
||||
@@ -76,16 +74,11 @@ func run() error {
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
// Importing is an offline chore: no password needed, no server started.
|
||||
// Importing is an offline chore: no server started, nothing to serve.
|
||||
if *importPath != "" {
|
||||
return runImport(db, *importPath)
|
||||
}
|
||||
|
||||
password := os.Getenv("PASSWORD")
|
||||
if password == "" {
|
||||
return errors.New("PASSWORD is not set")
|
||||
}
|
||||
|
||||
// Fail rather than fall back to UTC: a silently wrong zone shifts logged
|
||||
// dinners onto the wrong calendar day, which is invisible until the
|
||||
// history is already corrupt.
|
||||
@@ -105,7 +98,7 @@ func run() error {
|
||||
// response blocks every other request behind it.
|
||||
srv := &http.Server{
|
||||
Addr: addr,
|
||||
Handler: routes(db, loc, password),
|
||||
Handler: routes(db, loc),
|
||||
ReadHeaderTimeout: 10 * time.Second,
|
||||
WriteTimeout: 30 * time.Second,
|
||||
IdleTimeout: 120 * time.Second,
|
||||
@@ -168,7 +161,11 @@ func openDB(path string) (*sql.DB, error) {
|
||||
return db, nil
|
||||
}
|
||||
|
||||
func routes(db *sql.DB, loc *time.Location, password string) http.Handler {
|
||||
// routes serves the app unauthenticated. Access control is the reverse proxy's
|
||||
// job: Traefik forwards every request to Authelia before it reaches here, so a
|
||||
// second password in front of it only ever meant two prompts for one door. The
|
||||
// container publishes no ports, so nothing but the proxy can reach it.
|
||||
func routes(db *sql.DB, loc *time.Location) http.Handler {
|
||||
// Go's mime table has no entry for .webmanifest, and a manifest served as
|
||||
// octet-stream is ignored by the browser.
|
||||
_ = mime.AddExtensionType(".webmanifest", "application/manifest+json")
|
||||
@@ -191,51 +188,13 @@ func routes(db *sql.DB, loc *time.Location, password string) http.Handler {
|
||||
mux.HandleFunc("POST /ruuat/poista", a.deleteDish)
|
||||
mux.HandleFunc("POST /ruuat/tuonti", a.importDishes)
|
||||
|
||||
// /healthz stays outside auth so a monitor or reverse proxy can reach it.
|
||||
root := http.NewServeMux()
|
||||
root.HandleFunc("GET /healthz", func(w http.ResponseWriter, r *http.Request) {
|
||||
// /healthz is an ordinary route now that the app has no auth of its own.
|
||||
// It reveals only the version, so an Authelia bypass rule for it is safe if
|
||||
// a monitor needs to poll from outside the container network.
|
||||
mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, r *http.Request) {
|
||||
fmt.Fprintln(w, version)
|
||||
})
|
||||
root.Handle("/", auth(password, mux))
|
||||
return root
|
||||
}
|
||||
|
||||
// auth gates everything behind one shared household password. There are no
|
||||
// accounts, so the username is ignored (PRD §9).
|
||||
func auth(password string, next http.Handler) http.Handler {
|
||||
want := sha256.Sum256([]byte(password))
|
||||
guesses := newThrottle()
|
||||
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
_, given, ok := r.BasicAuth()
|
||||
|
||||
// A request with no Authorization header is the normal browser
|
||||
// handshake, not a guess: every session opens with one. Challenge it
|
||||
// without spending the address's allowance.
|
||||
if !ok {
|
||||
challenge(w)
|
||||
return
|
||||
}
|
||||
|
||||
// Hashing first keeps the comparison a fixed length, so neither the
|
||||
// password nor its length leaks through timing.
|
||||
got := sha256.Sum256([]byte(given))
|
||||
if subtle.ConstantTimeCompare(got[:], want[:]) != 1 {
|
||||
if !guesses.allow(clientIP(r)) {
|
||||
http.Error(w, "Liikaa yrityksiä.", http.StatusTooManyRequests)
|
||||
return
|
||||
}
|
||||
challenge(w)
|
||||
return
|
||||
}
|
||||
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
func challenge(w http.ResponseWriter) {
|
||||
w.Header().Set("WWW-Authenticate", `Basic realm="Foodster", charset="UTF-8"`)
|
||||
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
||||
return mux
|
||||
}
|
||||
|
||||
// today is the current calendar day in the configured location, truncated to
|
||||
|
||||
+11
-51
@@ -306,63 +306,23 @@ func TestDuplicateNamesAreCaseInsensitive(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuth(t *testing.T) {
|
||||
handler := auth("hunter2", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusTeapot) // proves we reached the wrapped handler
|
||||
}))
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
user string
|
||||
pass string
|
||||
withAuth bool
|
||||
want int
|
||||
}{
|
||||
{"correct password", "", "hunter2", true, http.StatusTeapot},
|
||||
{"username is ignored", "anyone", "hunter2", true, http.StatusTeapot},
|
||||
{"wrong password", "", "wrong", true, http.StatusUnauthorized},
|
||||
{"empty password", "", "", true, http.StatusUnauthorized},
|
||||
{"no credentials", "", "", false, http.StatusUnauthorized},
|
||||
}
|
||||
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
r := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
if c.withAuth {
|
||||
r.SetBasicAuth(c.user, c.pass)
|
||||
}
|
||||
w := httptest.NewRecorder()
|
||||
handler.ServeHTTP(w, r)
|
||||
|
||||
if w.Code != c.want {
|
||||
t.Errorf("status = %d, want %d", w.Code, c.want)
|
||||
}
|
||||
if c.want == http.StatusUnauthorized && w.Header().Get("WWW-Authenticate") == "" {
|
||||
t.Error("401 without a WWW-Authenticate header; the browser will not prompt")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestHealthzSkipsAuth(t *testing.T) {
|
||||
// The app carries no authentication of its own — Authelia in front of Traefik
|
||||
// does that — so the only thing left to assert is that every route answers
|
||||
// without credentials. A 401 from here would mean auth crept back in.
|
||||
func TestRoutesNeedNoCredentials(t *testing.T) {
|
||||
db, err := openDB(t.TempDir() + "/test.db")
|
||||
if err != nil {
|
||||
t.Fatalf("openDB: %v", err)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
h := routes(db, time.UTC, "hunter2")
|
||||
h := routes(db, time.UTC)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
h.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/healthz", nil))
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("/healthz without credentials = %d, want 200", w.Code)
|
||||
}
|
||||
|
||||
// Everything else must still be gated.
|
||||
w = httptest.NewRecorder()
|
||||
h.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/", nil))
|
||||
if w.Code != http.StatusUnauthorized {
|
||||
t.Errorf("/ without credentials = %d, want 401", w.Code)
|
||||
for _, path := range []string{"/healthz", "/", "/ruuat"} {
|
||||
w := httptest.NewRecorder()
|
||||
h.ServeHTTP(w, httptest.NewRequest(http.MethodGet, path, nil))
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("GET %s = %d, want 200", path, w.Code)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,111 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"net"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"golang.org/x/time/rate"
|
||||
)
|
||||
|
||||
// The app is reachable from the internet, so a shared password needs more
|
||||
// than a sleep in front of it. These allow a family fumbling the password a
|
||||
// handful of quick retries, then roughly six a minute — useless for guessing,
|
||||
// unnoticeable to anyone who knows it.
|
||||
//
|
||||
// This buys time; it is not the defence. A strong password is.
|
||||
const (
|
||||
guessBurst = 5
|
||||
guessInterval = 10 * time.Second
|
||||
|
||||
// Bounds on the per-IP table, so a spray across many addresses cannot
|
||||
// grow it without limit.
|
||||
throttleMaxEntries = 4096
|
||||
throttleIdle = 15 * time.Minute
|
||||
)
|
||||
|
||||
type visitor struct {
|
||||
limiter *rate.Limiter
|
||||
seen time.Time
|
||||
}
|
||||
|
||||
// throttle rate-limits failed password attempts per client address.
|
||||
//
|
||||
// ponytail: one mutex over one map. At household traffic this will never be
|
||||
// contended; shard it if that ever stops being true.
|
||||
type throttle struct {
|
||||
mu sync.Mutex
|
||||
visitors map[string]*visitor
|
||||
}
|
||||
|
||||
func newThrottle() *throttle {
|
||||
return &throttle{visitors: make(map[string]*visitor)}
|
||||
}
|
||||
|
||||
// allow reports whether another wrong guess from this address is permitted.
|
||||
func (t *throttle) allow(ip string) bool {
|
||||
now := time.Now()
|
||||
|
||||
t.mu.Lock()
|
||||
defer t.mu.Unlock()
|
||||
|
||||
if len(t.visitors) >= throttleMaxEntries {
|
||||
t.pruneLocked(now)
|
||||
}
|
||||
|
||||
v := t.visitors[ip]
|
||||
if v == nil {
|
||||
v = &visitor{limiter: rate.NewLimiter(rate.Every(guessInterval), guessBurst)}
|
||||
t.visitors[ip] = v
|
||||
}
|
||||
v.seen = now
|
||||
|
||||
return v.limiter.Allow()
|
||||
}
|
||||
|
||||
func (t *throttle) pruneLocked(now time.Time) {
|
||||
for ip, v := range t.visitors {
|
||||
if now.Sub(v.seen) > throttleIdle {
|
||||
delete(t.visitors, ip)
|
||||
}
|
||||
}
|
||||
// Still full of live entries: a spray is in progress. Drop the lot rather
|
||||
// than grow without bound. Everyone gets a fresh allowance, which is the
|
||||
// safe direction to fail — the password is still required.
|
||||
if len(t.visitors) >= throttleMaxEntries {
|
||||
clear(t.visitors)
|
||||
}
|
||||
}
|
||||
|
||||
// clientIP resolves the address to rate-limit against.
|
||||
//
|
||||
// X-Forwarded-For is only believed when the connection itself came from a
|
||||
// private address, meaning it arrived through the reverse proxy on the
|
||||
// container network. A client connecting directly could otherwise forge a
|
||||
// fresh address on every attempt and walk straight past the limiter.
|
||||
func clientIP(r *http.Request) string {
|
||||
host, _, err := net.SplitHostPort(r.RemoteAddr)
|
||||
if err != nil {
|
||||
host = r.RemoteAddr
|
||||
}
|
||||
|
||||
ip := net.ParseIP(host)
|
||||
if ip == nil || !(ip.IsPrivate() || ip.IsLoopback()) {
|
||||
return host
|
||||
}
|
||||
|
||||
forwarded := r.Header.Get("X-Forwarded-For")
|
||||
if forwarded == "" {
|
||||
return host
|
||||
}
|
||||
// The nearest proxy appends the address it saw, so the last entry is the
|
||||
// trustworthy one; anything before it was supplied by the client.
|
||||
parts := strings.Split(forwarded, ",")
|
||||
last := strings.TrimSpace(parts[len(parts)-1])
|
||||
if net.ParseIP(last) == nil {
|
||||
return host
|
||||
}
|
||||
return last
|
||||
}
|
||||
@@ -1,110 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestThrottleBlocksRepeatedGuesses(t *testing.T) {
|
||||
th := newThrottle()
|
||||
|
||||
for i := 0; i < guessBurst; i++ {
|
||||
if !th.allow("198.51.100.7") {
|
||||
t.Fatalf("guess %d refused inside the burst", i+1)
|
||||
}
|
||||
}
|
||||
if th.allow("198.51.100.7") {
|
||||
t.Error("guess allowed past the burst")
|
||||
}
|
||||
// A different address has its own allowance.
|
||||
if !th.allow("198.51.100.8") {
|
||||
t.Error("a second address was blocked by the first one's guesses")
|
||||
}
|
||||
}
|
||||
|
||||
func TestClientIPIgnoresForwardedHeaderFromDirectClients(t *testing.T) {
|
||||
// Connecting straight from the internet: X-Forwarded-For is attacker
|
||||
// input, so a forged value must not create a fresh rate-limit bucket.
|
||||
r := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
r.RemoteAddr = "203.0.113.9:44321"
|
||||
r.Header.Set("X-Forwarded-For", "1.2.3.4")
|
||||
|
||||
if got := clientIP(r); got != "203.0.113.9" {
|
||||
t.Errorf("clientIP = %q, want the real peer 203.0.113.9", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClientIPTakesLastForwardedEntryBehindProxy(t *testing.T) {
|
||||
// Arriving through Traefik on the container network. The proxy appends
|
||||
// the address it saw, so the last entry is the trustworthy one and the
|
||||
// forged entry in front of it must be ignored.
|
||||
r := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
r.RemoteAddr = "172.18.0.4:53000"
|
||||
r.Header.Set("X-Forwarded-For", "1.2.3.4, 198.51.100.22")
|
||||
|
||||
if got := clientIP(r); got != "198.51.100.22" {
|
||||
t.Errorf("clientIP = %q, want 198.51.100.22", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClientIPFallsBackWhenNoForwardedHeader(t *testing.T) {
|
||||
r := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
r.RemoteAddr = "172.18.0.4:53000"
|
||||
|
||||
if got := clientIP(r); got != "172.18.0.4" {
|
||||
t.Errorf("clientIP = %q, want 172.18.0.4", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthRateLimitsWrongPasswords(t *testing.T) {
|
||||
handler := auth("hunter2", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusTeapot)
|
||||
}))
|
||||
|
||||
send := func(pass string) int {
|
||||
r := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
r.RemoteAddr = "203.0.113.5:40000"
|
||||
r.SetBasicAuth("", pass)
|
||||
w := httptest.NewRecorder()
|
||||
handler.ServeHTTP(w, r)
|
||||
return w.Code
|
||||
}
|
||||
|
||||
for i := 0; i < guessBurst; i++ {
|
||||
if code := send("wrong"); code != http.StatusUnauthorized {
|
||||
t.Fatalf("guess %d returned %d, want 401", i+1, code)
|
||||
}
|
||||
}
|
||||
if code := send("wrong"); code != http.StatusTooManyRequests {
|
||||
t.Errorf("guess past the burst returned %d, want 429", code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthDoesNotSpendAllowanceOnTheBrowserHandshake(t *testing.T) {
|
||||
handler := auth("hunter2", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusTeapot)
|
||||
}))
|
||||
|
||||
// Every session opens with a credential-less request. Charging those
|
||||
// would lock a family out by simply opening the app a few times.
|
||||
for i := 0; i < guessBurst*4; i++ {
|
||||
r := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
r.RemoteAddr = "203.0.113.6:40000"
|
||||
w := httptest.NewRecorder()
|
||||
handler.ServeHTTP(w, r)
|
||||
if w.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("handshake %d returned %d, want 401", i+1, w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// The correct password still works afterwards.
|
||||
r := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
r.RemoteAddr = "203.0.113.6:40000"
|
||||
r.SetBasicAuth("", "hunter2")
|
||||
w := httptest.NewRecorder()
|
||||
handler.ServeHTTP(w, r)
|
||||
if w.Code != http.StatusTeapot {
|
||||
t.Errorf("correct password returned %d, want the wrapped handler", w.Code)
|
||||
}
|
||||
}
|
||||
@@ -143,8 +143,9 @@ templ page(title, current string) {
|
||||
<title>{ pageTitle(title) }</title>
|
||||
<link rel="icon" href="/static/favicon.svg" type="image/svg+xml"/>
|
||||
<link rel="apple-touch-icon" href="/static/apple-touch-icon.png"/>
|
||||
<!-- use-credentials: the manifest is fetched behind Basic auth and
|
||||
would otherwise come back 401 and be ignored. -->
|
||||
<!-- use-credentials: the manifest fetch is anonymous by default, so
|
||||
behind Authelia it would be redirected to the login page and
|
||||
the manifest quietly ignored. -->
|
||||
<link rel="manifest" href="/static/manifest.webmanifest" crossorigin="use-credentials"/>
|
||||
<link rel="stylesheet" href="/static/app.css"/>
|
||||
<!-- Not deferred: it applies the stored theme before first paint. -->
|
||||
|
||||
Reference in New Issue
Block a user