Fixes from the audit: input checks, apply order, sign-in limits

- The server endpoint must be a plain host name or IP address. It is
  written into client configs as is, so a newline could add lines such
  as PreUp, which wg-quick runs as root on the client.
- Listen addresses and the session length (1–720 hours) are checked.
  Before web settings or a restore are saved, the server tries the new
  listen addresses and certificate files, so a value it cannot start
  with is refused instead of stopping the service at the next restart.
- Kernel applies run one at a time and read the config once it is
  their turn, so an older config can no longer be applied last.
- Pending passkey sign-ins are capped: 10 per address, 1000 in total.
- Behind a local proxy, the last X-Forwarded-For entry is the client;
  earlier ones come from the client and are ignored.
- With LAN access off, peers are also kept from the IPv6 networks on
  the uplink, not only from its private IPv4 networks.
- A change that leaves no user with a password is refused, and so is a
  backup without one or from a newer version.
This commit is contained in:
Daniel Redetzke
2026-10-05 23:07:30 +03:00
parent aa4ca20296
commit 3e8dba6072
9 changed files with 485 additions and 28 deletions
+73 -3
View File
@@ -3,15 +3,18 @@ package main
import (
"cmp"
"context"
"crypto/tls"
"encoding/json"
"errors"
"fmt"
"io"
"log/slog"
"net"
"net/http"
"net/netip"
"slices"
"strings"
"syscall"
"time"
)
@@ -30,6 +33,7 @@ type App struct {
updates *Updater // nil in tests
started time.Time
shutdown func() // graceful stop; systemd restarts the service
webAddrs []string // the addresses the web server listens on now
}
// --- helpers ---
@@ -1090,11 +1094,15 @@ func (a *App) patchSettings(w http.ResponseWriter, r *http.Request) {
b, _ := json.Marshal(w)
return string(b)
}
before := listen()
before, oldWeb := listen(), c.Web
if err := field(m, "web", &c.Web); err != nil {
return err
}
restart = listen() != before
if restart = listen() != before; restart {
if err := a.checkWebStart(oldWeb, c.Web); err != nil {
return err
}
}
if err := field(m, "stats", &c.Stats); err != nil {
return err
}
@@ -1236,7 +1244,20 @@ func (a *App) restore(w http.ResponseWriter, r *http.Request) {
writeErr(w, badRequest("this file has no server key; is it a backup of this app?"))
return
}
if err := a.store.Update(func(c *Config) error { *c = in; return nil }); err != nil {
if in.Version > configVersion {
writeErr(w, badRequest("this backup is from a newer version of %s; update this server first", appName))
return
}
in.applyDefaults()
if !in.passwordSet() {
writeErr(w, badRequest("this backup has no user with a password; restoring it would lock everyone out"))
return
}
if err := a.store.Update(func(c *Config) error {
old := c.Web
*c = in
return a.checkWebStart(old, c.Web)
}); err != nil {
writeErr(w, err)
return
}
@@ -1244,6 +1265,55 @@ func (a *App) restore(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "applyError": a.apply(), "restartRequired": true})
}
// checkWebStart refuses web settings the service could not start with: an
// address it cannot listen on, or certificate files it cannot read. The
// service would stop at the next restart, and the web interface and the API
// with it.
func (a *App) checkWebStart(old, next WebConfig) error {
if err := validateListen(next.Listen, "listen address", false); err != nil {
return &userError{err.Error()}
}
if err := validateListen(next.HTTPListen, "HTTP listen address", true); err != nil {
return &userError{err.Error()}
}
if next.TLS.Mode == "files" && next.TLS != old.TLS {
if _, err := tls.LoadX509KeyPair(next.TLS.CertFile, next.TLS.KeyFile); err != nil {
return badRequest("the certificate files cannot be used: %v", err)
}
}
addrs := []string{next.Listen}
if next.HTTPListen != "" && next.TLS.Mode != "off" {
addrs = append(addrs, next.HTTPListen)
}
for _, addr := range addrs {
if err := a.canListen(addr); err != nil {
return badRequest("cannot listen on %s: %v", addr, err)
}
}
return nil
}
// canListen tries to listen on addr. An address the service listens on now,
// or one whose port it holds, is fine: it is free again after the restart.
func (a *App) canListen(addr string) error {
if slices.Contains(a.webAddrs, addr) {
return nil
}
ln, err := net.Listen("tcp", addr)
if err == nil {
return ln.Close()
}
if errors.Is(err, syscall.EADDRINUSE) {
_, port, _ := net.SplitHostPort(addr)
for _, own := range a.webAddrs {
if _, p, _ := net.SplitHostPort(own); p == port {
return nil
}
}
}
return err
}
// applyRuntime applies the settings that take effect without a restart: log
// level and log rotation. Traffic retention is read by the stats sampler.
func (a *App) applyRuntime(c *Config) {
+1 -1
View File
@@ -1988,7 +1988,7 @@
fieldEl('up6', 'IPv6 uplink interface', h('input', { id: 'up6', class: 'mono', value: draft.uplinkV6, placeholder: 'auto: ' + (srv.detectedUplinkV6 || 'none found'), onInput: str('uplinkV6') })),
cb('nat', 'Masquerade (NAT) peer traffic to the internet'),
cb('peerToPeer', 'Allow peers to reach each other'),
cb('lanAccess', 'Allow peers to reach the server\'s LAN', 'Private networks on the uplink interface'),
cb('lanAccess', 'Allow peers to reach the server\'s LAN', 'Private IPv4 and the IPv6 networks on the uplink interface'),
cb('openPort', 'Accept UDP ' + draft.listenPort + ' in the input chain'))),
h('section', { class: 'card', 'aria-labelledby': 'ky' },
+7 -2
View File
@@ -287,9 +287,14 @@ func remoteIP(r *http.Request) string {
host = r.RemoteAddr
}
// Behind a local reverse proxy the real client is in X-Forwarded-For.
// The proxy appends the address it saw, so only the last entry counts:
// earlier ones come from the client and can be anything.
if ip := net.ParseIP(host); ip != nil && ip.IsLoopback() {
if xff := r.Header.Get("X-Forwarded-For"); xff != "" {
return strings.TrimSpace(strings.Split(xff, ",")[0])
if xff := r.Header.Values("X-Forwarded-For"); len(xff) > 0 {
list := strings.Split(xff[len(xff)-1], ",")
if last := strings.TrimSpace(list[len(list)-1]); net.ParseIP(last) != nil {
return last
}
}
}
return host
+40 -1
View File
@@ -11,6 +11,7 @@ import (
"path/filepath"
"regexp"
"slices"
"strconv"
"strings"
"sync"
"syscall"
@@ -76,8 +77,29 @@ const (
minLogFiles, maxLogFiles = 1, 100
minHourlyHours, maxHourlyHrs = 24, 24 * 31
minDailyDays, maxDailyDays = 7, 3660
minSessionHours = 1
maxSessionHours = 30 * 24
)
// validateListen checks a listen address like ":443" or "192.0.2.1:443".
// Empty is allowed when optional (the HTTP listener is then off).
func validateListen(addr, field string, optional bool) error {
if addr == "" && optional {
return nil
}
host, port, err := net.SplitHostPort(addr)
if err != nil {
return fmt.Errorf("%s %q must look like :443 or 192.0.2.1:443", field, addr)
}
if n, err := strconv.Atoi(port); err != nil || n < 1 || n > 65535 {
return fmt.Errorf("%s %q: the port must be 1–65535", field, addr)
}
if host != "" && host != "localhost" && checkEndpoint(host) != nil {
return fmt.Errorf("%s %q: %q is not an IP address or host name", field, addr, host)
}
return nil
}
type WebConfig struct {
Listen string `json:"listen"` // HTTPS (or HTTP when tls.mode is "off") listen address
HTTPListen string `json:"httpListen"` // plain HTTP for ACME http-01 and redirects; "" disables
@@ -364,7 +386,9 @@ func (c *Config) validate() error {
if v6.Masked() != v6 {
return fmt.Errorf("IPv6 network must be the network address, e.g. %s", v6.Masked())
}
if s.Endpoint != "" && strings.ContainsAny(s.Endpoint, " /:") && net.ParseIP(s.Endpoint) == nil {
// The endpoint is written into client configs as is, so it must be a
// plain host name or IP: anything else could add lines to them.
if s.Endpoint != "" && checkEndpoint(s.Endpoint) != nil {
return errors.New("endpoint must be a host name or IP address without port")
}
if err := validateHostList(s.ClientDefaults.DNS, "DNS", false); err != nil {
@@ -397,6 +421,15 @@ func (c *Config) validate() error {
if _, ok := updateSources[c.Updates.Source]; !ok {
return fmt.Errorf("update source must be gitea or github")
}
if err := validateListen(c.Web.Listen, "listen address", false); err != nil {
return err
}
if err := validateListen(c.Web.HTTPListen, "HTTP listen address", true); err != nil {
return err
}
if h := c.Web.SessionHours; h < minSessionHours || h > maxSessionHours {
return fmt.Errorf("session length must be %d–%d hours", minSessionHours, maxSessionHours)
}
switch c.Web.TLS.Mode {
case "acme":
if c.Web.TLS.Domain == "" {
@@ -586,6 +619,12 @@ func (s *Store) Update(fn func(c *Config) error) error {
s.mu.Unlock()
return &userError{err.Error()}
}
// With no user left (applyDefaults then adds an "admin" without a
// password), nobody could sign in until someone ran "passwd" on the server.
if old.passwordSet() && !next.passwordSet() {
s.mu.Unlock()
return &userError{"this would leave no user with a password, and nobody could sign in"}
}
if err := writeFileAtomic(s.path, next, 0o600); err != nil {
s.mu.Unlock()
return err
+29
View File
@@ -4,6 +4,7 @@ import (
"log/slog"
"net/netip"
"os"
"slices"
"strings"
"sync"
"time"
@@ -40,6 +41,28 @@ type Kernel interface {
Close() error
}
// lanBlock picks, from the networks on the uplinks, the ones peers must not
// reach while LAN access is off: private IPv4 networks, and IPv6 networks
// except link-local, since a home LAN uses global IPv6 addresses. IPv6
// prefixes shorter than /48 are left out: they are no LAN.
func lanBlock(nets []netip.Prefix) []netip.Prefix {
var out []netip.Prefix
for _, p := range nets {
a := p.Addr().Unmap()
p = netip.PrefixFrom(a, min(p.Bits(), a.BitLen())).Masked()
switch {
case a.Is4() && !a.IsPrivate():
continue
case a.Is6() && (a.IsLinkLocalUnicast() || a.IsLoopback() || p.Bits() < 48):
continue
}
if !slices.Contains(out, p) {
out = append(out, p)
}
}
return out
}
// readSysctl returns the trimmed content of a /proc/sys file, or "".
func readSysctl(path string) string {
b, err := os.ReadFile(path)
@@ -56,6 +79,10 @@ type Reconciler struct {
store *Store
trigger chan struct{}
// applyMu runs one apply at a time. Each reads the config once it holds
// the lock, so the last apply always uses the newest config.
applyMu sync.Mutex
mu sync.Mutex
lastErr error
lastApply time.Time
@@ -76,6 +103,8 @@ func (r *Reconciler) Kick() {
// ApplyNow applies synchronously and returns the result, so an API call can
// report kernel errors to the user.
func (r *Reconciler) ApplyNow() error {
r.applyMu.Lock()
defer r.applyMu.Unlock()
err := r.kernel.Apply(r.store.Get())
r.mu.Lock()
r.lastErr, r.lastApply = err, time.Now()
+16 -15
View File
@@ -217,7 +217,8 @@ func (k *linuxKernel) Apply(c *Config) error {
if c.Server.IPv6Enabled {
_ = os.WriteFile("/proc/sys/net/ipv6/conf/all/forwarding", []byte("1"), 0o644)
}
return applyFirewall(c, k.Uplink(c, false), k.Uplink(c, true), lanNetworks(k.Uplink(c, false)))
up4, up6 := k.Uplink(c, false), k.Uplink(c, true)
return applyFirewall(c, up4, up6, lanNetworks(up4, up6))
}
func (k *linuxKernel) Sample(iface string) ([]PeerSample, error) {
@@ -264,27 +265,27 @@ func (k *linuxKernel) Uplink(c *Config, v6 bool) string {
return l.Attrs().Name
}
// lanNetworks returns the private IPv4 networks on the uplink, used to block
// peers from the server's LAN when LAN access is off.
func lanNetworks(uplink string) []netip.Prefix {
if uplink == "" {
return nil
// lanNetworks returns the LAN networks on the IPv4 and IPv6 uplinks (see
// lanBlock), used to block peers from the server's LAN when LAN access is off.
func lanNetworks(uplinks ...string) []netip.Prefix {
var nets []netip.Prefix
for i, uplink := range uplinks {
if uplink == "" || slices.Contains(uplinks[:i], uplink) {
continue
}
l, err := netlink.LinkByName(uplink)
if err != nil {
return nil
}
addrs, _ := netlink.AddrList(l, netlink.FAMILY_V4)
var out []netip.Prefix
for _, a := range addrs {
if !a.IP.IsPrivate() {
continue
}
addrs, _ := netlink.AddrList(l, netlink.FAMILY_ALL)
for _, a := range addrs {
ones, _ := a.Mask.Size()
ip, _ := netip.AddrFromSlice(a.IP.To4())
out = append(out, netip.PrefixFrom(ip, ones).Masked())
if ip, ok := netip.AddrFromSlice(a.IP); ok {
nets = append(nets, netip.PrefixFrom(ip.Unmap(), ones))
}
return out
}
}
return lanBlock(nets)
}
// publicAddr reports the uplink's address for the health check: the first
+4
View File
@@ -224,6 +224,10 @@ func run(configPath string) error {
app := &App{
store: store, kernel: kernel, recon: recon, stats: stats, speeds: speeds, auth: auth, tls: webTLS,
logPath: logPath, logw: logw, geo: geo, updates: newUpdater(cfg.Updates), started: time.Now(), shutdown: shutdown,
webAddrs: []string{cfg.Web.Listen},
}
if cfg.Web.HTTPListen != "" && cfg.Web.TLS.Mode != "off" {
app.webAddrs = append(app.webAddrs, cfg.Web.HTTPListen)
}
var wg sync.WaitGroup
+268
View File
@@ -6,6 +6,7 @@ import (
"errors"
"fmt"
"io"
"net"
"net/http"
"net/http/cookiejar"
"net/http/httptest"
@@ -79,6 +80,15 @@ func TestValidate(t *testing.T) {
"bad port": func(c *Config) { c.Server.ListenPort = 70000 },
"unmasked net": func(c *Config) { c.Server.IPv4 = "10.84.12.5/24" },
"update source": func(c *Config) { c.Updates.Source = "sourceforge" },
// The endpoint goes into client configs: no extra lines.
"endpoint newline": func(c *Config) { c.Server.Endpoint = "vpn.example.net\n[Interface]\nPreUp=id;#" },
"endpoint tab": func(c *Config) { c.Server.Endpoint = "vpn.example.net\tx" },
"endpoint port": func(c *Config) { c.Server.Endpoint = "vpn.example.net:51820" },
"listen": func(c *Config) { c.Web.Listen = "not-an-address" },
"listen port": func(c *Config) { c.Web.Listen = ":70000" },
"http listen": func(c *Config) { c.Web.HTTPListen = "80" },
"session hours": func(c *Config) { c.Web.SessionHours = -1 },
"session too long": func(c *Config) { c.Web.SessionHours = 100000 },
} {
cc := c.clone()
mutate(cc)
@@ -86,6 +96,20 @@ func TestValidate(t *testing.T) {
t.Errorf("%s: expected an error", name)
}
}
for _, ep := range []string{"vpn.example.net", "203.0.113.7", "2001:db8::1"} {
cc := c.clone()
cc.Server.Endpoint = ep
if err := cc.validate(); err != nil {
t.Errorf("endpoint %q rejected: %v", ep, err)
}
}
for _, l := range []string{":443", "0.0.0.0:8443", "[::]:443", "localhost:8080"} {
cc := c.clone()
cc.Web.Listen = l
if err := cc.validate(); err != nil {
t.Errorf("listen %q rejected: %v", l, err)
}
}
}
func TestClientConfig(t *testing.T) {
@@ -1339,3 +1363,247 @@ func TestSpeeds(t *testing.T) {
t.Fatalf("kept %d points, want %d", n, speedPoints)
}
}
// signedInApp starts the API with a signed-in admin and returns the app
// and a call function.
func signedInApp(t *testing.T) (*App, func(method, path string, body any, want int) map[string]any) {
t.Helper()
dir := t.TempDir()
store, err := openStore(filepath.Join(dir, "config.json"))
if err != nil {
t.Fatal(err)
}
hash, _ := hashPassword("a long test password")
_ = store.Update(func(c *Config) error { c.Users[0].PasswordHash = hash; return nil })
k := &fakeKernel{}
st, _ := openStats(filepath.Join(dir, "stats.json"), store, k)
app := &App{store: store, kernel: k, recon: newReconciler(k, store), stats: st, auth: newAuth(store),
tls: &webTLS{}, logPath: filepath.Join(dir, "log.jsonl"), started: time.Now(), shutdown: func() {}}
srv := httptest.NewServer(app.routes())
t.Cleanup(srv.Close)
jar, _ := cookiejar.New(nil)
cl := &http.Client{Jar: jar}
call := func(method, path string, body any, want int) map[string]any {
t.Helper()
var rd io.Reader
if body != nil {
b, _ := json.Marshal(body)
rd = bytes.NewReader(b)
}
req, _ := http.NewRequest(method, srv.URL+"/api/v1"+path, rd)
req.Header.Set("Content-Type", "application/json")
resp, err := cl.Do(req)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
var out map[string]any
_ = json.NewDecoder(resp.Body).Decode(&out)
if resp.StatusCode != want {
t.Fatalf("%s %s: status %d, want %d: %v", method, path, resp.StatusCode, want, out)
}
return out
}
call("POST", "/auth/login", map[string]string{"username": "admin", "password": "a long test password"}, 200)
return app, call
}
// Web settings the service could not start with are refused before they
// are saved: a restart would otherwise take the web interface and the API
// down for good.
func TestWebSettingsCheck(t *testing.T) {
app, call := signedInApp(t)
web := func(change func(w *WebConfig)) map[string]any {
w := app.store.Get().Web
w.HTTPListen = ""
change(&w)
return map[string]any{"web": w}
}
call("PATCH", "/settings", web(func(w *WebConfig) { w.Listen = "not-an-address" }), 400)
call("PATCH", "/settings", web(func(w *WebConfig) { w.SessionHours = -1 }), 400)
call("PATCH", "/settings", web(func(w *WebConfig) {
w.TLS = TLSConfig{Mode: "files", CertFile: "/nonexistent/cert.pem", KeyFile: "/nonexistent/key.pem"}
}), 400)
// A port another program holds is refused; a free one is saved.
busy, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
defer busy.Close()
call("PATCH", "/settings", web(func(w *WebConfig) { w.Listen = busy.Addr().String() }), 400)
free, _ := net.Listen("tcp", "127.0.0.1:0")
addr := free.Addr().String()
free.Close()
call("PATCH", "/settings", web(func(w *WebConfig) { w.Listen = addr; w.TLS = TLSConfig{Mode: "off"} }), 200)
if app.store.Get().Web.Listen != addr {
t.Fatal("valid listen address not saved")
}
// The address the service listens on now is in use by itself: fine.
app.webAddrs = []string{busy.Addr().String()}
call("PATCH", "/settings", web(func(w *WebConfig) { w.Listen = busy.Addr().String() }), 200)
// Restore runs the same check.
backup := app.store.Get()
backup.Web.Listen = "not-an-address"
call("POST", "/restore", backup, 400)
}
func TestRemoteIP(t *testing.T) {
for _, c := range []struct {
remote string
xff []string
want string
}{
{"203.0.113.5:1234", nil, "203.0.113.5"},
{"203.0.113.5:1234", []string{"198.51.100.1"}, "203.0.113.5"}, // not from a local proxy
{"127.0.0.1:1234", []string{"198.51.100.1"}, "198.51.100.1"},
// The client sent its own header; the proxy appended the real address.
{"127.0.0.1:1234", []string{"1.2.3.4, 198.51.100.1"}, "198.51.100.1"},
{"127.0.0.1:1234", []string{"1.2.3.4", "198.51.100.1"}, "198.51.100.1"},
{"127.0.0.1:1234", []string{"garbage"}, "127.0.0.1"},
} {
r := httptest.NewRequest("GET", "/", nil)
r.RemoteAddr = c.remote
for _, v := range c.xff {
r.Header.Add("X-Forwarded-For", v)
}
if got := remoteIP(r); got != c.want {
t.Errorf("%s %v: got %s, want %s", c.remote, c.xff, got, c.want)
}
}
}
// Anyone can start a passkey sign-in, so pending ones are capped per
// address and in total.
func TestPasskeyLoginCap(t *testing.T) {
a := newAuth(nil)
start := func(id, ip string, expires time.Time) bool {
a.mu.Lock()
defer a.mu.Unlock()
return a.addPasskeyLoginLocked(id, &ceremony{ip: lockKey(ip), expires: expires})
}
later := time.Now().Add(ticketTTL)
for i := range maxPasskeyLoginsPerIP {
if !start(fmt.Sprint("a", i), "198.51.100.1", later) {
t.Fatalf("sign-in %d refused", i)
}
}
if start("a-more", "198.51.100.1", later) {
t.Fatal("too many sign-ins from one address accepted")
}
if !start("b0", "198.51.100.2", later) {
t.Fatal("another address refused")
}
// Expired ones make room again.
a.mfa.logins = map[string]*ceremony{}
start("old", "198.51.100.3", time.Now().Add(-time.Second))
if !start("new", "198.51.100.3", later) || len(a.mfa.logins) != 1 {
t.Fatalf("expired sign-in not dropped: %d pending", len(a.mfa.logins))
}
// In total, the oldest makes room.
a.mfa.logins = map[string]*ceremony{}
for i := range maxPasskeyLogins {
start(fmt.Sprint("c", i), fmt.Sprintf("10.0.%d.%d", i/250, i%250), later.Add(time.Duration(i)*time.Millisecond))
}
start("last", "192.0.2.1", later.Add(time.Hour))
if _, ok := a.mfa.logins["c0"]; ok || len(a.mfa.logins) != maxPasskeyLogins {
t.Fatalf("cap not kept: %d pending, oldest kept %v", len(a.mfa.logins), ok)
}
}
func TestLanBlock(t *testing.T) {
got := lanBlock([]netip.Prefix{
netip.MustParsePrefix("192.168.1.20/24"),
netip.MustParsePrefix("203.0.113.9/24"), // public IPv4: not a LAN
netip.MustParsePrefix("2001:db8:1:2::20/64"),
netip.MustParsePrefix("fd00:1:2:3::20/64"),
netip.MustParsePrefix("fe80::1/64"),
netip.MustParsePrefix("2001:db8::1/32"), // no LAN
netip.MustParsePrefix("192.168.1.30/24"), // same network twice
})
want := []netip.Prefix{
netip.MustParsePrefix("192.168.1.0/24"),
netip.MustParsePrefix("2001:db8:1:2::/64"),
netip.MustParsePrefix("fd00:1:2:3::/64"),
}
if !slices.Equal(got, want) {
t.Fatalf("got %v, want %v", got, want)
}
}
// A change that would leave no user with a password is refused: restoring
// a backup without users, or the last users deleting each other.
func TestNoUserLeftWithPassword(t *testing.T) {
app, call := signedInApp(t)
if err := app.store.Update(func(c *Config) error { c.Users = nil; return nil }); err == nil {
t.Fatal("removing every user was accepted")
}
if !app.store.Get().passwordSet() {
t.Fatal("password lost")
}
backup := app.store.Get()
backup.Users, backup.APITokens = nil, nil
call("POST", "/restore", backup, 400)
backup = app.store.Get()
backup.Version = configVersion + 1
call("POST", "/restore", backup, 400)
call("POST", "/restore", app.store.Get(), 200)
if !app.store.Get().passwordSet() {
t.Fatal("password lost")
}
}
// slowKernel records the configs it applied; the first apply takes a while.
type slowKernel struct {
fakeKernel
mu sync.Mutex
calls int
applied []string // peer names, per apply
}
func (k *slowKernel) Apply(c *Config) error {
k.mu.Lock()
k.calls++
first := k.calls == 1
k.mu.Unlock()
if first {
time.Sleep(200 * time.Millisecond)
}
var names []string
for _, p := range c.Peers {
names = append(names, p.Name)
}
k.mu.Lock()
k.applied = append(k.applied, strings.Join(names, ","))
k.mu.Unlock()
return nil
}
// Applies run one at a time, so a slow apply of an older config cannot
// finish after the newest one and undo it in the kernel.
func TestApplyOrder(t *testing.T) {
store, err := openStore(filepath.Join(t.TempDir(), "config.json"))
if err != nil {
t.Fatal(err)
}
k := &slowKernel{}
r := newReconciler(k, store)
var wg sync.WaitGroup
wg.Add(1)
go func() { defer wg.Done(); _ = r.ApplyNow() }() // the old config, slowly
time.Sleep(50 * time.Millisecond)
if err := store.Update(func(c *Config) error {
c.Peers = append(c.Peers, Peer{ID: newID(), Name: "phone", IPv4: serverIPv4(netip.MustParsePrefix(c.Server.IPv4)).Next().String()})
return nil
}); err != nil {
t.Fatal(err)
}
_ = r.ApplyNow()
wg.Wait()
if last := k.applied[len(k.applied)-1]; last != "phone" {
t.Fatalf("the kernel ended with %q, not the newest config; applies: %q", last, k.applied)
}
}
+42 -1
View File
@@ -217,6 +217,7 @@ type ticket struct {
type ceremony struct {
userID string // "" for a passkey sign-in
ip string // lockKey of who started a passkey sign-in
data *webauthn.SessionData
expires time.Time
}
@@ -551,12 +552,52 @@ func (a *App) loginPasskeyBegin(w http.ResponseWriter, r *http.Request) {
return
}
id := randomString(24)
ip := remoteIP(r)
a.auth.mu.Lock()
a.auth.mfa.logins[id] = &ceremony{data: data, expires: time.Now().Add(ticketTTL)}
ok := a.auth.addPasskeyLoginLocked(id, &ceremony{data: data, ip: lockKey(ip), expires: time.Now().Add(ticketTTL)})
a.auth.mu.Unlock()
if !ok {
writeJSON(w, http.StatusTooManyRequests, map[string]string{"error": errBusy.Error()})
return
}
writeJSON(w, http.StatusOK, map[string]any{"id": id, "options": opts})
}
// Anyone can start a passkey sign-in, so the pending ones are capped: per
// address, and in total, where the oldest makes room.
const (
maxPasskeyLogins = 1000
maxPasskeyLoginsPerIP = 10
)
// addPasskeyLoginLocked stores a started passkey sign-in, or reports false
// when its address has too many pending. a.mu must be held.
func (a *Auth) addPasskeyLoginLocked(id string, c *ceremony) bool {
now := time.Now()
var fromIP int
var oldestID string
for k, x := range a.mfa.logins {
if now.After(x.expires) {
delete(a.mfa.logins, k)
continue
}
if x.ip == c.ip {
fromIP++
}
if oldestID == "" || x.expires.Before(a.mfa.logins[oldestID].expires) {
oldestID = k
}
}
if fromIP >= maxPasskeyLoginsPerIP {
return false
}
if len(a.mfa.logins) >= maxPasskeyLogins {
delete(a.mfa.logins, oldestID)
}
a.mfa.logins[id] = c
return true
}
func (a *App) loginPasskeyFinish(w http.ResponseWriter, r *http.Request) {
id := r.URL.Query().Get("id")
ip := remoteIP(r)