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
+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