Sign in

Search issue titles and bodies, change requests and comments. Private repositories you cannot read are not included.

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 }