Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
73 changes: 72 additions & 1 deletion main.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import (
"math/rand"
"net"
"net/http"
"net/url"
"os"
"os/signal"
"path/filepath"
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand All @@ -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 {
Expand Down
60 changes: 60 additions & 0 deletions og_proxy_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}