Przeglądaj źródła

feat: add SSRF protection for integration HTTP clients

Add a shared HTTP client factory that blocks connections to private
network addresses at connect time via a custom DialContext, preventing
SSRF and DNS-rebinding attacks.

A new INTEGRATION_ALLOW_PRIVATE_NETWORKS option (default: false)
controls this behavior. Integrations targeting fixed external services
(Telegram, Archive.org, Pinboard, Notion, Instapaper) skip the check.
Frédéric Guillot 1 miesiąc temu
rodzic
commit
f9b756ecf8
41 zmienionych plików z 406 dodań i 40 usunięć
  1. 1 0
      Makefile
  2. 12 0
      internal/config/options.go
  3. 24 0
      internal/config/options_parsing_test.go
  4. 70 0
      internal/http/client/client.go
  5. 113 0
      internal/http/client/client_test.go
  6. 3 1
      internal/integration/apprise/apprise.go
  7. 2 1
      internal/integration/archiveorg/archiveorg.go
  8. 3 1
      internal/integration/betula/betula.go
  9. 3 1
      internal/integration/cubox/cubox.go
  10. 3 1
      internal/integration/discord/discord.go
  11. 3 1
      internal/integration/espial/espial.go
  12. 2 1
      internal/integration/instapaper/instapaper.go
  13. 3 1
      internal/integration/karakeep/karakeep.go
  14. 3 1
      internal/integration/linkace/linkace.go
  15. 3 1
      internal/integration/linkding/linkding.go
  16. 3 1
      internal/integration/linktaco/linktaco.go
  17. 24 0
      internal/integration/linktaco/linktaco_test.go
  18. 3 1
      internal/integration/linkwarden/linkwarden.go
  19. 21 0
      internal/integration/linkwarden/linkwarden_test.go
  20. 5 3
      internal/integration/matrixbot/client.go
  21. 2 1
      internal/integration/notion/notion.go
  22. 2 1
      internal/integration/ntfy/ntfy.go
  23. 3 1
      internal/integration/nunuxkeeper/nunuxkeeper.go
  24. 3 1
      internal/integration/omnivore/omnivore.go
  25. 3 2
      internal/integration/pinboard/pinboard.go
  26. 3 1
      internal/integration/pushover/pushover.go
  27. 3 1
      internal/integration/raindrop/raindrop.go
  28. 3 1
      internal/integration/readeck/readeck.go
  29. 22 0
      internal/integration/readeck/readeck_test.go
  30. 3 1
      internal/integration/readwise/readwise.go
  31. 3 1
      internal/integration/rssbridge/rssbridge.go
  32. 3 1
      internal/integration/shaarli/shaarli.go
  33. 4 2
      internal/integration/shiori/shiori.go
  34. 3 1
      internal/integration/slack/slack.go
  35. 3 2
      internal/integration/telegrambot/client.go
  36. 4 2
      internal/integration/wallabag/wallabag.go
  37. 22 0
      internal/integration/wallabag/wallabag_test.go
  38. 3 1
      internal/integration/webhook/webhook.go
  39. 3 3
      internal/urllib/url.go
  40. 1 1
      internal/urllib/url_test.go
  41. 6 1
      miniflux.1

+ 1 - 0
Makefile

@@ -114,6 +114,7 @@ integration-test:
 	RUN_MIGRATIONS=1 \
 	LOG_LEVEL=debug \
 	FETCHER_ALLOW_PRIVATE_NETWORKS=1 \
+	INTEGRATION_ALLOW_PRIVATE_NETWORKS=1 \
 	go run main.go >/tmp/miniflux.log 2>&1 & echo "$$!" > "/tmp/miniflux.pid"
 
 	while ! nc -z localhost 8080; do sleep 1; done

+ 12 - 0
internal/config/options.go

@@ -298,6 +298,11 @@ func NewConfigOptions() *configOptions {
 				rawValue:        "0",
 				valueType:       boolType,
 			},
+			"INTEGRATION_ALLOW_PRIVATE_NETWORKS": {
+				parsedBoolValue: false,
+				rawValue:        "0",
+				valueType:       boolType,
+			},
 			"INVIDIOUS_INSTANCE": {
 				parsedStringValue: "yewtu.be",
 				rawValue:          "yewtu.be",
@@ -790,6 +795,13 @@ func (c *configOptions) FetcherAllowPrivateNetworks() bool {
 	return c.options["FETCHER_ALLOW_PRIVATE_NETWORKS"].parsedBoolValue
 }
 
+func (c *configOptions) IntegrationAllowPrivateNetworks() bool {
+	if c == nil {
+		return false
+	}
+	return c.options["INTEGRATION_ALLOW_PRIVATE_NETWORKS"].parsedBoolValue
+}
+
 func (c *configOptions) InvidiousInstance() string {
 	return c.options["INVIDIOUS_INSTANCE"].parsedStringValue
 }

+ 24 - 0
internal/config/options_parsing_test.go

@@ -1375,6 +1375,30 @@ func TestFetcherAllowPrivateNetworksOptionParsing(t *testing.T) {
 	}
 }
 
