mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-15 00:32:40 +00:00
URL link previews now post the page's og:image as an inline m.image thumbnail above the title/description reply. All outbound fetches in the preview path (page scrape, image HEAD probe, image download) route through a new SSRF/DoS-hardened safehttp client so a posted link can't steer fetches at loopback/RFC1918/cloud-metadata IPs or OOM the parser. Also flips the daily WOTD auto-post to opt-in (ENABLE_WOTD_POST=true) instead of opt-out.
147 lines
4.4 KiB
Go
147 lines
4.4 KiB
Go
// Package safehttp provides an http.Client hardened against SSRF and
|
|
// memory-DoS via hostile upstreams. Every outbound fetch the bot makes
|
|
// against feed-supplied URLs (RSS articles, image hosts) should go through
|
|
// one of these clients so a malicious feed can't steer the bot at loopback,
|
|
// link-local, RFC1918, or cloud metadata IPs, and can't OOM the process by
|
|
// streaming an unbounded body.
|
|
package safehttp
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"net"
|
|
"net/http"
|
|
"net/url"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
// ErrBlockedHost is returned when a URL resolves to a non-public IP.
|
|
var ErrBlockedHost = errors.New("safehttp: blocked non-public host")
|
|
|
|
// AllowPrivate, when true, disables the loopback/RFC1918 dial guard. It
|
|
// exists for tests that spin up httptest.NewServer on 127.0.0.1 — never
|
|
// set this in production.
|
|
var AllowPrivate bool
|
|
|
|
// safeDialContext refuses connections to non-public IPs. It runs after
|
|
// DNS resolution, so a hostile DNS rebinding that returns 127.0.0.1
|
|
// still gets blocked at dial time.
|
|
func safeDialContext(ctx context.Context, network, addr string) (net.Conn, error) {
|
|
host, port, err := net.SplitHostPort(addr)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
ips, err := (&net.Resolver{}).LookupIP(ctx, "ip", host)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var allowed net.IP
|
|
for _, ip := range ips {
|
|
if AllowPrivate || isPublicIP(ip) {
|
|
allowed = ip
|
|
break
|
|
}
|
|
}
|
|
if allowed == nil {
|
|
return nil, fmt.Errorf("%w: %s", ErrBlockedHost, host)
|
|
}
|
|
d := &net.Dialer{Timeout: 5 * time.Second, KeepAlive: 30 * time.Second}
|
|
return d.DialContext(ctx, network, net.JoinHostPort(allowed.String(), port))
|
|
}
|
|
|
|
// isPublicIP reports whether ip is a globally routable unicast address.
|
|
// Rejects loopback, link-local, multicast, RFC1918, CGNAT, and the
|
|
// AWS/GCP/Azure metadata IPs 169.254.169.254 / fd00:ec2::254 (these
|
|
// already fall under link-local but spell it out for clarity).
|
|
func isPublicIP(ip net.IP) bool {
|
|
if ip == nil || ip.IsUnspecified() || ip.IsLoopback() ||
|
|
ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() ||
|
|
ip.IsMulticast() || ip.IsPrivate() {
|
|
return false
|
|
}
|
|
// 100.64.0.0/10 (CGNAT) is not covered by IsPrivate on older Go.
|
|
if v4 := ip.To4(); v4 != nil {
|
|
if v4[0] == 100 && v4[1] >= 64 && v4[1] <= 127 {
|
|
return false
|
|
}
|
|
// 0.0.0.0/8 and friends.
|
|
if v4[0] == 0 {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
// ValidateURL returns nil if the URL is http(s) and parseable. It does
|
|
// not resolve DNS — the dial step does that — but it does reject bare
|
|
// schemes (file://, gopher://, etc.) before we even open a connection.
|
|
func ValidateURL(raw string) error {
|
|
u, err := url.Parse(strings.TrimSpace(raw))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if u.Scheme != "http" && u.Scheme != "https" {
|
|
return fmt.Errorf("safehttp: unsupported scheme %q", u.Scheme)
|
|
}
|
|
if u.Host == "" {
|
|
return errors.New("safehttp: empty host")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// NewClient returns an http.Client whose transport blocks non-public
|
|
// destinations at dial time, caps redirects at 5, and re-validates each
|
|
// redirect target's scheme. timeout is the per-request overall budget.
|
|
func NewClient(timeout time.Duration) *http.Client {
|
|
tr := &http.Transport{
|
|
DialContext: safeDialContext,
|
|
ForceAttemptHTTP2: true,
|
|
MaxIdleConns: 32,
|
|
IdleConnTimeout: 90 * time.Second,
|
|
TLSHandshakeTimeout: 5 * time.Second,
|
|
ExpectContinueTimeout: 1 * time.Second,
|
|
ResponseHeaderTimeout: 10 * time.Second,
|
|
}
|
|
return &http.Client{
|
|
Transport: tr,
|
|
Timeout: timeout,
|
|
CheckRedirect: func(req *http.Request, via []*http.Request) error {
|
|
if len(via) >= 5 {
|
|
return errors.New("safehttp: stopped after 5 redirects")
|
|
}
|
|
if req.URL.Scheme != "http" && req.URL.Scheme != "https" {
|
|
return fmt.Errorf("safehttp: unsupported redirect scheme %q", req.URL.Scheme)
|
|
}
|
|
return nil
|
|
},
|
|
}
|
|
}
|
|
|
|
// LimitedBody wraps r in a reader that errors once more than max bytes have
|
|
// been read. Use to cap how much of a response body downstream parsers
|
|
// (goquery, image.Decode) will ever see — a hostile origin streaming an
|
|
// endless body otherwise OOMs the process.
|
|
func LimitedBody(r io.Reader, max int64) io.Reader {
|
|
return &limitedReader{R: r, N: max}
|
|
}
|
|
|
|
type limitedReader struct {
|
|
R io.Reader
|
|
N int64
|
|
}
|
|
|
|
func (l *limitedReader) Read(p []byte) (int, error) {
|
|
if l.N <= 0 {
|
|
return 0, fmt.Errorf("safehttp: response body exceeded cap")
|
|
}
|
|
if int64(len(p)) > l.N {
|
|
p = p[:l.N]
|
|
}
|
|
n, err := l.R.Read(p)
|
|
l.N -= int64(n)
|
|
return n, err
|
|
}
|