// `lchat links run` — the 11ch.at short-link redirector (issue #905). Unlike // push/home (separate Go modules, separate binaries), this is a subcommand of // the same lchat binary — there's nothing instance-specific to import, so a // second module would just be ceremony. // // No database, no registry of any kind. A bare request to the apex (someone // types or clicks plain https://11ch.at/) bounces to the marketing site. // GET /l/ live-checks the target — an SSRF-guarded fetch of its own // GET /api/version, cached per host — before 302-ing to it; an unreachable or // non-lchat host just 404s. This is allowlist hygiene, not a security // boundary: anyone who controls can trivially pass the check by // serving genuine lchat-shaped JSON there. What actually limits abuse is that // the target is always visible in the URL itself (not a blind redirect) and // an unverifiable host never redirects at all (so this can't be driven as a // generic public URL shortener). See the design discussion on #905 for the // full reasoning, including why an earlier self-registration draft was // dropped in favor of this. // // The redirect never puts a fragment in Location, so a caller's own // #l= (the message-permalink fragment, links.go) survives the hop — // browsers reattach the original request's fragment when the redirect target // doesn't specify one, verified empirically for #905. package main import ( "crypto/tls" "encoding/json" "flag" "fmt" "io" "log" "net" "net/http" "os" "regexp" "sync" "time" ) const ( linksVerifyFreshTTL = 7 * 24 * time.Hour // a verified host stays good this long before re-checking linksVerifyFailTTL = time.Hour // an unreachable/non-lchat host is retried sooner linksVerifyTimeout = 5 * time.Second linksMarketingURL = "https://elevenmessenger.com" linksCacheSweepAt = 10000 // sweep stale entries once the cache grows past this (mirrors modLimiter) ) // validLinkHost bounds what we'll ever attempt to dial to a conservative // hostname shape — not a full RFC 1035 parser, just enough that garbage input // 404s before any network activity happens. The optional :port exists for // tests (a real instance is always plain 443, but nothing stops a self-hoster // running one behind a nonstandard port). 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})?$`) // linksInsecureSkipVerifyCert lets a test stand a self-signed httptest.NewTLSServer // in for a real instance. Never set outside a test — production always // validates the candidate host's certificate. var linksInsecureSkipVerifyCert = false // newLinksVerifyClient builds the client that fetches a candidate host's // /api/version. Same SSRF guard as unfurl.go's ogClient (guardDialControl) — // a host named in a /l/ path is exactly as attacker-influenced as a // member-pasted URL. Redirects aren't followed: we only ever trust the exact // host we were asked to verify. Built fresh per call (verification is rare — // cached — so this isn't a hot path) so it picks up // linksInsecureSkipVerifyCert at call time rather than at package init. func newLinksVerifyClient() *http.Client { return &http.Client{ Timeout: linksVerifyTimeout, Transport: &http.Transport{ DialContext: (&net.Dialer{ Timeout: linksVerifyTimeout, Control: guardDialControl, }).DialContext, TLSClientConfig: &tls.Config{InsecureSkipVerify: linksInsecureSkipVerifyCert}, //nolint:gosec // test-only, see the var's doc TLSHandshakeTimeout: linksVerifyTimeout, ResponseHeaderTimeout: linksVerifyTimeout, DisableKeepAlives: true, }, CheckRedirect: func(req *http.Request, via []*http.Request) error { return http.ErrUseLastResponse }, } } // linksVerifyCache remembers whether a host looked like a live lchat instance // last time we checked, so a redirect is a fast local read on any cache hit — // bounding the live-check cost to roughly once per host per TTL window rather // than once per click (see the package doc: this is what makes the whole // design safe to run with no rate limiting beyond that). type linksVerifyCache struct { mu sync.Mutex entries map[string]linksCacheEntry } type linksCacheEntry struct { ok bool checkedAt time.Time } func newLinksVerifyCache() *linksVerifyCache { return &linksVerifyCache{entries: map[string]linksCacheEntry{}} } func (c *linksVerifyCache) verify(host string) bool { c.mu.Lock() if e, ok := c.entries[host]; ok { ttl := linksVerifyFailTTL if e.ok { ttl = linksVerifyFreshTTL } if time.Since(e.checkedAt) < ttl { c.mu.Unlock() return e.ok } } c.mu.Unlock() ok := probeLchatInstance(host) c.mu.Lock() if len(c.entries) > linksCacheSweepAt { for k, e := range c.entries { if time.Since(e.checkedAt) > linksVerifyFreshTTL { delete(c.entries, k) } } } c.entries[host] = linksCacheEntry{ok: ok, checkedAt: time.Now()} c.mu.Unlock() return ok } // probeLchatInstance does the actual SSRF-guarded fetch + shape check: does // this host answer its own /api/version with the fields a real instance always // sets (main.go's getVersion)? Weak on purpose — see the package doc on what // this can and can't defend against. func probeLchatInstance(host string) bool { req, err := http.NewRequest("GET", "https://"+host+"/api/version", nil) if err != nil { return false } req.Header.Set("User-Agent", "lchat-links/1.0 (+https://elevenmessenger.com)") resp, err := newLinksVerifyClient().Do(req) if err != nil { return false } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return false } var v struct { Build string `json:"build"` Version string `json:"version"` } if json.NewDecoder(io.LimitReader(resp.Body, 4096)).Decode(&v) != nil { return false } return v.Build != "" && v.Version != "" } // linksServer holds the redirector's one piece of state (the verify cache) // plus a light per-IP rate limit on cache-miss attempts — generous for real // usage, a backstop against a script hammering unverified hosts. type linksServer struct { cache *linksVerifyCache limiter *modLimiter } func newLinksServer() *linksServer { return &linksServer{cache: newLinksVerifyCache(), limiter: newModLimiter(20, 10.0/60)} } func (l *linksServer) handler() http.Handler { mux := http.NewServeMux() // Lets a tap on an 11ch.at permalink open the app directly instead of // Safari — mirrors serveAASA (main.go) for /join/*, scoped to /l/*. mux.HandleFunc("GET /.well-known/apple-app-site-association", serveLinkAASA) mux.HandleFunc("GET /l/{host}", l.serveLinkRedirect) // Everything else — most notably the bare apex — has no content of its // own here, so send it to the real marketing site. mux.HandleFunc("/", serveLinksRoot) return mux } func serveLinksRoot(w http.ResponseWriter, r *http.Request) { http.Redirect(w, r, linksMarketingURL, http.StatusFound) } func serveLinkAASA(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") fmt.Fprintf(w, `{"applinks":{"details":[{"appIDs":[%q],"components":[{"/":"/l/*"}]}]}}`, appleAppID) } func (l *linksServer) serveLinkRedirect(w http.ResponseWriter, r *http.Request) { host := r.PathValue("host") if !validLinkHost.MatchString(host) { httpError(w, http.StatusNotFound, "not found") return } ip, _, err := net.SplitHostPort(r.RemoteAddr) if err != nil { ip = r.RemoteAddr } if !l.limiter.allow(ip) { httpError(w, http.StatusTooManyRequests, "slow down") return } if !l.cache.verify(host) { httpError(w, http.StatusNotFound, "not found") return } // No fragment here — the caller's own #l= (if any) reattaches // automatically; see the package doc. http.Redirect(w, r, "https://"+host+"/", http.StatusFound) } // runLinksCmd dispatches `lchat links `, mirroring runBotCmd's shape // (botcmd.go) — room for a future diagnostic subcommand (e.g. `links check // `) without a breaking rename of the one that matters today. func runLinksCmd(args []string) { if len(args) < 1 { fmt.Fprintln(os.Stderr, `usage: lchat links commands: run serve the 11ch.at short-link redirector (issue #905)`) os.Exit(2) } switch args[0] { case "run": runLinksRun(args[1:]) default: die("links: unknown command %q", args[0]) } } // runLinksRun is `lchat links run`. func runLinksRun(args []string) { fs := flag.NewFlagSet("links", flag.ExitOnError) addr := fs.String("addr", envOr("ADDR", ":1748"), "listen address (env: ADDR)") socket := fs.String("socket", envOr("LCHAT_SOCKET", ""), "unix socket to serve on instead of -addr (env: LCHAT_SOCKET) — how the router fronts this service") _ = fs.Parse(args) ln, addrDesc, err := listen(*addr, *socket) if err != nil { die("links: %v", err) } l := newLinksServer() log.Printf("links: serving on %s", addrDesc) srv := &http.Server{Handler: l.handler(), ReadHeaderTimeout: 10 * time.Second} if err := srv.Serve(ln); err != nil { log.Fatal(err) } }