package main import ( "crypto/aes" "crypto/cipher" "crypto/hkdf" "crypto/rand" "crypto/sha256" "encoding/base64" "encoding/hex" "encoding/json" "errors" "fmt" "html" "io" "net" "net/http" "net/url" "regexp" "strings" "syscall" "time" ) // Link (OpenGraph) previews. The server fetches a posted URL only long enough to // extract its title/description/image, then caches the result. This is the one // place the server sees plaintext URLs (message text stays encrypted); it can be // disabled later if that trade-off stops being acceptable. const ( previewFreshTTL = 7 * 24 * 60 * 60 // serve-stale-after, then refresh in the background previewFailTTL = 60 * 60 // retry failed/blank lookups sooner previewMaxAge = 30 * 24 * 60 * 60 // evict cache rows older than this (put-time sweep) previewMaxBytes = 512 * 1024 // only read the start of the page previewTimeout = 6 * time.Second ) // LinkPreview is the cached unfurl result (also the JSON shape sent to clients). type LinkPreview struct { URL string `json:"url"` Title string `json:"title"` Description string `json:"description"` Image string `json:"image"` OK bool `json:"ok"` FetchedAt int64 `json:"-"` } // ---- sealing the cache at rest (docs/no-plaintext.md) --------------------- // // Convergent encryption: both the row key and the AES key are derived from the // URL itself (plus a per-instance salt that only defeats cross-instance // precomputation). The server sees URLs transiently at request time — the // documented unfurl exception — derives, opens or seals, and stores only // ciphertext, so the cache at rest reveals nothing a reader doesn't already // know the URL for. Cards are fetched per requester language (the page can // differ), so the derivation input is the cache key — language + URL — not // the bare URL. // previewURLHash is the row key: hex sha256(salt || cache key). func previewURLHash(salt, u string) string { h := sha256.Sum256([]byte(salt + u)) return hex.EncodeToString(h[:]) } // previewCacheKey folds the requester's language into the string the row hash // and AEAD derive from, so members with different languages get (and cache) // different cards — a card fetched via a Finnish exit never serves to an "en" // member. Empty lang (no preference) keys exactly as before. func previewCacheKey(lang, u string) string { if lang == "" { return u } return lang + "\n" + u } var langTagRe = regexp.MustCompile(`^[A-Za-z]{1,8}(?:-[A-Za-z0-9]{1,8})*$`) // previewLang normalises an Accept-Language header to its first tag // ("en-IE,en;q=0.9,fi;q=0.8" → "en-IE"): one low-cardinality value to forward // upstream and to vary the cache by. "" (no preference) on absent/malformed. func previewLang(header string) string { tag, _, _ := strings.Cut(header, ",") tag, _, _ = strings.Cut(tag, ";") tag = strings.TrimSpace(tag) if !langTagRe.MatchString(tag) { return "" } return tag } // previewAEAD derives the URL's AES-GCM cipher. func previewAEAD(salt, u string) (cipher.AEAD, error) { key, err := hkdf.Key(sha256.New, []byte(u), []byte(salt), "lchat link preview v1", 32) if err != nil { return nil, err } block, err := aes.NewCipher(key) if err != nil { return nil, err } return cipher.NewGCM(block) } // sealedPreview is the card as stored inside the ciphertext — everything but // the URL (which is the key) and fetched_at (plaintext, for eviction). type sealedPreview struct { Title string `json:"title"` Description string `json:"description"` Image string `json:"image"` OK bool `json:"ok"` } // sealPreview encrypts a card to the base64url iv||ciphertext format // (crypto.js's convention, kept for consistency). key is the cache key // (previewCacheKey), not necessarily the bare URL. func sealPreview(salt, key string, pv LinkPreview) (string, error) { aead, err := previewAEAD(salt, key) if err != nil { return "", err } iv := make([]byte, aead.NonceSize()) if _, err := rand.Read(iv); err != nil { return "", err } plain, err := json.Marshal(sealedPreview{ Title: pv.Title, Description: pv.Description, Image: pv.Image, OK: pv.OK, }) if err != nil { return "", err } return base64.RawURLEncoding.EncodeToString(aead.Seal(iv, iv, plain, nil)), nil } // openPreview decrypts a stored card; false on any mismatch or corruption // (callers treat that as a cache miss). key matches sealPreview's. func openPreview(salt, key, blob string) (LinkPreview, bool) { aead, err := previewAEAD(salt, key) if err != nil { return LinkPreview{}, false } raw, err := base64.RawURLEncoding.DecodeString(blob) if err != nil || len(raw) < aead.NonceSize() { return LinkPreview{}, false } plain, err := aead.Open(nil, raw[:aead.NonceSize()], raw[aead.NonceSize():], nil) if err != nil { return LinkPreview{}, false } var sp sealedPreview if json.Unmarshal(plain, &sp) != nil { return LinkPreview{}, false } return LinkPreview{Title: sp.Title, Description: sp.Description, Image: sp.Image, OK: sp.OK}, true } // allowPrivatePreviewHosts is flipped on only in tests (so an httptest server on // loopback can be fetched); in production the SSRF guard always applies. var allowPrivatePreviewHosts = false // isPublicIP rejects loopback/private/link-local/CGNAT/unspecified addresses so a // posted URL can't make the server probe its own network (SSRF). func isPublicIP(ip net.IP) bool { if ip == nil { return false } if ip.IsLoopback() || ip.IsPrivate() || ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() || ip.IsMulticast() || ip.IsUnspecified() { return false } // 100.64.0.0/10 (CGNAT) — not covered by IsPrivate. if v4 := ip.To4(); v4 != nil && v4[0] == 100 && v4[1] >= 64 && v4[1] <= 127 { return false } return true } // guardDialControl blocks any connection whose *resolved* IP is not public, so a // server-side fetch can't be pointed at the server's own network (SSRF). Shared // by the unfurl fetcher and the Web Push sender — both dial attacker-influenced // hosts. Checking at dial time (not on the pre-resolution hostname) also defeats // DNS rebinding: every connection and redirect hop is re-validated. Honors // allowPrivatePreviewHosts so tests can reach a loopback httptest server. func guardDialControl(network, address string, _ syscall.RawConn) error { if allowPrivatePreviewHosts { return nil } host, _, err := net.SplitHostPort(address) if err != nil { return err } if !isPublicIP(net.ParseIP(host)) { return fmt.Errorf("blocked non-public address %s", address) } return nil } // ogClient fetches preview pages. Its dialer checks the *resolved* IP of every // connection, which also defeats DNS-rebinding (each hop is re-validated). var ogClient = &http.Client{ Timeout: previewTimeout, Transport: &http.Transport{ DialContext: (&net.Dialer{ Timeout: previewTimeout, Control: guardDialControl, }).DialContext, TLSHandshakeTimeout: previewTimeout, ResponseHeaderTimeout: previewTimeout, DisableKeepAlives: true, }, CheckRedirect: func(req *http.Request, via []*http.Request) error { if len(via) >= 5 { return errors.New("too many redirects") } if req.URL.Scheme != "http" && req.URL.Scheme != "https" { return errors.New("non-http redirect") } return nil }, } // fetchOG downloads a URL and parses its OpenGraph/HTML metadata. lang (a // normalised previewLang tag, may be "") is forwarded so the page negotiates // on the member's language, not the geolocation of whichever box fetches. func fetchOG(rawURL, lang string) (LinkPreview, error) { pv := LinkPreview{URL: rawURL} req, err := http.NewRequest("GET", rawURL, nil) if err != nil { return pv, err } req.Header.Set("User-Agent", "lchat-linkpreview/1.0 (+https://elevenmessenger.com)") req.Header.Set("Accept", "text/html,application/xhtml+xml") if lang != "" { al := lang // "en-IE" also offers plain "en", for sites that only know base tags. if base, _, found := strings.Cut(lang, "-"); found { al = lang + ", " + base + ";q=0.8" } req.Header.Set("Accept-Language", al) } resp, err := ogClient.Do(req) if err != nil { return pv, err } defer resp.Body.Close() if resp.StatusCode < 200 || resp.StatusCode >= 300 { return pv, fmt.Errorf("status %d", resp.StatusCode) } ct := strings.ToLower(resp.Header.Get("Content-Type")) if !strings.Contains(ct, "text/html") && !strings.Contains(ct, "xhtml") { return pv, errors.New("not html") } body, err := io.ReadAll(io.LimitReader(resp.Body, previewMaxBytes)) if err != nil { return pv, err } return parseOG(string(body), rawURL), nil } var ( metaTagRe = regexp.MustCompile(`(?is)]*>`) titleRe = regexp.MustCompile(`(?is)