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/git/vole.git

Write over SSH with a registered key:

git clone ssh://git@0.0.0.0: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.

internal/secure/secure_test.go +217

@@ -0,0 +1,217 @@
1 package secure
2
3 import (
4 "os"
5 "path/filepath"
6 "testing"
7 )
8
9 // mode reports the permission bits of path, which is what every test here is
10 // about.
11 func mode(t *testing.T, path string) os.FileMode {
12 t.Helper()
13 st, err := os.Stat(path)
14 if err != nil {
15 t.Fatal(err)
16 }
17 return st.Mode().Perm()
18 }
19
20 // TestHardenFileTightensAnInheritedMode is the upgrade case: a file created by an
21 // older Vole is 0644, and starting the instance must fix it.
22 func TestHardenFileTightensAnInheritedMode(t *testing.T) {
23 t.Parallel()
24 path := filepath.Join(t.TempDir(), "users.db")
25 if err := os.WriteFile(path, []byte("keys"), 0o644); err != nil {
26 t.Fatal(err)
27 }
28 // A umask can hide the mode we asked for, so set it explicitly.
29 if err := os.Chmod(path, 0o644); err != nil {
30 t.Fatal(err)
31 }
32 changed, err := HardenFile(path)
33 if err != nil {
34 t.Fatal(err)
35 }
36 if !changed {
37 t.Fatal("a 0644 file was reported as already correct")
38 }
39 if got := mode(t, path); got != FileMode {
40 t.Fatalf("mode = %04o, want %04o", got, FileMode)
41 }
42 // The content is untouched.
43 data, err := os.ReadFile(path)
44 if err != nil {
45 t.Fatal(err)
46 }
47 if string(data) != "keys" {
48 t.Fatalf("content = %q", data)
49 }
50 // And a second pass is a no-op, so startup does not churn.
51 changed, err = HardenFile(path)
52 if err != nil {
53 t.Fatal(err)
54 }
55 if changed {
56 t.Fatal("an already tight file was reported as changed")
57 }
58 }
59
60 // TestHardenDirKeepsGroupAccess: 0750 with a dedicated group is a deliberate
61 // deployment, so only world access is removed.
62 func TestHardenDirKeepsGroupAccess(t *testing.T) {
63 t.Parallel()
64 dir := t.TempDir()
65
66 group := filepath.Join(dir, "group")
67 if err := os.Mkdir(group, 0o750); err != nil {
68 t.Fatal(err)
69 }
70 if err := os.Chmod(group, 0o750); err != nil {
71 t.Fatal(err)
72 }
73 changed, err := HardenDir(group)
74 if err != nil {
75 t.Fatal(err)
76 }
77 if changed {
78 t.Fatal("0750 was tightened; group access is allowed")
79 }
80
81 world := filepath.Join(dir, "world")
82 if err := os.Mkdir(world, 0o755); err != nil {
83 t.Fatal(err)
84 }
85 if err := os.Chmod(world, 0o755); err != nil {
86 t.Fatal(err)
87 }
88 changed, err = HardenDir(world)
89 if err != nil {
90 t.Fatal(err)
91 }
92 if !changed {
93 t.Fatal("0755 was left world-accessible")
94 }
95 if got := mode(t, world); got&0o007 != 0 {
96 t.Fatalf("mode = %04o, still world-accessible", got)
97 }
98 }
99
100 func TestMkdirCreatesAndTightens(t *testing.T) {
101 t.Parallel()
102 dir := filepath.Join(t.TempDir(), "data", "nested")
103 if err := Mkdir(dir); err != nil {
104 t.Fatal(err)
105 }
106 if got := mode(t, dir); got != DirMode {
107 t.Fatalf("mode = %04o, want %04o", got, DirMode)
108 }
109 if err := os.Chmod(dir, 0o777); err != nil {
110 t.Fatal(err)
111 }
112 if err := Mkdir(dir); err != nil {
113 t.Fatal(err)
114 }
115 if got := mode(t, dir); got&0o007 != 0 {
116 t.Fatalf("mode = %04o, still world-accessible", got)
117 }
118 }
119
120 // TestOpenAppendTightensTheLogItWrites is the audit-log case: an existing log
121 // inherits its mode from whoever created it first.
122 func TestOpenAppendTightensTheLogItWrites(t *testing.T) {
123 t.Parallel()
124 path := filepath.Join(t.TempDir(), "audit.log")
125 if err := os.WriteFile(path, []byte("first\n"), 0o644); err != nil {
126 t.Fatal(err)
127 }
128 if err := os.Chmod(path, 0o644); err != nil {
129 t.Fatal(err)
130 }
131 f, err := OpenAppend(path)
132 if err != nil {
133 t.Fatal(err)
134 }
135 if _, err := f.WriteString("second\n"); err != nil {
136 t.Fatal(err)
137 }
138 f.Close()
139
140 if got := mode(t, path); got != FileMode {
141 t.Fatalf("mode = %04o, want %04o", got, FileMode)
142 }
143 data, err := os.ReadFile(path)
144 if err != nil {
145 t.Fatal(err)
146 }
147 if string(data) != "first\nsecond\n" {
148 t.Fatalf("log = %q; appending did not keep the earlier lines", data)
149 }
150 }
151
152 func TestOpenAppendCreatesAtFileMode(t *testing.T) {
153 t.Parallel()
154 path := filepath.Join(t.TempDir(), "new.log")
155 f, err := OpenAppend(path)
156 if err != nil {
157 t.Fatal(err)
158 }
159 f.Close()
160 if got := mode(t, path); got != FileMode {
161 t.Fatalf("mode = %04o, want %04o", got, FileMode)
162 }
163 }
164
165 // TestHardenReportsOnlyWhatChanged is what the startup log lists: an operator
166 // should read the paths that moved, not every secret the instance has.
167 func TestHardenReportsOnlyWhatChanged(t *testing.T) {
168 t.Parallel()
169 dir := t.TempDir()
170 loose := filepath.Join(dir, "loose.db")
171 tight := filepath.Join(dir, "tight.db")
172 missing := filepath.Join(dir, "missing.db")
173 for _, p := range []string{loose, tight} {
174 if err := os.WriteFile(p, []byte("x"), 0o600); err != nil {
175 t.Fatal(err)
176 }
177 }
178 if err := os.Chmod(loose, 0o644); err != nil {
179 t.Fatal(err)
180 }
181
182 results, err := Harden(loose, tight, missing, dir)
183 if err != nil {
184 t.Fatal(err)
185 }
186 changed := map[string]bool{}
187 for _, r := range results {
188 changed[r.Path] = true
189 }
190 if !changed[loose] {
191 t.Error("the 0644 file was not reported")
192 }
193 if changed[tight] {
194 t.Error("an already-private file was reported")
195 }
196 if changed[missing] {
197 t.Error("a missing file was reported")
198 }
199 }
200
201 func TestHardenFileRefusesADirectory(t *testing.T) {
202 t.Parallel()
203 if _, err := HardenFile(t.TempDir()); err == nil {
204 t.Fatal("HardenFile accepted a directory")
205 }
206 }
207
208 func TestHardenDirRefusesAFile(t *testing.T) {
209 t.Parallel()
210 path := filepath.Join(t.TempDir(), "file")
211 if err := os.WriteFile(path, nil, 0o600); err != nil {
212 t.Fatal(err)
213 }
214 if _, err := HardenDir(path); err == nil {
215 t.Fatal("HardenDir accepted a file")
216 }
217 }