| 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. |
| 23 | package main |
| 24 | |
| 25 | import ( |
| 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 | |
| 40 | const ( |
| 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). |
| 53 | var 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. |
| 58 | var 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. |
| 67 | func 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). |
| 91 | type linksVerifyCache struct { |
| 92 | mu sync.Mutex |
| 93 | entries map[string]linksCacheEntry |
| 94 | } |
| 95 | |
| 96 | type linksCacheEntry struct { |
| 97 | ok bool |
| 98 | checkedAt time.Time |
| 99 | } |
| 100 | |
| 101 | func newLinksVerifyCache() *linksVerifyCache { |
| 102 | return &linksVerifyCache{entries: map[string]linksCacheEntry{}} |
| 103 | } |
| 104 | |
| 105 | func (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. |
| 138 | func 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. |
| 165 | type linksServer struct { |
| 166 | cache *linksVerifyCache |
| 167 | limiter *modLimiter |
| 168 | } |
| 169 | |
| 170 | func newLinksServer() *linksServer { |
| 171 | return &linksServer{cache: newLinksVerifyCache(), limiter: newModLimiter(20, 10.0/60)} |
| 172 | } |
| 173 | |
| 174 | func (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 | |
| 186 | func serveLinksRoot(w http.ResponseWriter, r *http.Request) { |
| 187 | http.Redirect(w, r, linksMarketingURL, http.StatusFound) |
| 188 | } |
| 189 | |
| 190 | func 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 | |
| 195 | func (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. |
| 221 | func runLinksCmd(args []string) { |
| 222 | if len(args) < 1 { |
| 223 | fmt.Fprintln(os.Stderr, `usage: lchat links <command> |
| 224 | |
| 225 | commands: |
| 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`. |
| 238 | func 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 | |