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.go +150
| @@ -0,0 +1,150 @@ | |||
| 1 | // Package secure creates and checks the files that hold secrets: the instance | ||
| 2 | // directories, the account database, the SSH host key, the ACME account key and | ||
| 3 | // the audit log. | ||
| 4 | // | ||
| 5 | // File modes are a security control here, not a convention. A world-readable | ||
| 6 | // users.db is every SSH key in the roster and every passkey; a world-readable | ||
| 7 | // host key is a forge anybody can impersonate; a world-readable audit log can be | ||
| 8 | // read by the person it records. Creating them at 0600 is not enough on its own, | ||
| 9 | // because a file that already exists keeps whatever mode it was created with — | ||
| 10 | // which is how an instance upgraded from an earlier version stays exposed. So | ||
| 11 | // creation and tightening are the same operation, and a mode that cannot be | ||
| 12 | // tightened is an error rather than a warning: an instance that cannot protect | ||
| 13 | // its keys must not start serving them. | ||
| 14 | package secure | ||
| 15 | |||
| 16 | import ( | ||
| 17 | "fmt" | ||
| 18 | "os" | ||
| 19 | ) | ||
| 20 | |||
| 21 | const ( | ||
| 22 | // DirMode is the mode of a directory that holds secrets. It gives the service | ||
| 23 | // account everything and nobody else anything. | ||
| 24 | DirMode os.FileMode = 0o700 | ||
| 25 | // FileMode is the mode of a secret file: the owner reads and writes, nobody | ||
| 26 | // else can do either. | ||
| 27 | FileMode os.FileMode = 0o600 | ||
| 28 | ) | ||
| 29 | |||
| 30 | // Mkdir creates path and any missing parents at DirMode, then removes world | ||
| 31 | // access from a directory that already exists. Group access is left alone: a | ||
| 32 | // packaged instance may deliberately use 0750 with a dedicated group. | ||
| 33 | func Mkdir(path string) error { | ||
| 34 | if err := os.MkdirAll(path, DirMode); err != nil { | ||
| 35 | return err | ||
| 36 | } | ||
| 37 | _, err := HardenDir(path) | ||
| 38 | return err | ||
| 39 | } | ||
| 40 | |||
| 41 | // HardenDir removes world access from an existing directory and reports whether | ||
| 42 | // it had to change anything. | ||
| 43 | func HardenDir(path string) (bool, error) { | ||
| 44 | st, err := os.Stat(path) | ||
| 45 | if err != nil { | ||
| 46 | return false, err | ||
| 47 | } | ||
| 48 | if !st.IsDir() { | ||
| 49 | return false, fmt.Errorf("%s is not a directory", path) | ||
| 50 | } | ||
| 51 | mode := st.Mode().Perm() | ||
| 52 | if mode&0o007 == 0 { | ||
| 53 | return false, nil | ||
| 54 | } | ||
| 55 | want := mode &^ 0o007 | ||
| 56 | if err := os.Chmod(path, want); err != nil { | ||
| 57 | return false, fmt.Errorf("%s is %04o and cannot be tightened to %04o: %w", path, mode, want, err) | ||
| 58 | } | ||
| 59 | return true, nil | ||
| 60 | } | ||
| 61 | |||
| 62 | // HardenFile forces an existing secret file to FileMode and reports whether it | ||
| 63 | // had to change anything. | ||
| 64 | func HardenFile(path string) (bool, error) { | ||
| 65 | st, err := os.Stat(path) | ||
| 66 | if err != nil { | ||
| 67 | return false, err | ||
| 68 | } | ||
| 69 | if st.IsDir() { | ||
| 70 | return false, fmt.Errorf("%s is a directory, expected a file", path) | ||
| 71 | } | ||
| 72 | if st.Mode().Perm() == FileMode { | ||
| 73 | return false, nil | ||
| 74 | } | ||
| 75 | had := st.Mode().Perm() | ||
| 76 | if err := os.Chmod(path, FileMode); err != nil { | ||
| 77 | return false, fmt.Errorf("%s is %04o and cannot be tightened to %04o: %w", path, had, FileMode, err) | ||
| 78 | } | ||
| 79 | return true, nil | ||
| 80 | } | ||
| 81 | |||
| 82 | // HardenFileIfExists is HardenFile for a file that may not exist yet. | ||
| 83 | func HardenFileIfExists(path string) (bool, error) { | ||
| 84 | changed, err := HardenFile(path) | ||
| 85 | if os.IsNotExist(err) { | ||
| 86 | return false, nil | ||
| 87 | } | ||
| 88 | return changed, err | ||
| 89 | } | ||
| 90 | |||
| 91 | // OpenAppend opens a secret file for appending, creating it at FileMode. An | ||
| 92 | // existing file is tightened first, so a log inherited from an older install | ||
| 93 | // stops being readable by everyone the next time it is written. | ||
| 94 | func OpenAppend(path string) (*os.File, error) { | ||
| 95 | f, err := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, FileMode) | ||
| 96 | if err != nil { | ||
| 97 | return nil, err | ||
| 98 | } | ||
| 99 | if _, err := HardenFile(path); err != nil { | ||
| 100 | f.Close() | ||
| 101 | return nil, err | ||
| 102 | } | ||
| 103 | return f, nil | ||
| 104 | } | ||
| 105 | |||
| 106 | // WriteFile writes a secret file at FileMode, replacing whatever mode an existing | ||
| 107 | // file had. | ||
| 108 | func WriteFile(path string, data []byte) error { | ||
| 109 | if err := os.WriteFile(path, data, FileMode); err != nil { | ||
| 110 | return err | ||
| 111 | } | ||
| 112 | _, err := HardenFile(path) | ||
| 113 | return err | ||
| 114 | } | ||
| 115 | |||
| 116 | // Result is one path's outcome, so a caller can report what it changed. | ||
| 117 | type Result struct { | ||
| 118 | Path string | ||
| 119 | Changed bool | ||
| 120 | } | ||
| 121 | |||
| 122 | // Harden applies the right mode to each path: directories lose world access, | ||
| 123 | // files become 0600. Missing paths are skipped, because a store that has not been | ||
| 124 | // created yet will be created at the right mode. The first failure is returned | ||
| 125 | // with everything that did change, so the caller can still report progress. | ||
| 126 | func Harden(paths ...string) ([]Result, error) { | ||
| 127 | var results []Result | ||
| 128 | for _, path := range paths { | ||
| 129 | st, err := os.Stat(path) | ||
| 130 | if os.IsNotExist(err) { | ||
| 131 | continue | ||
| 132 | } | ||
| 133 | if err != nil { | ||
| 134 | return results, err | ||
| 135 | } | ||
| 136 | var changed bool | ||
| 137 | if st.IsDir() { | ||
| 138 | changed, err = HardenDir(path) | ||
| 139 | } else { | ||
| 140 | changed, err = HardenFile(path) | ||
| 141 | } | ||
| 142 | if err != nil { | ||
| 143 | return results, err | ||
| 144 | } | ||
| 145 | if changed { | ||
| 146 | results = append(results, Result{Path: path, Changed: true}) | ||
| 147 | } | ||
| 148 | } | ||
| 149 | return results, nil | ||
| 150 | } | ||