package main import ( "bytes" "encoding/json" "image" "image/png" "io" "net/http" "net/http/httptest" "net/url" "strings" "testing" ) func newTestFetch(t *testing.T) (*httptest.Server, *fetchServer) { t.Helper() g, err := newFetchServer(t.TempDir() + "/fetch.db") if err != nil { t.Fatalf("newFetchServer: %v", err) } ts := httptest.NewServer(g.handler()) t.Cleanup(ts.Close) t.Cleanup(func() { g.db.Close() }) return ts, g } // The fetch service unfurls anonymously (no token — it has no members to know about), // rewrites the card's image to its signed /img proxy, and that proxy URL // actually serves the image bytes back over the fetch service's own origin. func TestFetchUnfurlAndImageProxy(t *testing.T) { ts, g := newTestFetch(t) allowPrivatePreviewHosts = true t.Cleanup(func() { allowPrivatePreviewHosts = false }) imgBytes := []byte("not-really-a-png-but-served-as-one") site := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Path == "/pic.png" { w.Header().Set("Content-Type", "image/png") w.Write(imgBytes) return } w.Header().Set("Content-Type", "text/html") w.Write([]byte(`` + ``)) })) t.Cleanup(site.Close) r, err := http.Get(ts.URL + "/api/unfurl?url=" + url.QueryEscape(site.URL)) if err != nil { t.Fatalf("unfurl: %v", err) } // Callers are browsers on OTHER origins (every space is one): without // this header fetch() can't read the response and clients silently fall // back to their instance path — the bug that made the client flip a no-op. if r.Header.Get("Access-Control-Allow-Origin") != "*" { t.Fatal("missing Access-Control-Allow-Origin — cross-origin clients can't read the service") } var pv LinkPreview json.NewDecoder(r.Body).Decode(&pv) r.Body.Close() if !pv.OK || pv.Title != "Fetched" { t.Fatalf("preview = %+v", pv) } // The image is no longer the site's URL — it's the fetch service's signed proxy. if !strings.HasPrefix(pv.Image, ts.URL+"/img?") { t.Fatalf("image not proxied: %q", pv.Image) } ir, err := http.Get(pv.Image) if err != nil { t.Fatalf("proxied image: %v", err) } defer ir.Body.Close() if ir.StatusCode != http.StatusOK || ir.Header.Get("Content-Type") != "image/png" { t.Fatalf("proxied image: status %d, type %q", ir.StatusCode, ir.Header.Get("Content-Type")) } got, err := io.ReadAll(ir.Body) if err != nil || string(got) != string(imgBytes) { t.Fatalf("proxied bytes = %q err=%v", got, err) } // Cached: a second unfurl works with the cache row (and stays sealed at // rest — no plaintext in the stored row). var urlHash, blob string if err := g.db.QueryRow("SELECT url_hash, blob FROM fetch_previews").Scan(&urlHash, &blob); err != nil { t.Fatalf("raw row: %v", err) } for _, secret := range []string{site.URL, "Fetched", "pic.png"} { if strings.Contains(urlHash+blob, secret) { t.Fatalf("plaintext %q found in the stored row", secret) } } } // /img is not an open proxy: an unsigned or tampered URL never fetches. func TestFetchImageProxyRejectsUnsigned(t *testing.T) { ts, g := newTestFetch(t) // A URL the fetch service never signed: correct encoding, wrong signature. u := "aHR0cDovL2V4YW1wbGUuY29tL3guanBn" // base64url("http://example.com/x.jpg") for _, q := range []string{ "u=" + u + "&s=" + strings.Repeat("00", 32), "u=" + u, "u=%%%&s=zz", } { r, err := http.Get(ts.URL + "/img?" + q) if err != nil { t.Fatalf("img %q: %v", q, err) } r.Body.Close() if r.StatusCode == http.StatusOK { t.Fatalf("img %q: served without a valid signature", q) } } // And a signed URL to a non-image is refused after the fetch. allowPrivatePreviewHosts = true t.Cleanup(func() { allowPrivatePreviewHosts = false }) site := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "text/html") w.Write([]byte("not an image")) })) t.Cleanup(site.Close) req, _ := http.NewRequest("GET", ts.URL+"/api/unfurl", nil) signed := g.proxyImageURL(&http.Request{Host: strings.TrimPrefix(ts.URL, "http://"), URL: req.URL}, site.URL) r, err := http.Get(signed) if err != nil { t.Fatalf("signed non-image: %v", err) } r.Body.Close() if r.StatusCode != http.StatusBadGateway { t.Fatalf("signed non-image: want 502, got %d", r.StatusCode) } } // Language variants cache as separate rows on the fetch service too — same contract // as the instance (TestUnfurlLanguage), shared-cache edition. func TestFetchUnfurlLanguageVariants(t *testing.T) { ts, g := newTestFetch(t) allowPrivatePreviewHosts = true t.Cleanup(func() { allowPrivatePreviewHosts = false }) site := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "text/html") w.Write([]byte(``)) })) t.Cleanup(site.Close) fetch := func(lang string) string { t.Helper() req, _ := http.NewRequest("GET", ts.URL+"/api/unfurl?url="+url.QueryEscape(site.URL), nil) if lang != "" { req.Header.Set("Accept-Language", lang) } r, err := http.DefaultClient.Do(req) if err != nil { t.Fatalf("unfurl(%q): %v", lang, err) } defer r.Body.Close() var pv LinkPreview json.NewDecoder(r.Body).Decode(&pv) return pv.Title } if got := fetch("fi"); got != "lang:fi" { t.Fatalf("fi = %q", got) } if got := fetch("en-IE,en;q=0.9"); got != "lang:en-IE, en;q=0.8" { t.Fatalf("en-IE = %q", got) } var rows int g.db.QueryRow("SELECT COUNT(*) FROM fetch_previews").Scan(&rows) if rows != 2 { t.Fatalf("cache rows = %d, want 2", rows) } } // The client flip is env-gated: with LCHAT_FETCH_URL set, the chat page names // the fetch service in connect-src and hands the origin to shell.js; unset, the page // is byte-identical to today's (self-host default = local unfurl). func TestFetchClientFlipInjection(t *testing.T) { ts, srv := newTestServer(t) get := func() (csp, body string) { t.Helper() r, err := http.Get(ts.URL + "/") if err != nil { t.Fatalf("GET /: %v", err) } defer r.Body.Close() b, _ := io.ReadAll(r.Body) return r.Header.Get("Content-Security-Policy"), string(b) } csp, body := get() if strings.Contains(csp, "fetch.example") { t.Fatalf("CSP names a fetch service with none configured: %s", csp) } if !strings.Contains(body, `window.__fetchURL = ""`) { t.Fatal("page should inject an empty fetch URL when unconfigured") } srv.fetchURL = "https://fetch.example" csp, body = get() if !strings.Contains(csp, "connect-src 'self' https://*.giphy.com") || !strings.Contains(csp, " https://fetch.example; ") { t.Fatalf("CSP missing the fetch service origin: %s", csp) } if !strings.Contains(body, `window.__fetchURL = "https://fetch.example"`) { t.Fatal("page missing the injected fetch URL") } } // Service secrets survive a restart: the same DB yields the same salt and // signing key, so cached rows still open and signed URLs stay valid. // /api/gif fronts Giphy with the platform key: keyless → 404 (clients fall // back to bring-your-own-key), keyed → the upstream page passes through with // every media URL rewritten to the signed /img proxy, which then serves the // bytes — the browser never needs Giphy. func TestFetchGifSearch(t *testing.T) { ts, g := newTestFetch(t) // Keyless: a clean 404 so the client bridge falls through. r, err := http.Get(ts.URL + "/api/gif?q=cats") if err != nil { t.Fatalf("gif keyless: %v", err) } r.Body.Close() if r.StatusCode != http.StatusNotFound { t.Fatalf("gif keyless: want 404, got %d", r.StatusCode) } allowPrivatePreviewHosts = true t.Cleanup(func() { allowPrivatePreviewHosts = false }) gifBytes := []byte("GIF89a-not-really") var upstreamQuery url.Values upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Path == "/media/cat.gif" { w.Header().Set("Content-Type", "image/gif") w.Write(gifBytes) return } upstreamQuery = r.URL.Query() w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]any{ "data": []any{map[string]any{ "id": "abc", "title": "Cat", "images": map[string]any{ "downsized": map[string]any{ "url": "http://" + r.Host + "/media/cat.gif", "size": "1234", }, }, }}, }) })) t.Cleanup(upstream.Close) oldBase := giphyAPIBase giphyAPIBase = upstream.URL + "/v1/gifs/" t.Cleanup(func() { giphyAPIBase = oldBase }) g.giphyKey = "platform-key" r, err = http.Get(ts.URL + "/api/gif?q=cats&limit=5") if err != nil { t.Fatalf("gif search: %v", err) } var page struct { Data []struct { Images map[string]struct{ URL string `json:"url"` } `json:"images"` } `json:"data"` } json.NewDecoder(r.Body).Decode(&page) r.Body.Close() if upstreamQuery.Get("api_key") != "platform-key" || upstreamQuery.Get("q") != "cats" || upstreamQuery.Get("limit") != "5" { t.Fatalf("upstream saw %v", upstreamQuery) } if len(page.Data) != 1 { t.Fatalf("data = %+v", page.Data) } signed := page.Data[0].Images["downsized"].URL if !strings.HasPrefix(signed, ts.URL+"/img?") { t.Fatalf("media URL not signed onto the proxy: %q", signed) } ir, err := http.Get(signed) if err != nil { t.Fatalf("proxied gif: %v", err) } defer ir.Body.Close() got, _ := io.ReadAll(ir.Body) if ir.StatusCode != http.StatusOK || string(got) != string(gifBytes) { t.Fatalf("proxied gif: status %d, bytes %q", ir.StatusCode, got) } } func TestFetchSecretsPersist(t *testing.T) { dir := t.TempDir() g1, err := newFetchServer(dir + "/fetch.db") if err != nil { t.Fatalf("first open: %v", err) } salt, key := g1.salt, g1.signKey g1.db.Close() g2, err := newFetchServer(dir + "/fetch.db") if err != nil { t.Fatalf("reopen: %v", err) } defer g2.db.Close() if g2.salt != salt || string(g2.signKey) != string(key) { t.Fatal("fetch-service secrets changed across restart") } if len(key) != 32 || salt == "" { t.Fatalf("weak secrets: key %d bytes, salt %q", len(key), salt) } } // Oversized JPEG/PNG card images shrink to card width on the way through the // proxy; GIFs (animation) and already-small images pass through untouched. func TestFetchImageProxyShrinks(t *testing.T) { ts, g := newTestFetch(t) allowPrivatePreviewHosts = true t.Cleanup(func() { allowPrivatePreviewHosts = false }) // A 2400×1200 PNG (well over fetchImgResizeOver) and a small one. big := image.NewRGBA(image.Rect(0, 0, 2400, 1200)) var bigBuf bytes.Buffer png.Encode(&bigBuf, big) var smallBuf bytes.Buffer png.Encode(&smallBuf, image.NewRGBA(image.Rect(0, 0, 320, 200))) site := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "image/png") if r.URL.Path == "/big.png" { w.Write(bigBuf.Bytes()) } else { w.Write(smallBuf.Bytes()) } })) t.Cleanup(site.Close) get := func(path string) []byte { t.Helper() req, _ := http.NewRequest("GET", ts.URL+"/api/unfurl", nil) r, err := http.Get(g.proxyImageURL(&http.Request{Host: strings.TrimPrefix(ts.URL, "http://"), URL: req.URL}, site.URL+path)) if err != nil { t.Fatalf("proxy %s: %v", path, err) } defer r.Body.Close() b, _ := io.ReadAll(r.Body) if r.StatusCode != http.StatusOK { t.Fatalf("proxy %s: status %d", path, r.StatusCode) } return b } shrunk := get("/big.png") cfg, _, err := image.DecodeConfig(bytes.NewReader(shrunk)) if err != nil || cfg.Width != fetchImgResizeTo || cfg.Height != 600 { t.Fatalf("big.png came back %dx%d err=%v (want %dx600)", cfg.Width, cfg.Height, err, fetchImgResizeTo) } if len(shrunk) >= bigBuf.Len() { t.Fatalf("shrink didn't shrink: %d >= %d", len(shrunk), bigBuf.Len()) } if got := get("/small.png"); string(got) != smallBuf.String() { t.Fatal("small image should pass through byte-identical") } } // The hashcash gate: unadmitted requests get a challenge, a solved challenge // serves the request and mints a reusable ticket, tickets admit until expiry, // and a solved challenge can't be replayed for a second ticket. func TestFetchPowGate(t *testing.T) { ts, g := newTestFetch(t) if err := g.armPow(8); err != nil { // 8 bits: a few hundred hashes t.Fatalf("armPow: %v", err) } allowPrivatePreviewHosts = true t.Cleanup(func() { allowPrivatePreviewHosts = false }) site := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "text/html") w.Write([]byte(``)) })) t.Cleanup(site.Close) target := "/api/unfurl?url=" + url.QueryEscape(site.URL) // No ticket, no pow → 401 with a challenge. r, err := http.Get(ts.URL + target) if err != nil { t.Fatalf("gated: %v", err) } var gate struct { Pow struct { Challenge string `json:"challenge"` Bits int `json:"bits"` } `json:"pow"` } json.NewDecoder(r.Body).Decode(&gate) r.Body.Close() if r.StatusCode != http.StatusUnauthorized || gate.Pow.Challenge == "" || gate.Pow.Bits != 8 { t.Fatalf("gate = %d %+v", r.StatusCode, gate) } // Solve → the request itself is served AND a ticket comes back. nonce := fetchSolvePow(gate.Pow.Challenge, gate.Pow.Bits) r, err = http.Get(ts.URL + target + "&pow=" + url.QueryEscape(gate.Pow.Challenge) + "&nonce=" + nonce) if err != nil { t.Fatalf("solved: %v", err) } ticket := r.Header.Get("X-Fetch-Ticket") var pv LinkPreview json.NewDecoder(r.Body).Decode(&pv) r.Body.Close() if r.StatusCode != http.StatusOK || !pv.OK || pv.Title != "Gated" || ticket == "" { t.Fatalf("solved: status %d pv %+v ticket %q", r.StatusCode, pv, ticket) } // The ticket readmits; the spent challenge does not. r, _ = http.Get(ts.URL + target + "&ticket=" + url.QueryEscape(ticket)) r.Body.Close() if r.StatusCode != http.StatusOK { t.Fatalf("ticket readmit: %d", r.StatusCode) } r, _ = http.Get(ts.URL + target + "&pow=" + url.QueryEscape(gate.Pow.Challenge) + "&nonce=" + nonce) r.Body.Close() if r.StatusCode != http.StatusUnauthorized { t.Fatalf("replayed challenge: want 401, got %d", r.StatusCode) } // Garbage tickets bounce back to the gate. r, _ = http.Get(ts.URL + target + "&ticket=garbage") r.Body.Close() if r.StatusCode != http.StatusUnauthorized { t.Fatalf("garbage ticket: want 401, got %d", r.StatusCode) } }