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_test.go +267

@@ -0,0 +1,267 @@
1 package config
2
3 import (
4 "strings"
5 "testing"
6
7 "github.com/rodent-software/vole/forge/identity"
8 )
9
10 // TestDefaultsAreValid is the promise of a config file that does not exist:
11 // `vole serve` works out of the box, so the defaults must pass the same check a
12 // hand-written file does.
13 func TestDefaultsAreValid(t *testing.T) {
14 cfg, err := Load("")
15 if err != nil {
16 t.Fatal(err)
17 }
18 if err := cfg.Validate(); err != nil {
19 t.Fatalf("built-in defaults do not validate:\n%v", err)
20 }
21 }
22
23 func TestValidationRefusesUnsafeCombinations(t *testing.T) {
24 t.Parallel()
25 cases := []struct {
26 name string
27 tweak func(*Config)
28 want string
29 }{
30 {
31 name: "dev_login without dev_only",
32 tweak: func(c *Config) { c.DevLogin = "keenan" },
33 want: "dev_login requires dev_only",
34 },
35 {
36 name: "dev_login next to a public url",
37 tweak: func(c *Config) {
38 c.DevOnly, c.DevLogin = true, "keenan"
39 c.PublicURL = "https://forge.example.com"
40 },
41 want: "cannot be combined with a public_url",
42 },
43 {
44 name: "dev_login next to an https listener",
45 tweak: func(c *Config) {
46 c.DevOnly, c.DevLogin = true, "keenan"
47 c.HTTPSAddr = ":443"
48 c.ACMEDomains = []string{"forge.example.com"}
49 },
50 want: "cannot be combined with https_addr",
51 },
52 {
53 name: "dev_login on a public listener",
54 tweak: func(c *Config) {
55 c.DevOnly, c.DevLogin = true, "keenan"
56 c.HTTPAddr = "0.0.0.0:8080"
57 },
58 want: "loopback address",
59 },
60 {
61 name: "dev_login naming nothing",
62 tweak: func(c *Config) {
63 c.DevOnly, c.DevLogin = true, "Keenan!"
64 },
65 want: "must name an account",
66 },
67 {
68 name: "open_registration without a public url",
69 tweak: func(c *Config) { c.OpenRegistration = true },
70 want: "open_registration requires public_url",
71 },
72 {
73 name: "open_registration on plain http",
74 tweak: func(c *Config) {
75 c.OpenRegistration = true
76 c.PublicURL = "http://forge.example.com"
77 },
78 want: "requires an https public_url",
79 },
80 {
81 name: "self-registration granting admin",
82 tweak: func(c *Config) { c.OpenRegistrationRole = "admin" },
83 want: "must not be",
84 },
85 {
86 name: "an unknown self-registration role",
87 tweak: func(c *Config) { c.OpenRegistrationRole = "root" },
88 want: "unknown role",
89 },
90 {
91 name: "public_url with a path",
92 tweak: func(c *Config) { c.PublicURL = "https://forge.example.com/vole" },
93 want: "no path",
94 },
95 {
96 name: "public_url with credentials",
97 tweak: func(c *Config) { c.PublicURL = "https://user:pw@forge.example.com" },
98 want: "user or password",
99 },
100 {
101 name: "public_url with a query",
102 tweak: func(c *Config) { c.PublicURL = "https://forge.example.com/?x=1" },
103 want: "query or fragment",
104 },
105 {
106 name: "public_url with another scheme",
107 tweak: func(c *Config) { c.PublicURL = "ftp://forge.example.com" },
108 want: "must start with",
109 },
110 {
111 name: "pages_domain as a URL",
112 tweak: func(c *Config) { c.PagesDomain = "https://pages.example.com" },
113 want: "pages_domain",
114 },
115 {
116 name: "pages_domain with a port",
117 tweak: func(c *Config) { c.PagesDomain = "pages.example.com:8080" },
118 want: "pages_domain",
119 },
120 {
121 name: "pages_ref with a space",
122 tweak: func(c *Config) { c.PagesRef = "my pages" },
123 want: "pages_ref",
124 },
125 {
126 name: "a negative size",
127 tweak: func(c *Config) { c.LFSObjectMaxBytes = -1 },
128 want: "must not be negative",
129 },
130 {
131 name: "a push cap smaller than a file cap",
132 tweak: func(c *Config) {
133 c.MaxBlobBytes = 100 << 20
134 c.ReceiveMaxBytes = 1 << 10
135 },
136 want: "smaller than max_blob_bytes",
137 },
138 {
139 name: "a lease longer than an hour",
140 tweak: func(c *Config) { c.LeaseSeconds = 7200 },
141 want: "lease_seconds",
142 },
143 {
144 name: "an http listener",
145 tweak: func(c *Config) { c.HTTPAddr = "8080" },
146 want: "http_addr must be host:port",
147 },
148 {
149 name: "a named port",
150 tweak: func(c *Config) { c.SSHAddr = "127.0.0.1:ssh" },
151 want: "invalid port",
152 },
153 {
154 name: "https with no name to certify",
155 tweak: func(c *Config) { c.HTTPSAddr = ":443" },
156 want: "https_addr needs acme_domains",
157 },
158 {
159 name: "an acme email list",
160 tweak: func(c *Config) { c.ACMEEmail = "a@example.com, b@example.com" },
161 want: "acme_email",
162 },
163 {
164 name: "an acme domain that is a URL",
165 tweak: func(c *Config) {
166 c.HTTPSAddr = ":443"
167 c.ACMEDomains = []string{"https://forge.example.com"}
168 },
169 want: "acme_domains",
170 },
171 {
172 name: "an acme directory that is not https",
173 tweak: func(c *Config) { c.ACMEDirectoryURL = "http://acme.example.com/directory" },
174 want: "acme_directory_url must be https",
175 },
176 }
177 for _, c := range cases {
178 c := c
179 t.Run(c.name, func(t *testing.T) {
180 t.Parallel()
181 cfg, err := Load("")
182 if err != nil {
183 t.Fatal(err)
184 }
185 c.tweak(cfg)
186 err = cfg.Validate()
187 if err == nil {
188 t.Fatalf("%s was accepted", c.name)
189 }
190 if !strings.Contains(err.Error(), c.want) {
191 t.Fatalf("refusal did not say %q:\n%v", c.want, err)
192 }
193 })
194 }
195 }
196
197 // TestValidationAcceptsDevelopmentConfiguration: a working preview setup has to
198 // pass, or the guard becomes something people work around.
199 func TestValidationAcceptsDevelopmentConfiguration(t *testing.T) {
200 t.Parallel()
201 cfg, err := Load("")
202 if err != nil {
203 t.Fatal(err)
204 }
205 cfg.DevOnly = true
206 cfg.DevLogin = "keenan"
207 cfg.PublicURL = "http://127.0.0.1:8080"
208 if err := cfg.Validate(); err != nil {
209 t.Fatalf("a development instance was refused:\n%v", err)
210 }
211 }
212
213 func TestValidationAcceptsProductionConfiguration(t *testing.T) {
214 t.Parallel()
215 cfg, err := Load("")
216 if err != nil {
217 t.Fatal(err)
218 }
219 cfg.PublicURL = "https://forge.example.com/"
220 cfg.OpenRegistration = true
221 cfg.OpenRegistrationRole = "contribute"
222 cfg.HTTPSAddr = ":443"
223 cfg.HTTPAddr = ":80"
224 cfg.ACMEDomains = []string{"forge.example.com", "pages.example.com"}
225 cfg.ACMEEmail = "admin@example.com"
226 cfg.PagesDomain = "pages.example.com"
227 if err := cfg.Validate(); err != nil {
228 t.Fatalf("a production configuration was refused:\n%v", err)
229 }
230 }
231
232 func TestValidationReportsEveryProblem(t *testing.T) {
233 t.Parallel()
234 cfg, err := Load("")
235 if err != nil {
236 t.Fatal(err)
237 }
238 cfg.PagesDomain = "not a domain"
239 cfg.LeaseSeconds = -5
240 cfg.OpenRegistrationRole = "root"
241 verr := cfg.Validate()
242 if verr == nil {
243 t.Fatal("three bad settings were accepted")
244 }
245 for _, want := range []string{"pages_domain", "lease_seconds", "open_registration_role"} {
246 if !strings.Contains(verr.Error(), want) {
247 t.Errorf("one error did not name %s:\n%v", want, verr)
248 }
249 }
250 }
251
252 // TestAccountNameRuleMatchesRoster keeps the duplicated rule in step. config is
253 // the leaf package, so it does not import the roster just to ask whether a name
254 // could be an account.
255 func TestAccountNameRuleMatchesRoster(t *testing.T) {
256 t.Parallel()
257 names := []string{
258 "keenan", "ab", "a-b_c9", "", "a", "Keenan", "-keenan", "keenan-", "k" + strings.Repeat("x", 32),
259 "keenan!", "keenan smith", "1keenan",
260 }
261 for _, name := range names {
262 want := identity.ValidateName(name) == nil
263 if got := accountNameRE.MatchString(name); got != want {
264 t.Errorf("accountNameRE(%q) = %v, roster says %v", name, got, want)
265 }
266 }
267 }