diff --git a/README.md b/README.md index e797ceff..e91730cc 100644 --- a/README.md +++ b/README.md @@ -169,8 +169,22 @@ WEBHOOK_FORMAT=json # or "form" for the default SESSION_DEVICE_NAME=WuzAPI WUZAPI_PORT=8080 # Port for the WuzAPI server WUZAPI_GLOBAL_WEBHOOK= # Global webhook URL for all instances +OG_FETCH_PROXY= # Proxy used only for link-preview (Open Graph) fetches ``` +#### Link preview behind a proxy + +Some sites answer a captcha or geo-block page instead of the real page when the +request arrives from outside the country they serve, so the preview card ends up +showing the block page's title. Setting `OG_FETCH_PROXY` (for example +`http://10.0.0.5:3128`) routes **only** the Open Graph fetch through that proxy, +leaving WhatsApp session traffic on its own connection — that one is configured +separately, per user, through `proxy_url` and the `/session/proxy` endpoint. + +An invalid value is logged and ignored rather than failing startup. Because the +proxy resolves the target host, the SSRF guard that normally refuses private +addresses no longer sees it, so the proxy itself should deny internal ranges. + ### RabbitMQ Integration WuzAPI supports sending WhatsApp events to a RabbitMQ queue for global event distribution. When enabled, all WhatsApp events will be published to the specified queue regardless of individual user webhook configurations. diff --git a/main.go b/main.go index f669dfbf..a0023e94 100644 --- a/main.go +++ b/main.go @@ -7,6 +7,7 @@ import ( "math/rand" "net" "net/http" + "net/url" "os" "os/signal" "path/filepath" @@ -82,6 +83,11 @@ var ( var privateIPBlocks []*net.IPNet +var ( + ogProxyOnce sync.Once + ogProxyURL *url.URL +) + const version = "1.0.8" // killchannel maps a userID to its session goroutine's kill channel. It is @@ -132,16 +138,81 @@ func signalKill(userID string) { } } +// parseOGFetchProxy validates the OG_FETCH_PROXY value. An empty or malformed +// value disables the proxy rather than failing startup: a broken preview card +// is a much smaller problem than a server that refuses to boot. +func parseOGFetchProxy(raw string) (*url.URL, error) { + raw = strings.TrimSpace(raw) + if raw == "" { + return nil, nil + } + parsed, err := url.Parse(raw) + if err != nil { + return nil, err + } + if parsed.Scheme == "" || parsed.Host == "" { + return nil, fmt.Errorf("proxy URL needs a scheme and a host, got %q", raw) + } + return parsed, nil +} + +// ogFetchProxy returns the proxy to use for Open Graph fetches, or nil when +// OG_FETCH_PROXY is unset. +// +// Some sites answer a captcha or geo-block page instead of the real page when +// the request arrives from outside the country they serve, so the preview card +// ends up showing the block page's title instead of the article or product. +// Pointing this one fetch at a proxy inside the target country fixes the card. +// +// It is deliberately not the conventional HTTPS_PROXY: globalHTTPClient is used +// by nothing but the Open Graph fetch, and a dedicated name keeps a generic +// proxy setting from silently capturing traffic it was never meant to. WhatsApp +// session traffic uses whatsmeow's own transport (and its own per-user proxy_url +// setting) and is unaffected either way. +// +// Resolution is lazy because globalHTTPClient is a package-level var built +// before main() loads the .env file; reading the variable on first use keeps +// both the process environment and .env working. +func ogFetchProxy() *url.URL { + ogProxyOnce.Do(func() { + parsed, err := parseOGFetchProxy(os.Getenv("OG_FETCH_PROXY")) + if err != nil { + log.Warn().Err(err).Msg("Invalid OG_FETCH_PROXY, Open Graph fetches will go out directly") + return + } + if parsed == nil { + return + } + ogProxyURL = parsed + log.Info().Str("proxy", parsed.Redacted()).Msg("Open Graph fetches will go through the configured proxy") + }) + return ogProxyURL +} + func newSafeHTTPClient() *http.Client { return &http.Client{ Timeout: 60 * time.Second, Transport: &http.Transport{ + Proxy: func(*http.Request) (*url.URL, error) { + return ogFetchProxy(), nil + }, DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) { host, port, err := net.SplitHostPort(addr) if err != nil { return nil, fmt.Errorf("unexpected address format from http transport: %q: %w", addr, err) } + // With a proxy configured this dial targets the proxy itself, + // which commonly sits on a private address, so the SSRF guard + // below has to let it through. Note what that shifts: the real + // target is no longer resolved here, so refusing internal + // destinations becomes the proxy's responsibility and its ACL + // must deny private ranges. + allowPrivate := false + if proxyURL := ogFetchProxy(); proxyURL != nil && host == proxyURL.Hostname() { + allowPrivate = true + } + ips, err := net.LookupIP(host) if err != nil { return nil, fmt.Errorf("failed to resolve host '%s': %w", host, err) @@ -157,7 +228,7 @@ func newSafeHTTPClient() *http.Client { ) for _, ip := range ips { - if isPrivateOrLoopback(ip) { + if !allowPrivate && isPrivateOrLoopback(ip) { log.Warn().Str("ip", ip.String()).Str("host", host).Msg("SSRF attempt detected: refused to connect to private or local address") ssrfDetected = true if ssrfLastError == nil { diff --git a/og_proxy_test.go b/og_proxy_test.go new file mode 100644 index 00000000..e52ba665 --- /dev/null +++ b/og_proxy_test.go @@ -0,0 +1,60 @@ +package main + +import "testing" + +func TestParseOGFetchProxy(t *testing.T) { + cases := []struct { + name string + raw string + want string + wantErr bool + }{ + {name: "unset disables the proxy", raw: "", want: ""}, + {name: "blanks disable the proxy", raw: " ", want: ""}, + {name: "http proxy", raw: "http://10.0.0.5:3128", want: "http://10.0.0.5:3128"}, + {name: "hostname proxy", raw: "http://proxy.internal:8080", want: "http://proxy.internal:8080"}, + {name: "credentials are preserved", raw: "http://user:pass@10.0.0.5:3128", want: "http://user:pass@10.0.0.5:3128"}, + {name: "surrounding blanks are trimmed", raw: " http://10.0.0.5:3128 ", want: "http://10.0.0.5:3128"}, + {name: "missing scheme is rejected", raw: "10.0.0.5:3128", wantErr: true}, + {name: "missing host is rejected", raw: "http://", wantErr: true}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got, err := parseOGFetchProxy(tc.raw) + if tc.wantErr { + if err == nil { + t.Fatalf("expected an error for %q, got %v", tc.raw, got) + } + return + } + if err != nil { + t.Fatalf("unexpected error for %q: %v", tc.raw, err) + } + if tc.want == "" { + if got != nil { + t.Fatalf("expected no proxy for %q, got %v", tc.raw, got) + } + return + } + if got == nil { + t.Fatalf("expected proxy %q, got none", tc.want) + } + if got.String() != tc.want { + t.Fatalf("expected proxy %q, got %q", tc.want, got.String()) + } + }) + } +} + +// A malformed value must not take the server down: startup keeps going with +// previews fetched directly, which is the pre-existing behaviour. +func TestParseOGFetchProxyRejectsWithoutPanicking(t *testing.T) { + got, err := parseOGFetchProxy("://not-a-url") + if err == nil { + t.Fatalf("expected an error, got %v", got) + } + if got != nil { + t.Fatalf("expected no proxy alongside the error, got %v", got) + } +}