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/web/auth.go +42−10
| @@ -230,7 +230,8 @@ func (w *Web) withPrincipal(r *http.Request, acct *identity.Account, sess *sessi | |||
| 230 | 230 | ||
| 231 | 231 | // enableDevLogin resolves the configured account once. It refuses to start when | |
| 232 | 232 | // the listener is not loopback: a typo must not turn a shared instance into one | |
| 233 | // that signs everybody in as the same person. | ||
| 233 | // that signs everybody in as the same person. Config.Validate makes the same | ||
| 234 | // checks; this one keeps the Web honest when it is built directly. | ||
| 234 | 235 | func (w *Web) enableDevLogin(cfg *config.Config) error { | |
| 235 | 236 | name := strings.TrimSpace(cfg.DevLogin) | |
| 236 | 237 | if name == "" { | |
| @@ -239,6 +240,12 @@ func (w *Web) enableDevLogin(cfg *config.Config) error { | |||
| 239 | 240 | if !loopbackListen(cfg.HTTPAddr) { | |
| 240 | 241 | return fmt.Errorf("dev_login requires http_addr on a loopback address, got %q", cfg.HTTPAddr) | |
| 241 | 242 | } | |
| 243 | if strings.TrimSpace(cfg.HTTPSAddr) != "" { | ||
| 244 | return fmt.Errorf("dev_login cannot be combined with https_addr") | ||
| 245 | } | ||
| 246 | if public, err := config.ParsePublicURL(cfg.PublicURL); err == nil && public != nil && !loopbackHost(public.Hostname()) { | ||
| 247 | return fmt.Errorf("dev_login cannot be combined with a public_url that is not loopback (%q)", cfg.PublicURL) | ||
| 248 | } | ||
| 242 | 249 | acct, err := w.Ident.Find(name) | |
| 243 | 250 | if err != nil { | |
| 244 | 251 | return fmt.Errorf("dev_login %q: %w", name, err) | |
| @@ -251,7 +258,7 @@ func (w *Web) enableDevLogin(cfg *config.Config) error { | |||
| 251 | 258 | } | |
| 252 | 259 | ||
| 253 | 260 | func (w *Web) devLoginRequest(r *http.Request) bool { | |
| 254 | if w.devAccount == nil || !isLoopbackRequest(r) { | ||
| 261 | if w.devAccount == nil || !w.loopbackBrowser(r) { | ||
| 255 | 262 | return false | |
| 256 | 263 | } | |
| 257 | 264 | if c, err := r.Cookie(devOffCookieName); err == nil && c.Value != "" { | |
| @@ -290,22 +297,47 @@ func loopbackHost(host string) bool { | |||
| 290 | 297 | return ip != nil && ip.IsLoopback() | |
| 291 | 298 | } | |
| 292 | 299 | ||
| 293 | // isLoopbackRequest checks both ends. A loopback listener behind a reverse proxy | ||
| 300 | // loopbackBrowser checks both ends. A loopback listener behind a reverse proxy | ||
| 294 | 301 | // sees loopback as RemoteAddr, so the Host check is what stops a public request | |
| 295 | 302 | // from inheriting a development session. | |
| 296 | func isLoopbackRequest(r *http.Request) bool { | ||
| 303 | // | ||
| 304 | // The Host header alone is not enough: a proxy is free to rewrite it to | ||
| 305 | // 127.0.0.1, which is exactly the request that must not sign anybody in. So the | ||
| 306 | // host has to equal the configured listener address, and a request carrying a | ||
| 307 | // forwarding header is refused outright — a proxy that does not set one is | ||
| 308 | // unusual, and this check errs toward no session. | ||
| 309 | func (w *Web) loopbackBrowser(r *http.Request) bool { | ||
| 297 | 310 | remote, _, err := net.SplitHostPort(r.RemoteAddr) | |
| 298 | 311 | if err != nil || !loopbackHost(remote) { | |
| 299 | 312 | return false | |
| 300 | 313 | } | |
| 301 | if r.Host == "" { | ||
| 302 | return true | ||
| 314 | if w.Cfg == nil { | ||
| 315 | return false // no configured address to compare against | ||
| 316 | } | ||
| 317 | for _, h := range []string{"Forwarded", "X-Forwarded-For", "X-Forwarded-Host", "X-Forwarded-Proto", "X-Real-IP", "X-Real-Ip"} { | ||
| 318 | if r.Header.Get(h) != "" { | ||
| 319 | return false | ||
| 320 | } | ||
| 303 | 321 | } | |
| 304 | host := r.Host | ||
| 305 | if h, _, err := net.SplitHostPort(host); err == nil { | ||
| 306 | host = h | ||
| 322 | wantHost, wantPort, err := net.SplitHostPort(strings.TrimSpace(w.Cfg.HTTPAddr)) | ||
| 323 | if err != nil || wantHost == "" { | ||
| 324 | return false | ||
| 325 | } | ||
| 326 | host, port := splitHostPort(r.Host) | ||
| 327 | if port != wantPort { | ||
| 328 | return false | ||
| 329 | } | ||
| 330 | // A development browser may type localhost where the config says 127.0.0.1. | ||
| 331 | // A proxy rewriting Host to a loopback name still has to guess the exact port, | ||
| 332 | // and it would also have to strip every forwarding header it normally adds. | ||
| 333 | return strings.EqualFold(host, wantHost) || (loopbackHost(host) && loopbackHost(wantHost)) | ||
| 334 | } | ||
| 335 | |||
| 336 | func splitHostPort(addr string) (host, port string) { | ||
| 337 | if h, p, err := net.SplitHostPort(addr); err == nil { | ||
| 338 | return h, p | ||
| 307 | 339 | } | |
| 308 | return loopbackHost(host) | ||
| 340 | return addr, "" | ||
| 309 | 341 | } | |
| 310 | 342 | ||
| 311 | 343 | func (w *Web) securityHeaders(rw http.ResponseWriter) { | |