@ 261101.7
current
package main

import (
	"fmt"
	"net"
	"net/http"
	"net/netip"
	"net/url"
	"os"
	"slices"
	"strings"
	"time"
)

// logged records each request but those that come with every page: live
// reload streams, the stylesheet, fonts and the favicon. A shared server
// adds the visitor's address. A secret tree's token stays out of the log.
func (s *server) logged(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		p := r.URL.Path
		if p == "/favicon.ico" || strings.HasPrefix(p, ownPath) && p != ownPath+"stop" {
			next.ServeHTTP(w, r)
			return
		}
		uri := r.URL.RequestURI()
		// The token is cut from the decoded path, which the lookup matched;
		// the raw one may spell it in escapes, eg /%733cret/.
		if t := s.lookup(p); t != nil && t.secret {
			uri = "/<token>" + hideToken(r.URL, strings.TrimPrefix(p, t.prefix))
		} else if t := s.byPrefix(p + "/"); t != nil && t.secret {
			// The secret path without its slash, which redirects.
			uri = "/<token>" + hideToken(r.URL, "")
		}
		start := time.Now()
		sw := &statusWriter{ResponseWriter: w, status: http.StatusOK}
		next.ServeHTTP(sw, r)
		msg := fmt.Sprintf("%s %s %d %dms", r.Method, uri, sw.status, time.Since(start).Milliseconds())
		if s.shared {
			msg += " from " + r.RemoteAddr
		}
		if sw.status >= 500 {
			logErr("%s", msg)
		} else {
			logInfo("%s", msg)
		}
	})
}

// hideToken is the request URI of u for the log, with rest, the decoded
// path below a secret tree's prefix, in place of its path.
func hideToken(u *url.URL, rest string) string {
	uri := (&url.URL{Path: "/" + rest}).EscapedPath()
	if rest == "" && !strings.HasSuffix(u.Path, "/") {
		uri = ""
	}
	if u.RawQuery != "" {
		uri += "?" + u.RawQuery
	}
	return uri
}

// statusWriter keeps the status a handler sent, for the log.
type statusWriter struct {
	http.ResponseWriter
	status int
	wrote  bool
}

func (w *statusWriter) WriteHeader(code int) {
	if !w.wrote {
		w.status, w.wrote = code, true
	}
	w.ResponseWriter.WriteHeader(code)
}

func (w *statusWriter) Write(b []byte) (int, error) {
	w.wrote = true
	return w.ResponseWriter.Write(b)
}

func (w *statusWriter) Unwrap() http.ResponseWriter { return w.ResponseWriter }

// guard rejects requests for any host but this machine, which defeats DNS
// rebinding, and sets the headers every response needs. A shared server
// takes any host, since visitors use whatever name reaches it; a secret
// path is its guard against rebinding. So does a public server, which
// holds nothing a stranger may not read.
func guard(next http.Handler, anyHost bool) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		host, _, err := net.SplitHostPort(r.Host)
		if err != nil {
			host = r.Host
		}
		if !anyHost && !localHost(host) {
			http.Error(w, progName+" answers only to this machine", http.StatusMisdirectedRequest)
			return
		}
		h := w.Header()
		h.Set("Content-Security-Policy", csp)
		h.Set("X-Content-Type-Options", "nosniff")
		// same-origin, not no-referrer: under no-referrer browsers send
		// Origin: null even on this server's own form posts.
		h.Set("Referrer-Policy", "same-origin")
		// Pages render from files that change while being read, so the
		// browser must ask again before reusing anything.
		h.Set("Cache-Control", "no-cache")
		next.ServeHTTP(w, r)
	})
}

// hostsFile maps names to addresses; tests point it elsewhere.
var hostsFile = "/etc/hosts"

// localHost accepts a loopback address, localhost, or a name that the hosts
// file maps to loopback, eg an www instance named there. DNS is never
// asked: a DNS name that resolves to loopback is the rebinding attack itself.
func localHost(host string) bool {
	if strings.EqualFold(host, "localhost") {
		return true
	}
	if ip := net.ParseIP(host); ip != nil {
		return ip.IsLoopback()
	}
	for _, e := range hostsEntries() {
		if e.ip.IsLoopback() && e.has(host) {
			return true
		}
	}
	return false
}

// hostsEntry is one line of the hosts file: an address and its names.
type hostsEntry struct {
	ip    netip.Addr
	names []string
}

func (e hostsEntry) has(host string) bool {
	return slices.ContainsFunc(e.names, func(name string) bool { return strings.EqualFold(name, host) })
}

func hostsEntries() []hostsEntry {
	b, err := os.ReadFile(hostsFile)
	if err != nil {
		return nil
	}
	var out []hostsEntry
	for _, line := range strings.Split(string(b), "\n") {
		line, _, _ = strings.Cut(line, "#")
		f := strings.Fields(line)
		if len(f) < 2 {
			continue
		}
		ip, err := netip.ParseAddr(f[0])
		if err != nil {
			continue
		}
		out = append(out, hostsEntry{ip.Unmap(), f[1:]})
	}
	return out
}

// sameOrigin trusts Sec-Fetch-Site when present: browsers set it and pages
// cannot forge it. Origin is the fallback for clients that lack it; a
// client sending neither, such as curl, is not a browser a site can drive.
func sameOrigin(r *http.Request) bool {
	if site := r.Header.Get("Sec-Fetch-Site"); site != "" {
		return site == "same-origin"
	}
	o := r.Header.Get("Origin")
	if o == "" {
		return true
	}
	u, err := url.Parse(o)
	return err == nil && u.Host == r.Host
}