eleven / ocho Public

Clone
git clone https://amadan.net/eleven/ocho

Plain git — no account needed to clone.

Download

Download this file

1package main
2
3import (
4 "crypto/aes"
5 "crypto/cipher"
6 "crypto/hkdf"
7 "crypto/rand"
8 "crypto/sha256"
9 "encoding/base64"
10 "encoding/hex"
11 "encoding/json"
12 "errors"
13 "fmt"
14 "html"
15 "io"
16 "net"
17 "net/http"
18 "net/url"
19 "regexp"
20 "strings"
21 "syscall"
22 "time"
23)
24
25// Link (OpenGraph) previews. The server fetches a posted URL only long enough to
26// extract its title/description/image, then caches the result. This is the one
27// place the server sees plaintext URLs (message text stays encrypted); it can be
28// disabled later if that trade-off stops being acceptable.
29
30const (
31 previewFreshTTL = 7 * 24 * 60 * 60 // serve-stale-after, then refresh in the background
32 previewFailTTL = 60 * 60 // retry failed/blank lookups sooner
33 previewMaxAge = 30 * 24 * 60 * 60 // evict cache rows older than this (put-time sweep)
34 previewMaxBytes = 512 * 1024 // only read the start of the page
35 previewTimeout = 6 * time.Second
36)
37
38// LinkPreview is the cached unfurl result (also the JSON shape sent to clients).
39type LinkPreview struct {
40 URL string `json:"url"`
41 Title string `json:"title"`
42 Description string `json:"description"`
43 Image string `json:"image"`
44 OK bool `json:"ok"`
45 FetchedAt int64 `json:"-"`
46}
47
48// ---- sealing the cache at rest (docs/no-plaintext.md) ---------------------
49//
50// Convergent encryption: both the row key and the AES key are derived from the
51// URL itself (plus a per-instance salt that only defeats cross-instance
52// precomputation). The server sees URLs transiently at request time — the
53// documented unfurl exception — derives, opens or seals, and stores only
54// ciphertext, so the cache at rest reveals nothing a reader doesn't already
55// know the URL for. Cards are fetched per requester language (the page can
56// differ), so the derivation input is the cache key — language + URL — not
57// the bare URL.
58
59// previewURLHash is the row key: hex sha256(salt || cache key).
60func previewURLHash(salt, u string) string {
61 h := sha256.Sum256([]byte(salt + u))
62 return hex.EncodeToString(h[:])
63}
64
65// previewCacheKey folds the requester's language into the string the row hash
66// and AEAD derive from, so members with different languages get (and cache)
67// different cards — a card fetched via a Finnish exit never serves to an "en"
68// member. Empty lang (no preference) keys exactly as before.
69func previewCacheKey(lang, u string) string {
70 if lang == "" {
71 return u
72 }
73 return lang + "\n" + u
74}
75
76var langTagRe = regexp.MustCompile(`^[A-Za-z]{1,8}(?:-[A-Za-z0-9]{1,8})*$`)
77
78// previewLang normalises an Accept-Language header to its first tag
79// ("en-IE,en;q=0.9,fi;q=0.8" → "en-IE"): one low-cardinality value to forward
80// upstream and to vary the cache by. "" (no preference) on absent/malformed.
81func previewLang(header string) string {
82 tag, _, _ := strings.Cut(header, ",")
83 tag, _, _ = strings.Cut(tag, ";")
84 tag = strings.TrimSpace(tag)
85 if !langTagRe.MatchString(tag) {
86 return ""
87 }
88 return tag
89}
90
91// previewAEAD derives the URL's AES-GCM cipher.
92func previewAEAD(salt, u string) (cipher.AEAD, error) {
93 key, err := hkdf.Key(sha256.New, []byte(u), []byte(salt), "lchat link preview v1", 32)
94 if err != nil {
95 return nil, err
96 }
97 block, err := aes.NewCipher(key)
98 if err != nil {
99 return nil, err
100 }
101 return cipher.NewGCM(block)
102}
103
104// sealedPreview is the card as stored inside the ciphertext — everything but
105// the URL (which is the key) and fetched_at (plaintext, for eviction).
106type sealedPreview struct {
107 Title string `json:"title"`
108 Description string `json:"description"`
109 Image string `json:"image"`
110 OK bool `json:"ok"`
111}
112
113// sealPreview encrypts a card to the base64url iv||ciphertext format
114// (crypto.js's convention, kept for consistency). key is the cache key
115// (previewCacheKey), not necessarily the bare URL.
116func sealPreview(salt, key string, pv LinkPreview) (string, error) {
117 aead, err := previewAEAD(salt, key)
118 if err != nil {
119 return "", err
120 }
121 iv := make([]byte, aead.NonceSize())
122 if _, err := rand.Read(iv); err != nil {
123 return "", err
124 }
125 plain, err := json.Marshal(sealedPreview{
126 Title: pv.Title, Description: pv.Description, Image: pv.Image, OK: pv.OK,
127 })
128 if err != nil {
129 return "", err
130 }
131 return base64.RawURLEncoding.EncodeToString(aead.Seal(iv, iv, plain, nil)), nil
132}
133
134// openPreview decrypts a stored card; false on any mismatch or corruption
135// (callers treat that as a cache miss). key matches sealPreview's.
136func openPreview(salt, key, blob string) (LinkPreview, bool) {
137 aead, err := previewAEAD(salt, key)
138 if err != nil {
139 return LinkPreview{}, false
140 }
141 raw, err := base64.RawURLEncoding.DecodeString(blob)
142 if err != nil || len(raw) < aead.NonceSize() {
143 return LinkPreview{}, false
144 }
145 plain, err := aead.Open(nil, raw[:aead.NonceSize()], raw[aead.NonceSize():], nil)
146 if err != nil {
147 return LinkPreview{}, false
148 }
149 var sp sealedPreview
150 if json.Unmarshal(plain, &sp) != nil {
151 return LinkPreview{}, false
152 }
153 return LinkPreview{Title: sp.Title, Description: sp.Description, Image: sp.Image, OK: sp.OK}, true
154}
155
156// allowPrivatePreviewHosts is flipped on only in tests (so an httptest server on
157// loopback can be fetched); in production the SSRF guard always applies.
158var allowPrivatePreviewHosts = false
159
160// isPublicIP rejects loopback/private/link-local/CGNAT/unspecified addresses so a
161// posted URL can't make the server probe its own network (SSRF).
162func isPublicIP(ip net.IP) bool {
163 if ip == nil {
164 return false
165 }
166 if ip.IsLoopback() || ip.IsPrivate() || ip.IsLinkLocalUnicast() ||
167 ip.IsLinkLocalMulticast() || ip.IsMulticast() || ip.IsUnspecified() {
168 return false
169 }
170 // 100.64.0.0/10 (CGNAT) — not covered by IsPrivate.
171 if v4 := ip.To4(); v4 != nil && v4[0] == 100 && v4[1] >= 64 && v4[1] <= 127 {
172 return false
173 }
174 return true
175}
176
177// guardDialControl blocks any connection whose *resolved* IP is not public, so a
178// server-side fetch can't be pointed at the server's own network (SSRF). Shared
179// by the unfurl fetcher and the Web Push sender — both dial attacker-influenced
180// hosts. Checking at dial time (not on the pre-resolution hostname) also defeats
181// DNS rebinding: every connection and redirect hop is re-validated. Honors
182// allowPrivatePreviewHosts so tests can reach a loopback httptest server.
183func guardDialControl(network, address string, _ syscall.RawConn) error {
184 if allowPrivatePreviewHosts {
185 return nil
186 }
187 host, _, err := net.SplitHostPort(address)
188 if err != nil {
189 return err
190 }
191 if !isPublicIP(net.ParseIP(host)) {
192 return fmt.Errorf("blocked non-public address %s", address)
193 }
194 return nil
195}
196
197// ogClient fetches preview pages. Its dialer checks the *resolved* IP of every
198// connection, which also defeats DNS-rebinding (each hop is re-validated).
199var ogClient = &http.Client{
200 Timeout: previewTimeout,
201 Transport: &http.Transport{
202 DialContext: (&net.Dialer{
203 Timeout: previewTimeout,
204 Control: guardDialControl,
205 }).DialContext,
206 TLSHandshakeTimeout: previewTimeout,
207 ResponseHeaderTimeout: previewTimeout,
208 DisableKeepAlives: true,
209 },
210 CheckRedirect: func(req *http.Request, via []*http.Request) error {
211 if len(via) >= 5 {
212 return errors.New("too many redirects")
213 }
214 if req.URL.Scheme != "http" && req.URL.Scheme != "https" {
215 return errors.New("non-http redirect")
216 }
217 return nil
218 },
219}
220
221// fetchOG downloads a URL and parses its OpenGraph/HTML metadata. lang (a
222// normalised previewLang tag, may be "") is forwarded so the page negotiates
223// on the member's language, not the geolocation of whichever box fetches.
224func fetchOG(rawURL, lang string) (LinkPreview, error) {
225 pv := LinkPreview{URL: rawURL}
226 req, err := http.NewRequest("GET", rawURL, nil)
227 if err != nil {
228 return pv, err
229 }
230 req.Header.Set("User-Agent", "lchat-linkpreview/1.0 (+https://elevenmessenger.com)")
231 req.Header.Set("Accept", "text/html,application/xhtml+xml")
232 if lang != "" {
233 al := lang
234 // "en-IE" also offers plain "en", for sites that only know base tags.
235 if base, _, found := strings.Cut(lang, "-"); found {
236 al = lang + ", " + base + ";q=0.8"
237 }
238 req.Header.Set("Accept-Language", al)
239 }
240
241 resp, err := ogClient.Do(req)
242 if err != nil {
243 return pv, err
244 }
245 defer resp.Body.Close()
246 if resp.StatusCode < 200 || resp.StatusCode >= 300 {
247 return pv, fmt.Errorf("status %d", resp.StatusCode)
248 }
249 ct := strings.ToLower(resp.Header.Get("Content-Type"))
250 if !strings.Contains(ct, "text/html") && !strings.Contains(ct, "xhtml") {
251 return pv, errors.New("not html")
252 }
253 body, err := io.ReadAll(io.LimitReader(resp.Body, previewMaxBytes))
254 if err != nil {
255 return pv, err
256 }
257 return parseOG(string(body), rawURL), nil
258}
259
260var (
261 metaTagRe = regexp.MustCompile(`(?is)<meta\b[^>]*>`)
262 titleRe = regexp.MustCompile(`(?is)<title[^>]*>(.*?)</title>`)
263 attrRe = regexp.MustCompile(`(?is)([a-z][a-z0-9:_-]*)\s*=\s*("([^"]*)"|'([^']*)'|([^\s"'>]+))`)
264 tagRe = regexp.MustCompile(`(?is)<[^>]+>`)
265)
266
267func tagAttrs(tag string) map[string]string {
268 out := map[string]string{}
269 for _, m := range attrRe.FindAllStringSubmatch(tag, -1) {
270 val := m[3]
271 if val == "" && m[4] != "" {
272 val = m[4]
273 }
274 if val == "" && m[5] != "" {
275 val = m[5]
276 }
277 out[strings.ToLower(m[1])] = html.UnescapeString(val)
278 }
279 return out
280}
281
282// parseOG extracts a preview from page HTML. og:* wins, then twitter:*, then the
283// <title>/<meta description> fallbacks. A relative og:image is resolved absolute.
284func parseOG(body, pageURL string) LinkPreview {
285 pv := LinkPreview{URL: pageURL}
286 for _, tag := range metaTagRe.FindAllString(body, -1) {
287 a := tagAttrs(tag)
288 key := a["property"]
289 if key == "" {
290 key = a["name"]
291 }
292 content := a["content"]
293 if content == "" {
294 continue
295 }
296 switch strings.ToLower(key) {
297 case "og:title", "twitter:title":
298 if pv.Title == "" {
299 pv.Title = content
300 }
301 case "og:description", "twitter:description", "description":
302 if pv.Description == "" {
303 pv.Description = content
304 }
305 case "og:image", "og:image:url", "og:image:secure_url", "twitter:image", "twitter:image:src":
306 if pv.Image == "" {
307 pv.Image = content
308 }
309 }
310 }
311 if pv.Title == "" {
312 if m := titleRe.FindStringSubmatch(body); m != nil {
313 pv.Title = strings.TrimSpace(html.UnescapeString(tagRe.ReplaceAllString(m[1], "")))
314 }
315 }
316 if pv.Image != "" {
317 pv.Image = resolveImageURL(pageURL, pv.Image)
318 }
319 pv.Title = truncate(strings.TrimSpace(pv.Title), 300)
320 pv.Description = truncate(strings.TrimSpace(pv.Description), 500)
321 pv.OK = pv.Title != "" || pv.Image != ""
322 return pv
323}
324
325// resolveImageURL makes an og:image absolute and keeps only http(s) results.
326func resolveImageURL(pageURL, img string) string {
327 base, err := url.Parse(pageURL)
328 if err != nil {
329 return ""
330 }
331 ref, err := url.Parse(strings.TrimSpace(img))
332 if err != nil {
333 return ""
334 }
335 abs := base.ResolveReference(ref)
336 if abs.Scheme != "http" && abs.Scheme != "https" {
337 return ""
338 }
339 return abs.String()
340}
341
342func truncate(s string, n int) string {
343 r := []rune(s)
344 if len(r) <= n {
345 return s
346 }
347 return strings.TrimSpace(string(r[:n])) + "…"
348}
349