A program can show a page in the user’s browser, served from the user’s own
machine. Such a server acts as the user. So whoever can send it a request
acts as the user too: another user of the machine, any page in the user’s
browser, and a page of another site that makes its name resolve to
127.0.0.1. localweb keeps the three out, each with a check of its own
(decision 34).
- A request comes in on a loopback port. The listener closes a connection that another user opened.
- Hosts refuses a Host header that names another server: DNS rebinding.
- SameOrigin refuses what a page of another site caused, by Sec-Fetch-Site and Origin.
- The session lets in only the browser that opened the printed address, and checks the CSRF token.
- The handler of the program sees the request.
Procedure#
- Take the lock of the user’s server with
launch.Acquire. When a server of the user runs already, open that one and stop. - Listen with
localweb.Listenon a loopback address or a Unix socket. On Linux, wrap the TCP listener withlocalweb.PeerListener. - Make a launch token with
session.NewToken, and asession.Manager. - Chain the checks, outermost first:
localweb.Headers,localweb.Hosts,localweb.SameOrigin,Manager.Middleware, then your handler. - Serve with
localweb.Serve, which sets every timeout and stops within a grace period when the context ends. - Print the address with the token, and open the browser with a
launch.Opener. The Opener writes the address to a private file, because every user can read a command line.
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
b, err := localweb.Listen(ctx, "127.0.0.1:0", localweb.ListenOptions{})
if err != nil {
log.Fatal(err)
}
mux := http.NewServeMux()
mux.HandleFunc("/", func(w http.ResponseWriter, _ *http.Request) {
fmt.Fprintln(w, "Hello from the user's machine.")
})
h := localweb.Headers("")(localweb.Hosts(b.Answers, localweb.HostsOptions{})(
localweb.SameOrigin(localweb.OriginPolicy{Navigate: true})(mux)))
done := make(chan error, 1)
go func() { done <- localweb.Serve(ctx, b.Listeners, h, localweb.ServeOptions{}) }()
client := &http.Client{Transport: &http.Transport{DisableKeepAlives: true}}
for _, host := range []string{b.Host(), "rebind.example"} {
req, err := http.NewRequest(http.MethodGet, "http://"+b.Host()+"/", nil)
if err != nil {
log.Fatal(err)
}
req.Host = host
res, err := client.Do(req)
if err != nil {
log.Fatal(err)
}
body, _ := io.ReadAll(res.Body)
_ = res.Body.Close()
fmt.Printf("%s: %s %s", res.Status, res.Header.Get("X-Frame-Options"), body)
}
cancel()
fmt.Println(<-done)200 OK: DENY Hello from the user's machine.
421 Misdirected Request: DENY This server answers to its loopback address only.
<nil>localweb/example_test.go. CI runs it and checks its output.Do not leave out a check because the server listens on a loopback address. Every user of the machine, and every page in the browser, can reach a loopback port.
Live fragments#
A page names its fragments, and the topics that each depends on. The store
of the program notifies a topic when it changes. sse.Serve
sends every fragment first, then each fragment whose topic changed and whose
HTML is new. page.Set renders the page and its
fragments from one template set, and localweb/htmx holds the
scripts that swap them in.
ctx, stop := context.WithCancel(context.Background())
defer stop()
store := &Store{}
stream := func(w http.ResponseWriter, r *http.Request) {
_ = sse.Serve(w, r, &store.notifier, func(changed []string) ([]sse.Event, error) {
if changed == nil {
changed = []string{"runs"}
}
var es []sse.Event
for _, t := range changed {
es = append(es, store.fragment(t))
}
return es, nil
}, sse.Options{Done: ctx.Done()})
}
srv := httptest.NewServer(localweb.SameOrigin(localweb.OriginPolicy{})(http.HandlerFunc(stream)))
defer srv.Close()
resp, err := http.Get(srv.URL)
if err != nil {
fmt.Println(err)
return
}
defer resp.Body.Close()
events := localwebtest.NewReader(resp.Body)
e, _ := events.Next()
fmt.Println(e.Name, e.Data)
store.AddRun("create")
e, _ = events.Next()
fmt.Println(e.Name, e.Data)runs <ul>0 runs</ul>
runs <ul>1 runs</ul>localweb/sse/example_test.go. CI runs it and checks its output.Test the server#
localwebtest.Login gives a test a client that
signed in with the token, and localwebtest.Reader
reads the events of a stream.