vole
Clone
Read only, no credentials:
https://rodent.software/vole.git
Write over SSH with a registered key:
git clone ssh://git@rodent.software:2222/vole.git
A free and open-source forge that lives in your Git repo.
Validate the configuration and harden what a deployment creates
ed1812123cd9e9df0f34fdaf0d2511a340c2aaa6 · Keenan Nemetz · 13d ago · parent 13cff33f
Validate the configuration and harden what a deployment creates Two ways an instance used to run in a state it should have refused. The configuration was never checked. `dev_login` was guarded only by a loopback listener, so a proxy that rewrites Host to 127.0.0.1 inherited a credential-free session; open registration could be enabled on a plain-HTTP origin WebAuthn would never accept; a public_url could carry a path or credentials; a size could be negative. Config.Validate now runs before anything is opened and reports every problem at once, and dev_login requires an explicit dev_only opt-in next to a loopback listener with no public address. The per-request check no longer trusts Host at all: it must name the configured listener, and a request carrying a forwarding header gets no session. The files were never checked either. directories were created 0755 and the audit log 0644, and a file keeps the mode it was created with, so an upgraded instance stayed exposed. internal/secure creates secrets at 0600 in directories at 0700, tightens what an existing install left, keeps group access for a shared backup group, and refuses to start when a mode cannot be changed. Every audit writer and the account backup go through it. The service unit drops all capabilities, sets UMask=0027, disables core dumps, hides other processes and devices, and restricts address families; docs/operations.md now states the production baseline.
forge/config/validate.go +278
| @@ -0,0 +1,278 @@ | |||
| 1 | package config | ||
| 2 | |||
| 3 | // Validation of the configuration the server is about to run with. | ||
| 4 | // | ||
| 5 | // The settings in this file are not independent: a development login behind a | ||
| 6 | // public URL, an HTTPS listener with no name to certify, a self-registration | ||
| 7 | // form on an origin WebAuthn will not accept. Each of those used to be a warning | ||
| 8 | // at best, and a warning an operator never reads is not a control. Validate is | ||
| 9 | // the reason `vole serve` refuses to start instead. | ||
| 10 | // | ||
| 11 | // It is deliberately strict about shape and permissive about values: a hostname | ||
| 12 | // may be anything DNS allows, but it may not be a URL, and a size may be any | ||
| 13 | // non-negative number, but not a negative one. | ||
| 14 | |||
| 15 | import ( | ||
| 16 | "errors" | ||
| 17 | "fmt" | ||
| 18 | "net" | ||
| 19 | "net/url" | ||
| 20 | "regexp" | ||
| 21 | "strconv" | ||
| 22 | "strings" | ||
| 23 | |||
| 24 | "github.com/rodent-software/vole/forge/access" | ||
| 25 | ) | ||
| 26 | |||
| 27 | // accountNameRE is the roster's account-name rule. It is duplicated here rather | ||
| 28 | // than imported because config is the leaf package the rest of the instance | ||
| 29 | // builds on; TestAccountNameRuleMatchesRoster keeps the two in step. | ||
| 30 | var accountNameRE = regexp.MustCompile(`^[a-z0-9][a-z0-9_-]{1,31}$`) | ||
| 31 | |||
| 32 | // dnsNameRE is a host name without a trailing dot: one or more labels, each | ||
| 33 | // starting and ending alphanumeric. | ||
| 34 | var dnsNameRE = regexp.MustCompile(`^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?(\.[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?)*$`) | ||
| 35 | |||
| 36 | // emailRE is not RFC 5322. It is a check that the value is a single address and | ||
| 37 | // not a list, a display name, or a line of something else that reached the file. | ||
| 38 | var emailRE = regexp.MustCompile(`^[^@\s,;<>"]+@[^@\s,;<>"]+\.[^@\s,;<>"]+$`) | ||
| 39 | |||
| 40 | // Validate reports every reason this configuration must not start. It returns an | ||
| 41 | // error that names all of them at once, because an operator fixing a config file | ||
| 42 | // should not have to restart the service to find the next problem. | ||
| 43 | func (c *Config) Validate() error { | ||
| 44 | var problems []string | ||
| 45 | add := func(format string, args ...any) { | ||
| 46 | problems = append(problems, fmt.Sprintf(format, args...)) | ||
| 47 | } | ||
| 48 | |||
| 49 | // Listeners. | ||
| 50 | httpHost, _, httpErr := splitAddr("http_addr", c.HTTPAddr) | ||
| 51 | if httpErr != nil { | ||
| 52 | add("%v", httpErr) | ||
| 53 | } | ||
| 54 | if _, _, err := splitAddr("ssh_addr", c.SSHAddr); err != nil { | ||
| 55 | add("%v", err) | ||
| 56 | } | ||
| 57 | if strings.TrimSpace(c.HTTPSAddr) != "" { | ||
| 58 | if _, _, err := splitAddr("https_addr", c.HTTPSAddr); err != nil { | ||
| 59 | add("%v", err) | ||
| 60 | } | ||
| 61 | } | ||
| 62 | |||
| 63 | // The public URL is an origin, not a link: WebAuthn binds credentials to it, | ||
| 64 | // and every generated link is built by appending a path to it. | ||
| 65 | public, err := ParsePublicURL(c.PublicURL) | ||
| 66 | if err != nil { | ||
| 67 | add("%v", err) | ||
| 68 | } | ||
| 69 | if c.PagesDomain != "" && !validDNSSuffix(c.PagesDomain) { | ||
| 70 | add("pages_domain must be a bare DNS suffix such as %q (lowercase labels, no scheme, port or path), got %q", | ||
| 71 | "example.com", c.PagesDomain) | ||
| 72 | } | ||
| 73 | if c.PagesRef != "" && !validRefName(c.PagesRef) { | ||
| 74 | add("pages_ref must be a branch name, got %q", c.PagesRef) | ||
| 75 | } | ||
| 76 | |||
| 77 | // Development login is the one switch that hands out a session without a | ||
| 78 | // credential. Three things have to agree before it is allowed: the operator | ||
| 79 | // said the instance is a development instance, nothing public is configured, | ||
| 80 | // and the listener could not be reached from anywhere but the machine. | ||
| 81 | if name := strings.TrimSpace(c.DevLogin); name != "" { | ||
| 82 | switch { | ||
| 83 | case !c.DevOnly: | ||
| 84 | add("dev_login requires dev_only = true: development login signs a browser in without a credential and must never be reachable from a public instance") | ||
| 85 | case !accountNameRE.MatchString(name): | ||
| 86 | add("dev_login must name an account (lowercase letters, digits, dash, underscore), got %q", name) | ||
| 87 | case strings.TrimSpace(c.HTTPSAddr) != "": | ||
| 88 | add("dev_login cannot be combined with https_addr: that listener is reachable from the network") | ||
| 89 | case public != nil && !loopbackHost(public.Hostname()): | ||
| 90 | add("dev_login cannot be combined with a public_url that is not loopback (%q): a published instance is not a development instance", c.PublicURL) | ||
| 91 | case httpErr == nil && !loopbackHost(httpHost): | ||
| 92 | add("dev_login requires http_addr on a loopback address, got %q", c.HTTPAddr) | ||
| 93 | } | ||
| 94 | } | ||
| 95 | |||
| 96 | // Browser registration is only meaningful on an origin a browser will trust | ||
| 97 | // with a passkey, and WebAuthn requires HTTPS for everything but localhost. | ||
| 98 | if c.OpenRegistration { | ||
| 99 | switch { | ||
| 100 | case public == nil: | ||
| 101 | add("open_registration requires public_url: registration is a browser flow on the page's origin") | ||
| 102 | case public.Scheme != "https": | ||
| 103 | add("open_registration requires an https public_url, got %q", c.PublicURL) | ||
| 104 | } | ||
| 105 | } | ||
| 106 | if role := strings.TrimSpace(c.OpenRegistrationRole); role != "" { | ||
| 107 | r, err := access.ParseRole(role) | ||
| 108 | if err != nil { | ||
| 109 | add("open_registration_role: %v", err) | ||
| 110 | } else if r.AtLeast(access.Admin) { | ||
| 111 | add("open_registration_role must not be %q: self-registration cannot grant instance administration", role) | ||
| 112 | } | ||
| 113 | } | ||
| 114 | |||
| 115 | // Sizes and limits. Zero means "no bound" for the push limits and "default" | ||
| 116 | // for the rest, which the doc comments already explain; negative is always a | ||
| 117 | // mistake. | ||
| 118 | for _, s := range []struct { | ||
| 119 | name string | ||
| 120 | v int64 | ||
| 121 | }{ | ||
| 122 | {"max_blob_bytes", c.MaxBlobBytes}, | ||
| 123 | {"receive_max_bytes", c.ReceiveMaxBytes}, | ||
| 124 | {"lfs_object_max_bytes", c.LFSObjectMaxBytes}, | ||
| 125 | {"lfs_warn_bytes", c.LFSWarnBytes}, | ||
| 126 | {"pages_cache_bytes", c.PagesCacheBytes}, | ||
| 127 | } { | ||
| 128 | if s.v < 0 { | ||
| 129 | add("%s must not be negative, got %d", s.name, s.v) | ||
| 130 | } | ||
| 131 | } | ||
| 132 | if c.MaxBlobBytes > 0 && c.ReceiveMaxBytes > 0 && c.ReceiveMaxBytes < c.MaxBlobBytes { | ||
| 133 | add("receive_max_bytes (%d) is smaller than max_blob_bytes (%d), so no push containing a permitted file could be accepted", | ||
| 134 | c.ReceiveMaxBytes, c.MaxBlobBytes) | ||
| 135 | } | ||
| 136 | if c.LeaseSeconds < 0 || c.LeaseSeconds > 3600 { | ||
| 137 | add("lease_seconds must be between 0 and 3600, got %d: a longer lease hands a dead runner's job to nobody", c.LeaseSeconds) | ||
| 138 | } | ||
| 139 | if c.MaxJobsPerRepo < 0 { | ||
| 140 | add("max_jobs_per_repo must not be negative, got %d", c.MaxJobsPerRepo) | ||
| 141 | } | ||
| 142 | if c.PagesProcs < 0 || c.PagesProcs > 64 { | ||
| 143 | add("pages_procs must be between 0 and 64, got %d", c.PagesProcs) | ||
| 144 | } | ||
| 145 | if c.GitHTTPMaxConcurrent < 0 { | ||
| 146 | add("git_http_max_concurrent must not be negative, got %d", c.GitHTTPMaxConcurrent) | ||
| 147 | } | ||
| 148 | if c.GitHTTPMaxPerClient < 0 { | ||
| 149 | add("git_http_max_per_client must not be negative, got %d", c.GitHTTPMaxPerClient) | ||
| 150 | } | ||
| 151 | if c.GitHTTPTimeoutSeconds < 0 { | ||
| 152 | add("git_http_timeout_seconds must not be negative, got %d", c.GitHTTPTimeoutSeconds) | ||
| 153 | } | ||
| 154 | |||
| 155 | // ACME. The certificate authority gets one address to complain to and one | ||
| 156 | // directory to fetch, and a mistyped directory URL is a failed renewal weeks | ||
| 157 | // after the typo. | ||
| 158 | if e := strings.TrimSpace(c.ACMEEmail); e != "" && !emailRE.MatchString(e) { | ||
| 159 | add("acme_email must be a single email address, got %q", e) | ||
| 160 | } | ||
| 161 | for _, d := range c.ACMEDomains { | ||
| 162 | if !validDNSSuffix(d) || !strings.Contains(d, ".") { | ||
| 163 | add("acme_domains: %q is not a fully qualified host name", d) | ||
| 164 | } | ||
| 165 | } | ||
| 166 | if raw := strings.TrimSpace(c.ACMEDirectoryURL); raw != "" { | ||
| 167 | u, err := url.Parse(raw) | ||
| 168 | switch { | ||
| 169 | case err != nil: | ||
| 170 | add("acme_directory_url: %v", err) | ||
| 171 | case u.Scheme != "https": | ||
| 172 | add("acme_directory_url must be https, got %q", raw) | ||
| 173 | case u.Host == "": | ||
| 174 | add("acme_directory_url must name a host, got %q", raw) | ||
| 175 | case u.RawQuery != "" || u.Fragment != "": | ||
| 176 | add("acme_directory_url must not carry a query or fragment, got %q", raw) | ||
| 177 | } | ||
| 178 | } | ||
| 179 | if strings.TrimSpace(c.HTTPSAddr) != "" && len(c.ACMEDomains) == 0 && | ||
| 180 | (public == nil || public.Hostname() == "") { | ||
| 181 | add("https_addr needs acme_domains or an https public_url: without a name there is no certificate to obtain") | ||
| 182 | } | ||
| 183 | |||
| 184 | if len(problems) == 0 { | ||
| 185 | return nil | ||
| 186 | } | ||
| 187 | return errors.New("invalid configuration:\n - " + strings.Join(problems, "\n - ")) | ||
| 188 | } | ||
| 189 | |||
| 190 | // ParsePublicURL validates an instance's canonical browser origin. An empty value | ||
| 191 | // is valid: an instance may have no browser entry point. A trailing slash is | ||
| 192 | // ignored by BaseURL, so it is accepted here. | ||
| 193 | func ParsePublicURL(raw string) (*url.URL, error) { | ||
| 194 | raw = strings.TrimSpace(raw) | ||
| 195 | if raw == "" { | ||
| 196 | return nil, nil | ||
| 197 | } | ||
| 198 | u, err := url.Parse(raw) | ||
| 199 | if err != nil { | ||
| 200 | return nil, fmt.Errorf("public_url: %v", err) | ||
| 201 | } | ||
| 202 | switch { | ||
| 203 | case u.Scheme != "https" && u.Scheme != "http": | ||
| 204 | return nil, fmt.Errorf("public_url must start with https:// or http://, got %q", raw) | ||
| 205 | case u.User != nil: | ||
| 206 | return nil, fmt.Errorf("public_url must not carry a user or password: %q", raw) | ||
| 207 | case u.RawQuery != "" || u.Fragment != "": | ||
| 208 | return nil, fmt.Errorf("public_url must not carry a query or fragment: %q", raw) | ||
| 209 | case u.Path != "" && u.Path != "/": | ||
| 210 | return nil, fmt.Errorf("public_url must be an origin with no path, got %q", raw) | ||
| 211 | case u.Hostname() == "": | ||
| 212 | return nil, fmt.Errorf("public_url must name a host: %q", raw) | ||
| 213 | } | ||
| 214 | if p := u.Port(); p != "" { | ||
| 215 | if n, err := strconv.Atoi(p); err != nil || n < 1 || n > 65535 { | ||
| 216 | return nil, fmt.Errorf("public_url has an invalid port: %q", p) | ||
| 217 | } | ||
| 218 | } | ||
| 219 | if !validDNSSuffix(u.Hostname()) && net.ParseIP(u.Hostname()) == nil { | ||
| 220 | return nil, fmt.Errorf("public_url host %q is not a host name or address", u.Hostname()) | ||
| 221 | } | ||
| 222 | return u, nil | ||
| 223 | } | ||
| 224 | |||
| 225 | // splitAddr parses host:port as the listeners need it. An empty host is valid and | ||
| 226 | // means every interface; the port must be a number, because a listener whose port | ||
| 227 | // is a name is a listener that can be steered by /etc/services. | ||
| 228 | func splitAddr(name, addr string) (host string, port int, err error) { | ||
| 229 | addr = strings.TrimSpace(addr) | ||
| 230 | host, p, err := net.SplitHostPort(addr) | ||
| 231 | if err != nil { | ||
| 232 | return "", 0, fmt.Errorf("%s must be host:port, got %q: %v", name, addr, err) | ||
| 233 | } | ||
| 234 | n, err := strconv.Atoi(p) | ||
| 235 | if err != nil || n < 0 || n > 65535 { | ||
| 236 | return "", 0, fmt.Errorf("%s has an invalid port %q", name, p) | ||
| 237 | } | ||
| 238 | return host, n, nil | ||
| 239 | } | ||
| 240 | |||
| 241 | // loopbackHost is the single definition of "only reachable from this machine" | ||
| 242 | // that both validation and the request check use. | ||
| 243 | func loopbackHost(host string) bool { | ||
| 244 | if strings.EqualFold(host, "localhost") { | ||
| 245 | return true | ||
| 246 | } | ||
| 247 | ip := net.ParseIP(strings.Trim(host, "[]")) | ||
| 248 | return ip != nil && ip.IsLoopback() | ||
| 249 | } | ||
| 250 | |||
| 251 | // validDNSSuffix accepts a bare lower-case DNS name. It is used for pages_domain, | ||
| 252 | // where a visitor's Host header is matched against the suffix, so a value with a | ||
| 253 | // slash or a port would silently match nothing. | ||
| 254 | func validDNSSuffix(s string) bool { | ||
| 255 | s = strings.TrimSpace(s) | ||
| 256 | if s == "" || len(s) > 253 || strings.HasSuffix(s, ".") { | ||
| 257 | return false | ||
| 258 | } | ||
| 259 | return dnsNameRE.MatchString(s) | ||
| 260 | } | ||
| 261 | |||
| 262 | // validRefName rejects a pages_ref git would refuse, plus the shapes that are | ||
| 263 | // legal but never a branch: leading dash, dot components, control characters. | ||
| 264 | func validRefName(name string) bool { | ||
| 265 | name = strings.TrimSpace(name) | ||
| 266 | if name == "" || strings.HasPrefix(name, "-") || strings.HasPrefix(name, "/") || | ||
| 267 | strings.HasSuffix(name, "/") || strings.HasSuffix(name, ".lock") || | ||
| 268 | strings.ContainsAny(name, " ~^:?*[\\") || strings.Contains(name, "..") || | ||
| 269 | strings.Contains(name, "@{") { | ||
| 270 | return false | ||
| 271 | } | ||
| 272 | for _, r := range name { | ||
| 273 | if r < 0x20 || r == 0x7f { | ||
| 274 | return false | ||
| 275 | } | ||
| 276 | } | ||
| 277 | return true | ||
| 278 | } | ||