+func TestIntegrationAllowPrivateNetworksOptionParsing(t *testing.T) {
+	configParser := NewConfigParser()
+
+	if configParser.options.IntegrationAllowPrivateNetworks() {
+		t.Fatalf("Expected INTEGRATION_ALLOW_PRIVATE_NETWORKS to be disabled by default")
+	}
+
+	if err := configParser.parseLines([]string{"INTEGRATION_ALLOW_PRIVATE_NETWORKS=1"}); err != nil {
+		t.Fatalf("Unexpected error: %v", err)
+	}
+
+	if !configParser.options.IntegrationAllowPrivateNetworks() {
+		t.Fatalf("Expected INTEGRATION_ALLOW_PRIVATE_NETWORKS to be enabled")
+	}
+
+	if err := configParser.parseLines([]string{"INTEGRATION_ALLOW_PRIVATE_NETWORKS=0"}); err != nil {
+		t.Fatalf("Unexpected error: %v", err)
+	}
+
+	if configParser.options.IntegrationAllowPrivateNetworks() {
+		t.Fatalf("Expected INTEGRATION_ALLOW_PRIVATE_NETWORKS to be disabled")
+	}
+}
+
 func TestHTTPServerTimeoutOptionParsing(t *testing.T) {
 	configParser := NewConfigParser()
 

+ 70 - 0
internal/http/client/client.go

@@ -0,0 +1,70 @@
+// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
+// SPDX-License-Identifier: Apache-2.0
+
+package client // import "miniflux.app/v2/internal/http/client"
+
+import (
+	"context"
+	"errors"
+	"fmt"
+	"net"
+	"net/http"
+	"time"
+
+	"miniflux.app/v2/internal/urllib"
+)
+
+// ErrPrivateNetwork is returned when a connection to a private network is blocked.
+var ErrPrivateNetwork = errors.New("client: connection to private network is blocked")
+
+// Options holds configuration for creating an HTTP client.
+type Options struct {
+	Timeout              time.Duration
+	BlockPrivateNetworks bool
+}
+
+// NewClientWithOptions creates a new HTTP client with the specified options.
+func NewClientWithOptions(opts Options) *http.Client {
+	if !opts.BlockPrivateNetworks {
+		return &http.Client{Timeout: opts.Timeout}
+	}
+
+	dialer := &net.Dialer{
+		Timeout: opts.Timeout,
+	}
+
+	transport := &http.Transport{
+		// The check is performed at connect time on the actual resolved IP, which eliminates TOCTOU / DNS-rebinding vulnerabilities.
+		DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) {
+			host, port, err := net.SplitHostPort(addr)
+			if err != nil {
+				return nil, fmt.Errorf("client: unable to parse address %q: %w", addr, err)
+			}
+
+			ips, err := net.LookupIP(host)
+			if err != nil {
+				return nil, fmt.Errorf("client: unable to resolve host %q: %w", host, err)
+			}
+
+			var safeIP net.IP
+			for _, ip := range ips {
+				if !urllib.IsNonPublicIP(ip) {
+					safeIP = ip
+					break
+				}
+			}
+
+			if safeIP == nil {
+				return nil, fmt.Errorf("%w: host %q resolves to a non-public IP address", ErrPrivateNetwork, host)
+			}
+
+			safeAddr := net.JoinHostPort(safeIP.String(), port)
+			return dialer.DialContext(ctx, network, safeAddr)
+		},
+	}
+
+	return &http.Client{
+		Timeout:   opts.Timeout,
+		Transport: transport,
+	}
+}

+ 113 - 0
internal/http/client/client_test.go

@@ -0,0 +1,113 @@
+// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
+// SPDX-License-Identifier: Apache-2.0
+
+package client
+
+import (
+	"errors"
+	"net"
+	"net/http"
+	"net/http/httptest"
+	"testing"
+	"time"
+)
+
+func TestNewClientWithoutBlockingPrivateNetworks(t *testing.T) {
+	server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		w.WriteHeader(http.StatusOK)
+	}))
+	defer server.Close()
+
+	client := NewClientWithOptions(Options{Timeout: 5 * time.Second})
+	resp, err := client.Get(server.URL)
+	if err != nil {
+		t.Fatalf("Expected no error, got %v", err)
+	}
+	defer resp.Body.Close()
+
+	if resp.StatusCode != http.StatusOK {
+		t.Fatalf("Expected status 200, got %d", resp.StatusCode)
+	}
+}
+
+func TestBlockPrivateNetworksBlocksLoopback(t *testing.T) {
+	server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		w.WriteHeader(http.StatusOK)
+	}))
+	defer server.Close()
+
+	client := NewClientWithOptions(Options{Timeout: 5 * time.Second, BlockPrivateNetworks: true})
+	_, err := client.Get(server.URL)
+	if err == nil {
+		t.Fatal("Expected an error when connecting to loopback address, got nil")
+	}
+
+	if !errors.Is(err, ErrPrivateNetwork) {
+		t.Fatalf("Expected ErrPrivateNetwork, got %v", err)
+	}
+}
+
+func TestBlockPrivateNetworksAllowsPublicIPs(t *testing.T) {
+	client := NewClientWithOptions(Options{Timeout: 5 * time.Second, BlockPrivateNetworks: true})
+	if client == nil {
+		t.Fatal("Expected non-nil client")
+	}
+
+	transport, ok := client.Transport.(*http.Transport)
+	if !ok {
+		t.Fatal("Expected custom http.Transport when blockPrivateNetworks is true")
+	}
+	if transport.DialContext == nil {
+		t.Fatal("Expected custom DialContext when blockPrivateNetworks is true")
+	}
+}
+
+func TestNoCustomTransportWhenNotBlocking(t *testing.T) {
+	client := NewClientWithOptions(Options{Timeout: 5 * time.Second})
+	if client.Transport != nil {
+		t.Fatal("Expected nil transport when blockPrivateNetworks is false")
+	}
+}
+
+func TestBlockPrivateNetworksBlocksPrivateIP(t *testing.T) {
+	listener, err := net.Listen("tcp", "127.0.0.1:0")
+	if err != nil {
+		t.Fatalf("Failed to create listener: %v", err)
+	}
+	defer listener.Close()
+
+	server := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		w.WriteHeader(http.StatusOK)
+	}))
+	server.Listener = listener
+	server.Start()
+	defer server.Close()
+
+	client := NewClientWithOptions(Options{Timeout: 5 * time.Second, BlockPrivateNetworks: true})
+	_, err = client.Get(server.URL)
+	if err == nil {
+		t.Fatal("Expected error when connecting to private IP")
+	}
+
+	if !errors.Is(err, ErrPrivateNetwork) {
+		t.Fatalf("Expected ErrPrivateNetwork, got: %v", err)
+	}
+}
+
+func TestBlockPrivateNetworksAllowsLoopbackWhenDisabled(t *testing.T) {
+	server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		w.WriteHeader(http.StatusOK)
+	}))
+	defer server.Close()
+
+	client := NewClientWithOptions(Options{Timeout: 5 * time.Second})
+	resp, err := client.Get(server.URL)
+	if err != nil {
+		t.Fatalf("Expected no error when blockPrivateNetworks is false, got %v", err)
+	}
+	defer resp.Body.Close()
+
+	if resp.StatusCode != http.StatusOK {
+		t.Fatalf("Expected status 200, got %d", resp.StatusCode)
+	}
+}

+ 3 - 1
internal/integration/apprise/apprise.go

@@ -12,6 +12,8 @@ import (
 	"net/http"
 	"time"
 
+	"miniflux.app/v2/internal/config"
+	"miniflux.app/v2/internal/http/client"
 	"miniflux.app/v2/internal/model"
 	"miniflux.app/v2/internal/urllib"
 	"miniflux.app/v2/internal/version"
@@ -65,7 +67,7 @@ func (c *Client) SendNotification(feed *model.Feed, entries model.Entries) error
 			slog.String("entry_url", entry.URL),
 		)
 
