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/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) {