eleven / ocho Public

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

Plain git — no account needed to clone.

Download

Download this file

1// `lchat links run` — the 11ch.at short-link redirector (issue #905). Unlike
2// push/home (separate Go modules, separate binaries), this is a subcommand of
3// the same lchat binary — there's nothing instance-specific to import, so a
4// second module would just be ceremony.
5//
6// No database, no registry of any kind. A bare request to the apex (someone
7// types or clicks plain https://11ch.at/) bounces to the marketing site.
8// GET /l/<host> live-checks the target — an SSRF-guarded fetch of its own
9// GET /api/version, cached per host — before 302-ing to it; an unreachable or
10// non-lchat host just 404s. This is allowlist hygiene, not a security
11// boundary: anyone who controls <host> can trivially pass the check by
12// serving genuine lchat-shaped JSON there. What actually limits abuse is that
13// the target is always visible in the URL itself (not a blind redirect) and
14// an unverifiable host never redirects at all (so this can't be driven as a
15// generic public URL shortener). See the design discussion on #905 for the
16// full reasoning, including why an earlier self-registration draft was
17// dropped in favor of this.
18//
19// The redirect never puts a fragment in Location, so a caller's own
20// #l=<token> (the message-permalink fragment, links.go) survives the hop —
21// browsers reattach the original request's fragment when the redirect target
22// doesn't specify one, verified empirically for #905.
23package main
24
25import (
26 "crypto/tls"
27 "encoding/json"
28 "flag"
29 "fmt"
30 "io"
31 "log"
32 "net"
33 "net/http"
34 "os"
35 "regexp"
36 "sync"
37 "time"
38)
39
40const (
41 linksVerifyFreshTTL = 7 * 24 * time.Hour // a verified host stays good this long before re-checking
42 linksVerifyFailTTL = time.Hour // an unreachable/non-lchat host is retried sooner
43 linksVerifyTimeout = 5 * time.Second
44 linksMarketingURL = "https://elevenmessenger.com"
45 linksCacheSweepAt = 10000 // sweep stale entries once the cache grows past this (mirrors modLimiter)
46)
47
48// validLinkHost bounds what we'll ever attempt to dial to a conservative
49// hostname shape — not a full RFC 1035 parser, just enough that garbage input
50// 404s before any network activity happens. The optional :port exists for
51// tests (a real instance is always plain 443, but nothing stops a self-hoster
52// running one behind a nonstandard port).
53var validLinkHost = regexp.MustCompile(`^[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)+(:\d{1,5})?$`)
54
55// linksInsecureSkipVerifyCert lets a test stand a self-signed httptest.NewTLSServer
56// in for a real instance. Never set outside a test — production always
57// validates the candidate host's certificate.
58var linksInsecureSkipVerifyCert = false
59
60// newLinksVerifyClient builds the client that fetches a candidate host's
61// /api/version. Same SSRF guard as unfurl.go's ogClient (guardDialControl) —
62// a host named in a /l/ path is exactly as attacker-influenced as a
63// member-pasted URL. Redirects aren't followed: we only ever trust the exact
64// host we were asked to verify. Built fresh per call (verification is rare —
65// cached — so this isn't a hot path) so it picks up
66// linksInsecureSkipVerifyCert at call time rather than at package init.
67func newLinksVerifyClient() *http.Client {
68 return &http.Client{
69 Timeout: linksVerifyTimeout,
70 Transport: &http.Transport{
71 DialContext: (&net.Dialer{
72 Timeout: linksVerifyTimeout,
73 Control: guardDialControl,
74 }).DialContext,
75 TLSClientConfig: &tls.Config{InsecureSkipVerify: linksInsecureSkipVerifyCert}, //nolint:gosec // test-only, see the var's doc
76 TLSHandshakeTimeout: linksVerifyTimeout,
77 ResponseHeaderTimeout: linksVerifyTimeout,
78 DisableKeepAlives: true,
79 },
80 CheckRedirect: func(req *http.Request, via []*http.Request) error {
81 return http.ErrUseLastResponse
82 },
83 }
84}
85
86// linksVerifyCache remembers whether a host looked like a live lchat instance
87// last time we checked, so a redirect is a fast local read on any cache hit —
88// bounding the live-check cost to roughly once per host per TTL window rather
89// than once per click (see the package doc: this is what makes the whole
90// design safe to run with no rate limiting beyond that).
91type linksVerifyCache struct {
92 mu sync.Mutex
93 entries map[string]linksCacheEntry
94}
95
96type linksCacheEntry struct {
97 ok bool
98 checkedAt time.Time
99}
100
101func newLinksVerifyCache() *linksVerifyCache {
102 return &linksVerifyCache{entries: map[string]linksCacheEntry{}}
103}
104
105func (c *linksVerifyCache) verify(host string) bool {
106 c.mu.Lock()
107 if e, ok := c.entries[host]; ok {
108 ttl := linksVerifyFailTTL
109 if e.ok {
110 ttl = linksVerifyFreshTTL
111 }
112 if time.Since(e.checkedAt) < ttl {
113 c.mu.Unlock()
114 return e.ok
115 }
116 }
117 c.mu.Unlock()
118
119 ok := probeLchatInstance(host)
120
121 c.mu.Lock()
122 if len(c.entries) > linksCacheSweepAt {
123 for k, e := range c.entries {
124 if time.Since(e.checkedAt) > linksVerifyFreshTTL {
125 delete(c.entries, k)
126 }
127 }
128 }
129 c.entries[host] = linksCacheEntry{ok: ok, checkedAt: time.Now()}
130 c.mu.Unlock()
131 return ok
132}
133
134// probeLchatInstance does the actual SSRF-guarded fetch + shape check: does
135// this host answer its own /api/version with the fields a real instance always
136// sets (main.go's getVersion)? Weak on purpose — see the package doc on what
137// this can and can't defend against.
138func probeLchatInstance(host string) bool {
139 req, err := http.NewRequest("GET", "https://"+host+"/api/version", nil)
140 if err != nil {
141 return false
142 }
143 req.Header.Set("User-Agent", "lchat-links/1.0 (+https://elevenmessenger.com)")
144 resp, err := newLinksVerifyClient().Do(req)
145 if err != nil {
146 return false
147 }
148 defer resp.Body.Close()
149 if resp.StatusCode != http.StatusOK {
150 return false
151 }
152 var v struct {
153 Build string `json:"build"`
154 Version string `json:"version"`
155 }
156 if json.NewDecoder(io.LimitReader(resp.Body, 4096)).Decode(&v) != nil {
157 return false
158 }
159 return v.Build != "" && v.Version != ""
160}
161
162// linksServer holds the redirector's one piece of state (the verify cache)
163// plus a light per-IP rate limit on cache-miss attempts — generous for real
164// usage, a backstop against a script hammering unverified hosts.
165type linksServer struct {
166 cache *linksVerifyCache
167 limiter *modLimiter
168}
169
170func newLinksServer() *linksServer {
171 return &linksServer{cache: newLinksVerifyCache(), limiter: newModLimiter(20, 10.0/60)}
172}
173
174func (l *linksServer) handler() http.Handler {
175 mux := http.NewServeMux()
176 // Lets a tap on an 11ch.at permalink open the app directly instead of
177 // Safari — mirrors serveAASA (main.go) for /join/*, scoped to /l/*.
178 mux.HandleFunc("GET /.well-known/apple-app-site-association", serveLinkAASA)
179 mux.HandleFunc("GET /l/{host}", l.serveLinkRedirect)
180 // Everything else — most notably the bare apex — has no content of its
181 // own here, so send it to the real marketing site.
182 mux.HandleFunc("/", serveLinksRoot)
183 return mux
184}
185
186func serveLinksRoot(w http.ResponseWriter, r *http.Request) {
187 http.Redirect(w, r, linksMarketingURL, http.StatusFound)
188}
189
190func serveLinkAASA(w http.ResponseWriter, r *http.Request) {
191 w.Header().Set("Content-Type", "application/json")
192 fmt.Fprintf(w, `{"applinks":{"details":[{"appIDs":[%q],"components":[{"/":"/l/*"}]}]}}`, appleAppID)
193}
194
195func (l *linksServer) serveLinkRedirect(w http.ResponseWriter, r *http.Request) {
196 host := r.PathValue("host")
197 if !validLinkHost.MatchString(host) {
198 httpError(w, http.StatusNotFound, "not found")
199 return
200 }
201 ip, _, err := net.SplitHostPort(r.RemoteAddr)
202 if err != nil {
203 ip = r.RemoteAddr
204 }
205 if !l.limiter.allow(ip) {
206 httpError(w, http.StatusTooManyRequests, "slow down")
207 return
208 }
209 if !l.cache.verify(host) {
210 httpError(w, http.StatusNotFound, "not found")
211 return
212 }
213 // No fragment here — the caller's own #l=<token> (if any) reattaches
214 // automatically; see the package doc.
215 http.Redirect(w, r, "https://"+host+"/", http.StatusFound)
216}
217
218// runLinksCmd dispatches `lchat links <command>`, mirroring runBotCmd's shape
219// (botcmd.go) — room for a future diagnostic subcommand (e.g. `links check
220// <host>`) without a breaking rename of the one that matters today.
221func runLinksCmd(args []string) {
222 if len(args) < 1 {
223 fmt.Fprintln(os.Stderr, `usage: lchat links <command>
224
225commands:
226 run serve the 11ch.at short-link redirector (issue #905)`)
227 os.Exit(2)
228 }
229 switch args[0] {
230 case "run":
231 runLinksRun(args[1:])
232 default:
233 die("links: unknown command %q", args[0])
234 }
235}
236
237// runLinksRun is `lchat links run`.
238func runLinksRun(args []string) {
239 fs := flag.NewFlagSet("links", flag.ExitOnError)
240 addr := fs.String("addr", envOr("ADDR", ":1748"), "listen address (env: ADDR)")
241 socket := fs.String("socket", envOr("LCHAT_SOCKET", ""), "unix socket to serve on instead of -addr (env: LCHAT_SOCKET) — how the router fronts this service")
242 _ = fs.Parse(args)
243
244 ln, addrDesc, err := listen(*addr, *socket)
245 if err != nil {
246 die("links: %v", err)
247 }
248 l := newLinksServer()
249 log.Printf("links: serving on %s", addrDesc)
250 srv := &http.Server{Handler: l.handler(), ReadHeaderTimeout: 10 * time.Second}
251 if err := srv.Serve(ln); err != nil {
252 log.Fatal(err)
253 }
254}
255