-		httpClient := &http.Client{Timeout: defaultClientTimeout}
+		httpClient := client.NewClientWithOptions(client.Options{Timeout: defaultClientTimeout, BlockPrivateNetworks: !config.Opts.IntegrationAllowPrivateNetworks()})
 		response, err := httpClient.Do(request)
 		if err != nil {
 			return fmt.Errorf("apprise: unable to send request: %v", err)

+ 2 - 1
internal/integration/archiveorg/archiveorg.go

@@ -9,6 +9,7 @@ import (
 	"net/url"
 	"time"
 
+	"miniflux.app/v2/internal/http/client"
 	"miniflux.app/v2/internal/version"
 )
 
@@ -32,7 +33,7 @@ func (c *Client) SendURL(entryURL string) error {
 
 	request.Header.Set("User-Agent", "Miniflux/"+version.Version)
 
-	httpClient := &http.Client{Timeout: defaultClientTimeout}
+	httpClient := client.NewClientWithOptions(client.Options{Timeout: defaultClientTimeout})
 	response, err := httpClient.Do(request)
 	if err != nil {
 		return fmt.Errorf("archiveorg: unable to send request: %v", err)

+ 3 - 1
internal/integration/betula/betula.go

@@ -10,6 +10,8 @@ import (
 	"strings"
 	"time"
 
+	"miniflux.app/v2/internal/config"
+	"miniflux.app/v2/internal/http/client"
 	"miniflux.app/v2/internal/urllib"
 	"miniflux.app/v2/internal/version"
 )
@@ -44,7 +46,7 @@ func (c *Client) CreateBookmark(entryURL, entryTitle string, tags []string) erro
 	request.Header.Set("User-Agent", "Miniflux/"+version.Version)
 	request.AddCookie(&http.Cookie{Name: "betula-token", Value: c.token})
 
-	httpClient := &http.Client{Timeout: defaultClientTimeout}
+	httpClient := client.NewClientWithOptions(client.Options{Timeout: defaultClientTimeout, BlockPrivateNetworks: !config.Opts.IntegrationAllowPrivateNetworks()})
 	response, err := httpClient.Do(request)
 	if err != nil {
 		return fmt.Errorf("betula: unable to send request: %v", err)

+ 3 - 1
internal/integration/cubox/cubox.go

@@ -14,6 +14,8 @@ import (
 	"net/http"
 	"time"
 
+	"miniflux.app/v2/internal/config"
+	"miniflux.app/v2/internal/http/client"
 	"miniflux.app/v2/internal/version"
 )
 
@@ -51,7 +53,7 @@ func (c *Client) SaveLink(entryURL string) error {
 	request.Header.Set("Content-Type", "application/json")
 	request.Header.Set("User-Agent", "Miniflux/"+version.Version)
 
-	response, err := http.DefaultClient.Do(request)
+	response, err := client.NewClientWithOptions(client.Options{Timeout: defaultClientTimeout, BlockPrivateNetworks: !config.Opts.IntegrationAllowPrivateNetworks()}).Do(request)
 	if err != nil {
 		return fmt.Errorf("cubox: unable to send request: %w", err)
 	}

+ 3 - 1
internal/integration/discord/discord.go

@@ -13,6 +13,8 @@ import (
 	"net/http"
 	"time"
 
+	"miniflux.app/v2/internal/config"
+	"miniflux.app/v2/internal/http/client"
 	"miniflux.app/v2/internal/model"
 	"miniflux.app/v2/internal/urllib"
 	"miniflux.app/v2/internal/version"
@@ -77,7 +79,7 @@ func (c *Client) SendDiscordMsg(feed *model.Feed, entries model.Entries) error {
 			slog.String("entry_url", entry.URL),
 		)
 
-		httpClient := &http.Client{Timeout: defaultClientTimeout}
+		httpClient := client.NewClientWithOptions(client.Options{Timeout: defaultClientTimeout, BlockPrivateNetworks: !config.Opts.IntegrationAllowPrivateNetworks()})
 		response, err := httpClient.Do(request)
 		if err != nil {
 			return fmt.Errorf("discord: unable to send request: %v", err)

+ 3 - 1
internal/integration/espial/espial.go

@@ -11,6 +11,8 @@ import (
 	"net/http"
 	"time"
 
+	"miniflux.app/v2/internal/config"
+	"miniflux.app/v2/internal/http/client"
 	"miniflux.app/v2/internal/urllib"
 	"miniflux.app/v2/internal/version"
 )
@@ -56,7 +58,7 @@ func (c *Client) CreateLink(entryURL, entryTitle, espialTags string) error {
 	request.Header.Set("User-Agent", "Miniflux/"+version.Version)
 	request.Header.Set("Authorization", "ApiKey "+c.apiKey)
 
-	httpClient := &http.Client{Timeout: defaultClientTimeout}
+	httpClient := client.NewClientWithOptions(client.Options{Timeout: defaultClientTimeout, BlockPrivateNetworks: !config.Opts.IntegrationAllowPrivateNetworks()})
 	response, err := httpClient.Do(request)
 	if err != nil {
 		return fmt.Errorf("espial: unable to send request: %v", err)

+ 2 - 1
internal/integration/instapaper/instapaper.go

@@ -10,6 +10,7 @@ import (
 	"net/url"
 	"time"
 
+	"miniflux.app/v2/internal/http/client"
 	"miniflux.app/v2/internal/version"
 )
 
@@ -42,7 +43,7 @@ func (c *Client) AddURL(entryURL, entryTitle string) error {
 	request.SetBasicAuth(c.username, c.password)
 	request.Header.Set("User-Agent", "Miniflux/"+version.Version)
 
-	httpClient := &http.Client{Timeout: defaultClientTimeout}
+	httpClient := client.NewClientWithOptions(client.Options{Timeout: defaultClientTimeout})
 	response, err := httpClient.Do(request)
 	if err != nil {
 		return fmt.Errorf("instapaper: unable to send request: %v", err)

+ 3 - 1
internal/integration/karakeep/karakeep.go

@@ -13,6 +13,8 @@ import (
 	"strings"
 	"time"
 
+	"miniflux.app/v2/internal/config"
+	"miniflux.app/v2/internal/http/client"
 	"miniflux.app/v2/internal/version"
 )
 
@@ -48,7 +50,7 @@ type errorResponse struct {
 }
 
 func NewClient(apiToken string, apiEndpoint string, tags string) *Client {
-	return &Client{wrapped: &http.Client{Timeout: defaultClientTimeout}, apiEndpoint: apiEndpoint, apiToken: apiToken, tags: tags}
+	return &Client{wrapped: client.NewClientWithOptions(client.Options{Timeout: defaultClientTimeout, BlockPrivateNetworks: !config.Opts.IntegrationAllowPrivateNetworks()}), apiEndpoint: apiEndpoint, apiToken: apiToken, tags: tags}
 }
 
 func (c *Client) attachTags(entryID string) error {

+ 3 - 1
internal/integration/linkace/linkace.go

@@ -12,6 +12,8 @@ import (
 	"strings"
 	"time"
 
+	"miniflux.app/v2/internal/config"
+	"miniflux.app/v2/internal/http/client"
 	"miniflux.app/v2/internal/urllib"
 	"miniflux.app/v2/internal/version"
 )
@@ -64,7 +66,7 @@ func (c *Client) AddURL(entryURL, entryTitle string) error {
 	request.Header.Set("User-Agent", "Miniflux/"+version.Version)
 	request.Header.Set("Authorization", "Bearer "+c.apiKey)
 
-	httpClient := &http.Client{Timeout: defaultClientTimeout}
+	httpClient := client.NewClientWithOptions(client.Options{Timeout: defaultClientTimeout, BlockPrivateNetworks: !config.Opts.IntegrationAllowPrivateNetworks()})
 	response, err := httpClient.Do(request)
 	if err != nil {
 		return fmt.Errorf("linkace: unable to send request: %v", err)

+ 3 - 1
internal/integration/linkding/linkding.go

@@ -12,6 +12,8 @@ import (
 	"strings"
 	"time"
 
+	"miniflux.app/v2/internal/config"
+	"miniflux.app/v2/internal/http/client"
 	"miniflux.app/v2/internal/urllib"
 	"miniflux.app/v2/internal/version"
 )
@@ -63,7 +65,7 @@ func (c *Client) CreateBookmark(entryURL, entryTitle string) error {
 	request.Header.Set("User-Agent", "Miniflux/"+version.Version)
 	request.Header.Set("Authorization", "Token "+c.apiKey)
 
-	httpClient := &http.Client{Timeout: defaultClientTimeout}
+	httpClient := client.NewClientWithOptions(client.Options{Timeout: defaultClientTimeout, BlockPrivateNetworks: !config.Opts.IntegrationAllowPrivateNetworks()})
 	response, err := httpClient.Do(request)
 	if err != nil {
 		return fmt.Errorf("linkding: unable to send request: %v", err)

+ 3 - 1
internal/integration/linktaco/linktaco.go

@@ -12,6 +12,8 @@ import (
 	"strings"
 	"time"
 
+	"miniflux.app/v2/internal/config"
+	"miniflux.app/v2/internal/http/client"
 	"miniflux.app/v2/internal/version"
 )
 
@@ -104,7 +106,7 @@ func (c *Client) CreateBookmark(entryURL, entryTitle, entryContent string) error
 	request.Header.Set("User-Agent", "Miniflux/"+version.Version)
 	request.Header.Set("Authorization", "Bearer "+c.apiToken)
 
-	httpClient := &http.Client{Timeout: defaultClientTimeout}
+	httpClient := client.NewClientWithOptions(client.Options{Timeout: defaultClientTimeout, BlockPrivateNetworks: !config.Opts.IntegrationAllowPrivateNetworks()})
 	response, err := httpClient.Do(request)
 	if err != nil {
 		return fmt.Errorf("linktaco: unable to send request: %v", err)

+ 24 - 0
internal/integration/linktaco/linktaco_test.go

@@ -10,9 +10,13 @@ import (
 	"net/http/httptest"
 	"strings"
 	"testing"
+
+	"miniflux.app/v2/internal/config"
 )
 
 func TestCreateBookmark(t *testing.T) {
+	configureIntegrationAllowPrivateNetworksOption(t)
+
 	tests := []struct {
 		name           string
 		apiToken       string
@@ -323,6 +327,8 @@ func TestNewClient(t *testing.T) {
 }
 
 func TestGraphQLMutation(t *testing.T) {
+	configureIntegrationAllowPrivateNetworksOption(t)
+
 	// Test that the GraphQL mutation is properly formatted
 	server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
 		body, _ := io.ReadAll(r.Body)
@@ -438,3 +444,21 @@ func BenchmarkTagProcessing(b *testing.B) {
 		_ = strings.Join(splitTags, ",")
 	}
 }
+
+func configureIntegrationAllowPrivateNetworksOption(t *testing.T) {
+	t.Helper()
+
+	t.Setenv("INTEGRATION_ALLOW_PRIVATE_NETWORKS", "1")
+
+	configParser := config.NewConfigParser()
+	parsedOptions, err := configParser.ParseEnvironmentVariables()
+	if err != nil {
+		t.Fatalf("Unable to configure test options: %v", err)
+	}
+
+	previousOptions := config.Opts
+	config.Opts = parsedOptions
+	t.Cleanup(func() {
+		config.Opts = previousOptions
+	})
+}

+ 3 - 1
internal/integration/linkwarden/linkwarden.go

@@ -12,6 +12,8 @@ import (
 	"net/http"
 	"time"
 
+	"miniflux.app/v2/internal/config"
+	"miniflux.app/v2/internal/http/client"
 	"miniflux.app/v2/internal/urllib"
 	"miniflux.app/v2/internal/version"
 )
@@ -72,7 +74,7 @@ func (c *Client) CreateBookmark(entryURL, entryTitle string) error {
 	request.Header.Set("User-Agent", "Miniflux/"+version.Version)
 	request.Header.Set("Authorization", "Bearer "+c.apiKey)
 
-	httpClient := &http.Client{Timeout: defaultClientTimeout}
+	httpClient := client.NewClientWithOptions(client.Options{Timeout: defaultClientTimeout, BlockPrivateNetworks: !config.Opts.IntegrationAllowPrivateNetworks()})
 	response, err := httpClient.Do(request)
 	if err != nil {
 		return fmt.Errorf("linkwarden: unable to send request: %v", err)

+ 21 - 0
internal/integration/linkwarden/linkwarden_test.go

@@ -11,10 +11,13 @@ import (
 	"strings"
 	"testing"
 
+	"miniflux.app/v2/internal/config"
 	"miniflux.app/v2/internal/model"
 )
 
 func TestCreateBookmark(t *testing.T) {
+	configureIntegrationAllowPrivateNetworksOption(t)
+
 	tests := []struct {
 		name           string
 		baseURL        string
@@ -323,3 +326,21 @@ func TestNewClient(t *testing.T) {
 		})
 	}
 }
+
+func configureIntegrationAllowPrivateNetworksOption(t *testing.T) {
+	t.Helper()
+
+	t.Setenv("INTEGRATION_ALLOW_PRIVATE_NETWORKS", "1")
+
+	configParser := config.NewConfigParser()
+	parsedOptions, err := configParser.ParseEnvironmentVariables()
+	if err != nil {
+		t.Fatalf("Unable to configure test options: %v", err)
+	}
+
+	previousOptions := config.Opts
+	config.Opts = parsedOptions
+	t.Cleanup(func() {
+		config.Opts = previousOptions
+	})
+}

+ 5 - 3
internal/integration/matrixbot/client.go

@@ -11,7 +11,9 @@ import (
 	"net/url"
 	"time"
 
+	"miniflux.app/v2/internal/config"
 	"miniflux.app/v2/internal/crypto"
+	"miniflux.app/v2/internal/http/client"
 	"miniflux.app/v2/internal/version"
 )
 
@@ -40,7 +42,7 @@ func (c *Client) DiscoverEndpoints() (*DiscoveryEndpointResponse, error) {
 	request.Header.Set("Accept", "application/json")
 	request.Header.Set("User-Agent", "Miniflux/"+version.Version)
 
-	httpClient := &http.Client{Timeout: defaultClientTimeout}
+	httpClient := client.NewClientWithOptions(client.Options{Timeout: defaultClientTimeout, BlockPrivateNetworks: !config.Opts.IntegrationAllowPrivateNetworks()})
 	response, err := httpClient.Do(request)
 	if err != nil {
 		return nil, fmt.Errorf("matrix: unable to send request: %v", err)
@@ -89,7 +91,7 @@ func (c *Client) Login(homeServerURL, matrixUsername, matrixPassword string) (*L
 	request.Header.Set("Accept", "application/json")
 	request.Header.Set("User-Agent", "Miniflux/"+version.Version)
 
-	httpClient := &http.Client{Timeout: defaultClientTimeout}
+	httpClient := client.NewClientWithOptions(client.Options{Timeout: defaultClientTimeout, BlockPrivateNetworks: !config.Opts.IntegrationAllowPrivateNetworks()})
 	response, err := httpClient.Do(request)
 	if err != nil {
 		return nil, fmt.Errorf("matrix: unable to send request: %v", err)
@@ -138,7 +140,7 @@ func (c *Client) SendFormattedTextMessage(homeServerURL, accessToken, roomID, te
 	request.Header.Set("User-Agent", "Miniflux/"+version.Version)
 	request.Header.Set("Authorization", "Bearer "+accessToken)
 
-	httpClient := &http.Client{Timeout: defaultClientTimeout}
+	httpClient := client.NewClientWithOptions(client.Options{Timeout: defaultClientTimeout, BlockPrivateNetworks: !config.Opts.IntegrationAllowPrivateNetworks()})
 	response, err := httpClient.Do(request)
 	if err != nil {
 		return nil, fmt.Errorf("matrix: unable to send request: %v", err)

+ 2 - 1
internal/integration/notion/notion.go

@@ -11,6 +11,7 @@ import (
 	"net/http"
 	"time"
 
+	"miniflux.app/v2/internal/http/client"
 	"miniflux.app/v2/internal/version"
 )
 
@@ -57,7 +58,7 @@ func (c *Client) UpdateDocument(entryURL string, entryTitle string) error {
 	request.Header.Set("Notion-Version", "2022-06-28")
 	request.Header.Set("Authorization", "Bearer "+c.apiToken)
 
-	httpClient := &http.Client{Timeout: defaultClientTimeout}
+	httpClient := client.NewClientWithOptions(client.Options{Timeout: defaultClientTimeout})
 	response, err := httpClient.Do(request)
 	if err != nil {
 		return fmt.Errorf("notion: unable to send request: %v", err)

+ 2 - 1
internal/integration/ntfy/ntfy.go

@@ -13,6 +13,7 @@ import (
 	"time"
 
 	"miniflux.app/v2/internal/config"
+	"miniflux.app/v2/internal/http/client"
 	"miniflux.app/v2/internal/model"
 	"miniflux.app/v2/internal/version"
 )
@@ -107,7 +108,7 @@ func (c *Client) makeRequest(payload any) error {
 		request.SetBasicAuth(c.ntfyUsername, c.ntfyPassword)
 	}
 
-	httpClient := &http.Client{Timeout: defaultClientTimeout}
+	httpClient := client.NewClientWithOptions(client.Options{Timeout: defaultClientTimeout, BlockPrivateNetworks: !config.Opts.IntegrationAllowPrivateNetworks()})
 	response, err := httpClient.Do(request)
 	if err != nil {
 		return fmt.Errorf("ntfy: unable to send request: %v", err)

+ 3 - 1
internal/integration/nunuxkeeper/nunuxkeeper.go

@@ -11,6 +11,8 @@ import (
 	"net/http"
 	"time"
 
+	"miniflux.app/v2/internal/config"
+	"miniflux.app/v2/internal/http/client"
 	"miniflux.app/v2/internal/urllib"
 	"miniflux.app/v2/internal/version"
 )
@@ -55,7 +57,7 @@ func (c *Client) AddEntry(entryURL, entryTitle, entryContent string) error {
 	request.Header.Set("Content-Type", "application/json")
 	request.Header.Set("User-Agent", "Miniflux/"+version.Version)
 
-	httpClient := &http.Client{Timeout: defaultClientTimeout}
+	httpClient := client.NewClientWithOptions(client.Options{Timeout: defaultClientTimeout, BlockPrivateNetworks: !config.Opts.IntegrationAllowPrivateNetworks()})
 	response, err := httpClient.Do(request)
 	if err != nil {
 		return fmt.Errorf("nunux-keeper: unable to send request: %v", err)

+ 3 - 1
internal/integration/omnivore/omnivore.go

@@ -11,7 +11,9 @@ import (
 	"net/http"
 	"time"
 
+	"miniflux.app/v2/internal/config"
 	"miniflux.app/v2/internal/crypto"
+	"miniflux.app/v2/internal/http/client"
 	"miniflux.app/v2/internal/version"
 )
 
@@ -60,7 +62,7 @@ func NewClient(apiToken string, apiEndpoint string) *Client {
 		apiEndpoint = defaultApiEndpoint
 	}
 
-	return &Client{wrapped: &http.Client{Timeout: defaultClientTimeout}, apiEndpoint: apiEndpoint, apiToken: apiToken}
+	return &Client{wrapped: client.NewClientWithOptions(client.Options{Timeout: defaultClientTimeout, BlockPrivateNetworks: !config.Opts.IntegrationAllowPrivateNetworks()}), apiEndpoint: apiEndpoint, apiToken: apiToken}
 }
 
 func (c *Client) SaveURL(url string) error {

+ 3 - 2
internal/integration/pinboard/pinboard.go

@@ -11,6 +11,7 @@ import (
 	"net/url"
 	"time"
 
+	"miniflux.app/v2/internal/http/client"
 	"miniflux.app/v2/internal/version"
 )
 
@@ -59,7 +60,7 @@ func (c *Client) CreateBookmark(entryURL, entryTitle, pinboardTags string, markA
 
 	request.Header.Set("User-Agent", "Miniflux/"+version.Version)
 
-	httpClient := &http.Client{Timeout: defaultClientTimeout}
+	httpClient := client.NewClientWithOptions(client.Options{Timeout: defaultClientTimeout})
 	response, err := httpClient.Do(request)
 	if err != nil {
 		return fmt.Errorf("pinboard: unable to send request: %v", err)
@@ -91,7 +92,7 @@ func (c *Client) getBookmark(entryURL string) (*Post, error) {
 
 	request.Header.Set("User-Agent", "Miniflux/"+version.Version)
 
-	httpClient := &http.Client{Timeout: defaultClientTimeout}
+	httpClient := client.NewClientWithOptions(client.Options{Timeout: defaultClientTimeout})
 	response, err := httpClient.Do(request)
 	if err != nil {
 		return nil, fmt.Errorf("pinboard: unable fetch bookmark: %v", err)

+ 3 - 1
internal/integration/pushover/pushover.go

@@ -12,6 +12,8 @@ import (
 	"strings"
 	"time"
 
+	"miniflux.app/v2/internal/config"
+	"miniflux.app/v2/internal/http/client"
 	"miniflux.app/v2/internal/model"
 	"miniflux.app/v2/internal/version"
 )
@@ -115,7 +117,7 @@ func (c *Client) makeRequest(payload *message) error {
 	req.Header.Add("Content-Type", "application/json")
 	req.Header.Set("User-Agent", "Miniflux/"+version.Version)
 
-	httpClient := &http.Client{Timeout: defaultClientTimeout}
+	httpClient := client.NewClientWithOptions(client.Options{Timeout: defaultClientTimeout, BlockPrivateNetworks: !config.Opts.IntegrationAllowPrivateNetworks()})
 	resp, err := httpClient.Do(req)
 	if err != nil {
 		return fmt.Errorf("pushover: unable to send request: %w", err)

+ 3 - 1
internal/integration/raindrop/raindrop.go

@@ -12,6 +12,8 @@ import (
 	"strings"
 	"time"
 
+	"miniflux.app/v2/internal/config"
+	"miniflux.app/v2/internal/http/client"
 	"miniflux.app/v2/internal/version"
 )
 
@@ -53,7 +55,7 @@ func (c *Client) CreateRaindrop(entryURL, entryTitle string) error {
 	request.Header.Set("User-Agent", "Miniflux/"+version.Version)
 	request.Header.Set("Authorization", "Bearer "+c.token)
 
-	httpClient := &http.Client{Timeout: defaultClientTimeout}
+	httpClient := client.NewClientWithOptions(client.Options{Timeout: defaultClientTimeout, BlockPrivateNetworks: !config.Opts.IntegrationAllowPrivateNetworks()})
 	response, err := httpClient.Do(request)
 	if err != nil {
 		return fmt.Errorf("raindrop: unable to send request: %v", err)

+ 3 - 1
internal/integration/readeck/readeck.go

@@ -13,6 +13,8 @@ import (
 	"strings"
 	"time"
 
+	"miniflux.app/v2/internal/config"
+	"miniflux.app/v2/internal/http/client"
 	"miniflux.app/v2/internal/urllib"
 	"miniflux.app/v2/internal/version"
 )
@@ -120,7 +122,7 @@ func (c *Client) CreateBookmark(entryURL, entryTitle string, entryContent string
 	request.Header.Set("User-Agent", "Miniflux/"+version.Version)
 	request.Header.Set("Authorization", "Bearer "+c.apiKey)
 
-	httpClient := &http.Client{Timeout: defaultClientTimeout}
+	httpClient := client.NewClientWithOptions(client.Options{Timeout: defaultClientTimeout, BlockPrivateNetworks: !config.Opts.IntegrationAllowPrivateNetworks()})
 	response, err := httpClient.Do(request)
 	if err != nil {
 		return fmt.Errorf("readeck: unable to send request: %v", err)

+ 22 - 0
internal/integration/readeck/readeck_test.go

@@ -11,9 +11,13 @@ import (
 	"net/http/httptest"
 	"strings"
 	"testing"
+
+	"miniflux.app/v2/internal/config"
 )
 
 func TestCreateBookmark(t *testing.T) {
+	configureIntegrationAllowPrivateNetworksOption(t)
+
 	entryURL := "https://example.com/article"
 	entryTitle := "Example Title"
 	entryContent := "<p>Some HTML content</p>"
@@ -258,3 +262,21 @@ func TestNewClient(t *testing.T) {
 		t.Errorf("expected onlyURL %v, got %v", onlyURL, c.onlyURL)
 	}
 }
+
+func configureIntegrationAllowPrivateNetworksOption(t *testing.T) {
+	t.Helper()
+
+	t.Setenv("INTEGRATION_ALLOW_PRIVATE_NETWORKS", "1")
+
+	configParser := config.NewConfigParser()
+	parsedOptions, err := configParser.ParseEnvironmentVariables()
+	if err != nil {
+		t.Fatalf("Unable to configure test options: %v", err)
+	}
+
+	previousOptions := config.Opts
+	config.Opts = parsedOptions
+	t.Cleanup(func() {
+		config.Opts = previousOptions
+	})
+}

+ 3 - 1
internal/integration/readwise/readwise.go

@@ -13,6 +13,8 @@ import (
 	"net/http"
 	"time"
 
+	"miniflux.app/v2/internal/config"
+	"miniflux.app/v2/internal/http/client"
 	"miniflux.app/v2/internal/version"
 )
 
@@ -51,7 +53,7 @@ func (c *Client) CreateDocument(entryURL string) error {
 	request.Header.Set("User-Agent", "Miniflux/"+version.Version)
 	request.Header.Set("Authorization", "Token "+c.apiKey)
 
-	httpClient := &http.Client{Timeout: defaultClientTimeout}
+	httpClient := client.NewClientWithOptions(client.Options{Timeout: defaultClientTimeout, BlockPrivateNetworks: !config.Opts.IntegrationAllowPrivateNetworks()})
 	response, err := httpClient.Do(request)
 	if err != nil {
 		return fmt.Errorf("readwise: unable to send request: %v", err)

+ 3 - 1
internal/integration/rssbridge/rssbridge.go

@@ -12,6 +12,8 @@ import (
 	"strings"
 	"time"
 
+	"miniflux.app/v2/internal/config"
+	"miniflux.app/v2/internal/http/client"
 	"miniflux.app/v2/internal/version"
 )
 
@@ -50,7 +52,7 @@ func DetectBridges(rssBridgeURL, rssBridgeToken, websiteURL string) ([]*Bridge,
 
 	request.Header.Set("User-Agent", "Miniflux/"+version.Version)
 
-	httpClient := &http.Client{Timeout: defaultClientTimeout}
+	httpClient := client.NewClientWithOptions(client.Options{Timeout: defaultClientTimeout, BlockPrivateNetworks: !config.Opts.IntegrationAllowPrivateNetworks()})
 
 	response, err := httpClient.Do(request)
 	if err != nil {

+ 3 - 1
internal/integration/shaarli/shaarli.go

@@ -14,6 +14,8 @@ import (
 	"net/http"
 	"time"
 
+	"miniflux.app/v2/internal/config"
+	"miniflux.app/v2/internal/http/client"
 	"miniflux.app/v2/internal/urllib"
 	"miniflux.app/v2/internal/version"
 )
@@ -59,7 +61,7 @@ func (c *Client) CreateLink(entryURL, entryTitle string) error {
 	request.Header.Set("User-Agent", "Miniflux/"+version.Version)
 	request.Header.Set("Authorization", "Bearer "+c.generateBearerToken())
 
-	httpClient := &http.Client{Timeout: defaultClientTimeout}
+	httpClient := client.NewClientWithOptions(client.Options{Timeout: defaultClientTimeout, BlockPrivateNetworks: !config.Opts.IntegrationAllowPrivateNetworks()})
 	response, err := httpClient.Do(request)
 	if err != nil {
 		return fmt.Errorf("shaarli: unable to send request: %v", err)

+ 4 - 2
internal/integration/shiori/shiori.go

@@ -11,6 +11,8 @@ import (
 	"net/http"
 	"time"
 
+	"miniflux.app/v2/internal/config"
+	"miniflux.app/v2/internal/http/client"
 	"miniflux.app/v2/internal/urllib"
 	"miniflux.app/v2/internal/version"
 )
@@ -65,7 +67,7 @@ func (c *Client) CreateBookmark(entryURL, entryTitle string) error {
 	request.Header.Set("User-Agent", "Miniflux/"+version.Version)
 	request.Header.Set("Authorization", "Bearer "+token)
 
-	httpClient := &http.Client{Timeout: defaultClientTimeout}
+	httpClient := client.NewClientWithOptions(client.Options{Timeout: defaultClientTimeout, BlockPrivateNetworks: !config.Opts.IntegrationAllowPrivateNetworks()})
 
 	response, err := httpClient.Do(request)
 	if err != nil {
@@ -100,7 +102,7 @@ func (c *Client) authenticate() (string, error) {
 	request.Header.Set("Accept", "application/json")
 	request.Header.Set("User-Agent", "Miniflux/"+version.Version)
 
-	httpClient := &http.Client{Timeout: defaultClientTimeout}
+	httpClient := client.NewClientWithOptions(client.Options{Timeout: defaultClientTimeout, BlockPrivateNetworks: !config.Opts.IntegrationAllowPrivateNetworks()})
 
 	response, err := httpClient.Do(request)
 	if err != nil {

+ 3 - 1
internal/integration/slack/slack.go

@@ -13,6 +13,8 @@ import (
 	"net/http"
 	"time"
 
+	"miniflux.app/v2/internal/config"
+	"miniflux.app/v2/internal/http/client"
 	"miniflux.app/v2/internal/model"
 	"miniflux.app/v2/internal/urllib"
 	"miniflux.app/v2/internal/version"
@@ -81,7 +83,7 @@ func (c *Client) SendSlackMsg(feed *model.Feed, entries model.Entries) error {
 			slog.String("entry_url", entry.URL),
 		)
 
-		httpClient := &http.Client{Timeout: defaultClientTimeout}
+		httpClient := client.NewClientWithOptions(client.Options{Timeout: defaultClientTimeout, BlockPrivateNetworks: !config.Opts.IntegrationAllowPrivateNetworks()})
 		response, err := httpClient.Do(request)
 		if err != nil {
 			return fmt.Errorf("slack: unable to send request: %v", err)

+ 3 - 2
internal/integration/telegrambot/client.go

@@ -11,6 +11,7 @@ import (
 	"net/url"
 	"time"
 
+	"miniflux.app/v2/internal/http/client"
 	"miniflux.app/v2/internal/version"
 )
 
@@ -50,7 +51,7 @@ func (c *Client) GetMe() (*User, error) {
 	request.Header.Set("Accept", "application/json")
 	request.Header.Set("User-Agent", "Miniflux/"+version.Version)
 
-	httpClient := &http.Client{Timeout: defaultClientTimeout}
+	httpClient := client.NewClientWithOptions(client.Options{Timeout: defaultClientTimeout})
 	response, err := httpClient.Do(request)
 	if err != nil {
 		return nil, fmt.Errorf("telegram: unable to send request: %v", err)
@@ -90,7 +91,7 @@ func (c *Client) SendMessage(message *MessageRequest) (*Message, error) {
 	request.Header.Set("Accept", "application/json")
 	request.Header.Set("User-Agent", "Miniflux/"+version.Version)
 
-	httpClient := &http.Client{Timeout: defaultClientTimeout}
+	httpClient := client.NewClientWithOptions(client.Options{Timeout: defaultClientTimeout})
 	response, err := httpClient.Do(request)
 	if err != nil {
 		return nil, fmt.Errorf("telegram: unable to send request: %v", err)

+ 4 - 2
internal/integration/wallabag/wallabag.go

@@ -13,6 +13,8 @@ import (
 	"strings"
 	"time"
 
+	"miniflux.app/v2/internal/config"
+	"miniflux.app/v2/internal/http/client"
 	"miniflux.app/v2/internal/urllib"
 	"miniflux.app/v2/internal/version"
 )
@@ -84,7 +86,7 @@ func (c *Client) createEntry(accessToken, entryURL, entryTitle, entryContent, ta
 	request.Header.Set("User-Agent", "Miniflux/"+version.Version)
 	request.Header.Set("Authorization", "Bearer "+accessToken)
 
-	httpClient := &http.Client{Timeout: defaultClientTimeout}
+	httpClient := client.NewClientWithOptions(client.Options{Timeout: defaultClientTimeout, BlockPrivateNetworks: !config.Opts.IntegrationAllowPrivateNetworks()})
 	response, err := httpClient.Do(request)
 	if err != nil {
 		return fmt.Errorf("wallabag: unable to send request: %v", err)
@@ -120,7 +122,7 @@ func (c *Client) getAccessToken() (string, error) {
 	request.Header.Set("Accept", "application/json")
 	request.Header.Set("User-Agent", "Miniflux/"+version.Version)
 
-	httpClient := &http.Client{Timeout: defaultClientTimeout}
+	httpClient := client.NewClientWithOptions(client.Options{Timeout: defaultClientTimeout, BlockPrivateNetworks: !config.Opts.IntegrationAllowPrivateNetworks()})
 	response, err := httpClient.Do(request)
 	if err != nil {
 		return "", fmt.Errorf("wallabag: unable to send request: %v", err)

+ 22 - 0
internal/integration/wallabag/wallabag_test.go

@@ -10,9 +10,13 @@ import (
 	"net/http/httptest"
 	"strings"
 	"testing"
+
+	"miniflux.app/v2/internal/config"
 )
 
 func TestCreateEntry(t *testing.T) {
+	configureIntegrationAllowPrivateNetworksOption(t)
+
 	entryURL := "https://example.com"
 	entryTitle := "title"
 	entryContent := "content"
@@ -319,3 +323,21 @@ func TestNewClient(t *testing.T) {
 		})
 	}
 }
+
+func configureIntegrationAllowPrivateNetworksOption(t *testing.T) {
+	t.Helper()
+
+	t.Setenv("INTEGRATION_ALLOW_PRIVATE_NETWORKS", "1")
+
+	configParser := config.NewConfigParser()
+	parsedOptions, err := configParser.ParseEnvironmentVariables()
+	if err != nil {
+		t.Fatalf("Unable to configure test options: %v", err)
+	}
+
+	previousOptions := config.Opts
+	config.Opts = parsedOptions
+	t.Cleanup(func() {
+		config.Opts = previousOptions
+	})
+}

+ 3 - 1
internal/integration/webhook/webhook.go

@@ -11,7 +11,9 @@ import (
 	"net/http"
 	"time"
 
+	"miniflux.app/v2/internal/config"
 	"miniflux.app/v2/internal/crypto"
+	"miniflux.app/v2/internal/http/client"
 	"miniflux.app/v2/internal/model"
 	"miniflux.app/v2/internal/version"
 )
@@ -132,7 +134,7 @@ func (c *Client) makeRequest(eventType string, payload any) error {
 	request.Header.Set("X-Miniflux-Signature", crypto.GenerateSHA256Hmac(c.webhookSecret, requestBody))
 	request.Header.Set("X-Miniflux-Event-Type", eventType)
 
-	httpClient := &http.Client{Timeout: defaultClientTimeout}
+	httpClient := client.NewClientWithOptions(client.Options{Timeout: defaultClientTimeout, BlockPrivateNetworks: !config.Opts.IntegrationAllowPrivateNetworks()})
 	response, err := httpClient.Do(request)
 	if err != nil {
 		return fmt.Errorf("webhook: unable to send request: %v", err)

+ 3 - 3
internal/urllib/url.go

@@ -162,16 +162,16 @@ func ResolvesToPrivateIP(host string) (bool, error) {
 		return false, err
 	}
 
-	if slices.ContainsFunc(ips, isNonPublicIP) {
+	if slices.ContainsFunc(ips, IsNonPublicIP) {
 		return true, nil
 	}
 
 	return false, nil
 }
 
-// isNonPublicIP returns true if the given IP is private, loopback,
+// IsNonPublicIP returns true if the given IP is private, loopback,
 // link-local, multicast, or unspecified.
-func isNonPublicIP(ip net.IP) bool {
+func IsNonPublicIP(ip net.IP) bool {
 	if ip == nil {
 		return true
 	}

+ 1 - 1
internal/urllib/url_test.go

@@ -246,7 +246,7 @@ func TestIsNonPublicIP(t *testing.T) {
 				}
 			}
 
-			if got := isNonPublicIP(ip); got != tc.want {
+			if got := IsNonPublicIP(ip); got != tc.want {
 				t.Fatalf("unexpected result for %s: got %v want %v", tc.name, got, tc.want)
 			}
 		})

+ 6 - 1
miniflux.1

@@ -1,5 +1,5 @@
 .\" Manpage for miniflux.
-.TH "MINIFLUX" "1" "February 28, 2026" "\ \&" "\ \&"
+.TH "MINIFLUX" "1" "March 1, 2026" "\ \&" "\ \&"
 
 .SH NAME
 miniflux \- Minimalist and opinionated feed reader
@@ -345,6 +345,11 @@ Forces cookies to use secure flag and send HSTS header\&.
 .br
 Default is disabled\&.
 .TP
+.B INTEGRATION_ALLOW_PRIVATE_NETWORKS
+Set to 1 to allow outgoing integration requests to private or loopback networks\&.
+.br
+Disabled by default, private networks are refused\&.
+.TP
 .B INVIDIOUS_INSTANCE
 Set a custom invidious instance to use\&.
 .br