From 8f13a37d92ee407d5213ae6ffa4edb3d9d2dad20 Mon Sep 17 00:00:00 2001 From: Daniel Redetzke Date: Sun, 4 Oct 2026 20:56:35 +0300 Subject: [PATCH] Fingerprint app.js, setup.js and app.css --- main_test.go | 13 ++++++++++--- web.go | 50 ++++++++++++++++++++++++++++++++++++++++++++++++-- 2 files changed, 58 insertions(+), 5 deletions(-) diff --git a/main_test.go b/main_test.go index a9450ba..8ecfabb 100644 --- a/main_test.go +++ b/main_test.go @@ -921,8 +921,15 @@ func TestDecoy(t *testing.T) { } } - if b, _ := get("/", 200); !strings.Contains(b, "/app.js") { - t.Fatal("web interface not served with the decoy off") + b, _ := get("/", 200) + if !strings.Contains(b, `"/app.js?v=`+assetHash["app.js"]+`"`) || !strings.Contains(b, `"/app.css?v=`+assetHash["app.css"]+`"`) { + t.Fatalf("web interface not served with fingerprinted files: %q", b) + } + if _, h := get("/app.js?v="+assetHash["app.js"], 200); !strings.Contains(h.Get("Cache-Control"), "immutable") { + t.Fatalf("fingerprinted app.js: %v", h) + } + if _, h := get("/app.js?v=old", 200); h.Get("Cache-Control") != "no-cache" { + t.Fatalf("stale app.js cached: %v", h) } set(func(c *Config) { v4 := netip.MustParsePrefix(c.Server.IPv4) @@ -939,7 +946,7 @@ func TestDecoy(t *testing.T) { t.Fatalf("%s leaks: %q", p, b) } } - if b, _ := get("/setup/live-token", 200); !strings.Contains(b, `src="/setup/live-token/setup.js"`) { + if b, _ := get("/setup/live-token", 200); !strings.Contains(b, `src="/setup/live-token/setup.js?v=`+assetHash["setup.js"]+`"`) { t.Fatalf("setup page files not under the link: %q", b) } get("/setup/live-token/app.css", 200) diff --git a/web.go b/web.go index 1406392..3e96bf2 100644 --- a/web.go +++ b/web.go @@ -1,7 +1,9 @@ package main import ( + "crypto/sha256" "embed" + "encoding/hex" "net/http" "net/url" "strings" @@ -14,6 +16,39 @@ import ( //go:embed index.html setup.html app.js setup.js app.css favicon.svg apple-touch-icon.png ShipporiMinchoB1-ExtraBold.woff2 var webFiles embed.FS +// The pages load app.js, setup.js and app.css with ?v=, so +// a new binary makes browsers fetch the new files, and a fingerprinted file +// can be cached for good. +var ( + assetHash = map[string]string{} + indexPage []byte +) + +func init() { + for _, name := range []string{"app.js", "setup.js", "app.css"} { + b, err := webFiles.ReadFile(name) + if err != nil { + panic(err) + } + sum := sha256.Sum256(b) + assetHash[name] = hex.EncodeToString(sum[:5]) + } + b, err := webFiles.ReadFile("index.html") + if err != nil { + panic(err) + } + indexPage = fingerprint(b, "/") +} + +// fingerprint adds ?v= to the page's references to base + file. +func fingerprint(page []byte, base string) []byte { + s := string(page) + for name, h := range assetHash { + s = strings.ReplaceAll(s, `"`+base+name+`"`, `"`+base+name+"?v="+h+`"`) + } + return []byte(s) +} + func (a *App) webHandler() http.Handler { files := http.FileServerFS(webFiles) return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -22,7 +57,18 @@ func (a *App) webHandler() http.Handler { return } switch r.URL.Path { - case "/", "/app.js", "/setup.js", "/app.css", "/favicon.svg", "/apple-touch-icon.png": + case "/": + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.Header().Set("Cache-Control", "no-cache") + _, _ = w.Write(indexPage) + case "/app.js", "/setup.js", "/app.css": + if v := r.URL.Query().Get("v"); v != "" && v == assetHash[r.URL.Path[1:]] { + w.Header().Set("Cache-Control", "public, max-age=31536000, immutable") + } else { + w.Header().Set("Cache-Control", "no-cache") + } + files.ServeHTTP(w, r) + case "/favicon.svg", "/apple-touch-icon.png": w.Header().Set("Cache-Control", "no-cache") files.ServeHTTP(w, r) case "/ShipporiMinchoB1-ExtraBold.woff2": @@ -66,7 +112,7 @@ func (a *App) setupPage(w http.ResponseWriter, r *http.Request) { page := strings.NewReplacer(`href="/`, `href="`+base, `src="/`, `src="`+base).Replace(string(b)) w.Header().Set("Content-Type", "text/html; charset=utf-8") w.Header().Set("Cache-Control", "no-store") - _, _ = w.Write([]byte(page)) + _, _ = w.Write(fingerprint([]byte(page), base)) } func (a *App) setupAsset(w http.ResponseWriter, r *http.Request) {