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.

server/deployment_modes_test.go +104

@@ -0,0 +1,104 @@
1 package server_test
2
3 // The deployment baseline, checked the way an operator would: look at the modes
4 // of everything a fresh instance creates and refuse to be surprised by one.
5
6 import (
7 "io"
8 "log/slog"
9 "os"
10 "path/filepath"
11 "testing"
12
13 "github.com/rodent-software/vole/server"
14 )
15
16 // TestFreshInstanceCreatesNoReadableSecrets is the acceptance criterion for a
17 // new install: the roster, the host key and the audit log are for the service
18 // account, not for everyone with a shell.
19 func TestFreshInstanceCreatesNoReadableSecrets(t *testing.T) {
20 t.Parallel()
21 f := newFixture(t)
22
23 // A privileged operation, so the audit log exists and is written by the code
24 // that will write it in production.
25 f.addWriter(t)
26
27 paths := []string{
28 f.cfg.DataDir,
29 f.cfg.UsersDB(),
30 f.cfg.IndexDB(),
31 f.cfg.RunsDB(),
32 f.cfg.SSHHostKey(),
33 f.cfg.AuditLog(),
34 }
35 for _, path := range paths {
36 st, err := os.Stat(path)
37 if err != nil {
38 t.Fatalf("%s: %v", path, err)
39 }
40 mode := st.Mode().Perm()
41 if st.IsDir() {
42 if mode&0o007 != 0 {
43 t.Errorf("%s is %04o: world-accessible", path, mode)
44 }
45 continue
46 }
47 if mode&0o077 != 0 {
48 t.Errorf("%s is %04o: readable beyond the service account", path, mode)
49 }
50 }
51 }
52
53 // TestUpgradedInstanceHasItsSecretsTightened covers the instance that already
54 // existed: the files are there, and they were created 0644.
55 func TestUpgradedInstanceHasItsSecretsTightened(t *testing.T) {
56 t.Parallel()
57 f := newFixture(t)
58 f.addWriter(t)
59
60 for _, path := range []string{f.cfg.UsersDB(), f.cfg.SSHHostKey(), f.cfg.AuditLog()} {
61 if err := os.Chmod(path, 0o644); err != nil {
62 t.Fatal(err)
63 }
64 }
65
66 // Starting the instance is what tightens them, so build a second server over
67 // the same directory the way a restart does.
68 restarted, err := server.New(f.cfg, binPath, slog.New(slog.NewTextHandler(io.Discard, nil)))
69 if err != nil {
70 t.Fatalf("restart refused to start over an instance with loose modes: %v", err)
71 }
72 t.Cleanup(func() { restarted.Close() })
73
74 for _, path := range []string{f.cfg.UsersDB(), f.cfg.SSHHostKey(), f.cfg.AuditLog()} {
75 st, err := os.Stat(path)
76 if err != nil {
77 t.Fatal(err)
78 }
79 if mode := st.Mode().Perm(); mode&0o077 != 0 {
80 t.Errorf("%s is %04o after a restart", path, mode)
81 }
82 }
83 }
84
85 // TestBackupIsNotWorldReadable: `vole admin account backup` writes every account,
86 // key and passkey to a path the operator chooses, so the copy needs the mode the
87 // original has.
88 func TestBackupIsNotWorldReadable(t *testing.T) {
89 t.Parallel()
90 f := newFixture(t)
91 f.addWriter(t)
92
93 dst := filepath.Join(t.TempDir(), "roster.db")
94 if err := f.srv.Identity.Backup(dst); err != nil {
95 t.Fatal(err)
96 }
97 st, err := os.Stat(dst)
98 if err != nil {
99 t.Fatal(err)
100 }
101 if mode := st.Mode().Perm(); mode&0o077 != 0 {
102 t.Fatalf("%s is %04o: the backup is readable beyond its owner", dst, mode)
103 }
104 }