From 8f7a38f012cc74f917ec659667ba2556afdc787b Mon Sep 17 00:00:00 2001 From: Darren Odden Date: Mon, 19 Oct 2026 21:30:10 -0700 Subject: [PATCH] Guard the feed blocks' fetch: public addresses only, no redirects, a size limit Co-Authored-By: Claude Sonnet 5.5 --- README.md | 6 + config/mail-builder.php | 16 ++ src/Compilers/EmailSlotCompiler.php | 10 +- src/Exceptions/UnsafeOutboundUrlException.php | 9 + src/Support/OutboundUrlGuard.php | 164 ++++++++++++++++++ tests/Feature/FeedUrlGuardTest.php | 121 +++++++++++++ tests/Support/FakeOutboundUrlGuard.php | 22 +++ tests/TestCase.php | 10 ++ 8 files changed, 354 insertions(+), 4 deletions(-) create mode 100644 src/Exceptions/UnsafeOutboundUrlException.php create mode 100644 src/Support/OutboundUrlGuard.php create mode 100644 tests/Feature/FeedUrlGuardTest.php create mode 100644 tests/Support/FakeOutboundUrlGuard.php diff --git a/README.md b/README.md index f3f2408..a4c3564 100644 --- a/README.md +++ b/README.md @@ -392,6 +392,12 @@ npm run test:js # the editor's model and history (Node's built-in test runner --- +## Feed blocks + +The `rss` and `dynamic_feed` blocks fetch JSON from the `feed_url` stored in the email, when the email is compiled. Because that URL is content, the fetch is guarded: the host must resolve only to public addresses (private, loopback, link-local and cloud metadata addresses are refused), redirects are not followed, the request times out after a few seconds, and a response over `mail-builder.feeds.max_bytes` (1 MB) is ignored. A refused or failed feed falls back to the block's own items. To fetch a feed you serve from your own network, list its host in `mail-builder.feeds.allowed_hosts`. + +--- + ## Sending, lists and campaigns This package builds and checks emails. It does not manage contacts, lists, consent or sending. Those come from [Odden CRM](https://github.com/getodden/crm) (open source) and its paid add-ons, or from your own application: `TemplateMailable` sends through any Laravel mailer. See [odden.io](https://odden.io) for Odden Cloud and the paid Marketing add-on. diff --git a/config/mail-builder.php b/config/mail-builder.php index 1c83751..9fc2ef0 100644 --- a/config/mail-builder.php +++ b/config/mail-builder.php @@ -56,4 +56,20 @@ // The most slots a preview request may render. 'max_slots' => 200, ], + + /* + |-------------------------------------------------------------------------- + | Feed Blocks + |-------------------------------------------------------------------------- + | + | The RSS and dynamic feed blocks fetch JSON from a URL stored in the email, when the email is compiled. The address + | the host resolves to must be a public one: private, loopback and cloud metadata addresses are refused, redirects are + | not followed, and a response larger than max_bytes is ignored. A refused feed falls back to the block's own items. + | List a host in allowed_hosts to let it through, for a feed you serve from your own network. + | + */ + 'feeds' => [ + 'allowed_hosts' => [], + 'max_bytes' => 1_048_576, + ], ]; diff --git a/src/Compilers/EmailSlotCompiler.php b/src/Compilers/EmailSlotCompiler.php index 97b84f9..b4c7b56 100644 --- a/src/Compilers/EmailSlotCompiler.php +++ b/src/Compilers/EmailSlotCompiler.php @@ -5,11 +5,11 @@ namespace Odden\MailBuilder\Compilers; use Illuminate\Contracts\View\Factory as ViewFactory; -use Illuminate\Support\Facades\Http; use Odden\MailBuilder\Data\EmailDocument; use Odden\MailBuilder\Data\EmailSlot; use Odden\MailBuilder\Enums\SlotType; use Odden\MailBuilder\MergeTags\MergeTagInterpolator; +use Odden\MailBuilder\Support\OutboundUrlGuard; use Odden\MailBuilder\Themes\FontManager; use TijsVerkoyen\CssToInlineStyles\CssToInlineStyles; @@ -174,8 +174,10 @@ protected function hydrateFeedSlot(array $data): array } try { - $response = Http::timeout(3)->get($feedUrl); - if ($response->successful()) { + $guard = app(OutboundUrlGuard::class); + $response = $guard->request($feedUrl)->get($feedUrl); + + if ($response->successful() && strlen($response->body()) <= $guard->maxBytes()) { $json = $response->json(); if (is_array($json)) { $items = isset($json['items']) && is_array($json['items']) ? $json['items'] : $json; @@ -185,7 +187,7 @@ protected function hydrateFeedSlot(array $data): array } } } catch (\Throwable) { - // Fail gracefully to configured static fallback items + // A refused or failed fetch falls back to the configured static items. } return $data; diff --git a/src/Exceptions/UnsafeOutboundUrlException.php b/src/Exceptions/UnsafeOutboundUrlException.php new file mode 100644 index 0000000..a87a246 --- /dev/null +++ b/src/Exceptions/UnsafeOutboundUrlException.php @@ -0,0 +1,9 @@ +check($url); + + $maxBytes = $this->maxBytes(); + + $options = [ + 'allow_redirects' => false, + // Stop before the body is read when the server announces more than we accept. + 'on_headers' => function (ResponseInterface $response) use ($maxBytes): void { + if ((int) $response->getHeaderLine('Content-Length') > $maxBytes) { + throw new RuntimeException('The feed response is larger than the allowed size.'); + } + }, + ]; + + if ($ip !== null && defined('CURLOPT_RESOLVE')) { + $options['curl'] = [CURLOPT_RESOLVE => ["{$host}:{$port}:".(str_contains($ip, ':') ? "[{$ip}]" : $ip)]]; + } + + return Http::withOptions($options) + ->connectTimeout(self::CONNECT_TIMEOUT) + ->timeout(self::TIMEOUT); + } + + /** + * The largest response body a feed may have, in bytes. + */ + public function maxBytes(): int + { + return max(1, (int) config('mail-builder.feeds.max_bytes', 1_048_576)); + } + + /** + * Check the URL and return its host, port and the address to connect to (null for an allow-listed host). + * + * @return array{0: string, 1: int, 2: string|null} + * + * @throws UnsafeOutboundUrlException + */ + public function check(string $url): array + { + $parts = parse_url($url); + $scheme = strtolower((string) ($parts['scheme'] ?? '')); + $host = strtolower(trim((string) ($parts['host'] ?? ''), '[]')); + + if (! in_array($scheme, ['http', 'https'], true) || $host === '') { + throw new UnsafeOutboundUrlException('Only http and https URLs with a host can be fetched.'); + } + + $port = (int) ($parts['port'] ?? ($scheme === 'https' ? 443 : 80)); + + if (in_array($host, $this->allowedHosts(), true)) { + return [$host, $port, null]; + } + + $addresses = filter_var($host, FILTER_VALIDATE_IP) !== false ? [$host] : $this->resolveHost($host); + + if ($addresses === []) { + throw new UnsafeOutboundUrlException("The host [{$host}] does not resolve."); + } + + foreach ($addresses as $address) { + if (! self::isPublicAddress($address)) { + throw new UnsafeOutboundUrlException("The host [{$host}] resolves to a private or reserved address, which a feed may not fetch."); + } + } + + return [$host, $port, $addresses[0]]; + } + + public function isSafe(string $url): bool + { + try { + $this->check($url); + + return true; + } catch (UnsafeOutboundUrlException) { + return false; + } + } + + public static function isPublicAddress(string $address): bool + { + return filter_var( + $address, + FILTER_VALIDATE_IP, + FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE | FILTER_FLAG_GLOBAL_RANGE, + ) !== false; + } + + /** + * Every IPv4 and IPv6 address the host resolves to. + * + * @return list + */ + protected function resolveHost(string $host): array + { + $addresses = []; + + foreach ([DNS_A => 'ip', DNS_AAAA => 'ipv6'] as $type => $key) { + $records = @dns_get_record($host, $type); + + foreach (is_array($records) ? $records : [] as $record) { + if (isset($record[$key]) && is_string($record[$key])) { + $addresses[] = $record[$key]; + } + } + } + + if ($addresses === []) { + $fallback = @gethostbynamel($host); + $addresses = is_array($fallback) ? $fallback : []; + } + + return array_values(array_unique($addresses)); + } + + /** + * @return list + */ + protected function allowedHosts(): array + { + $hosts = config('mail-builder.feeds.allowed_hosts', []); + + return array_values(array_filter(array_map( + fn (mixed $host): string => strtolower(trim((string) $host)), + is_array($hosts) ? $hosts : [], + ))); + } +} diff --git a/tests/Feature/FeedUrlGuardTest.php b/tests/Feature/FeedUrlGuardTest.php new file mode 100644 index 0000000..49ab087 --- /dev/null +++ b/tests/Feature/FeedUrlGuardTest.php @@ -0,0 +1,121 @@ + + */ + public static function refusedUrls(): array + { + return [ + 'loopback' => ['http://127.0.0.1/admin'], + 'localhost name' => ['http://localhost/admin'], + 'private range' => ['http://10.1.2.3/feed.json'], + 'private range 192.168' => ['http://192.168.0.10/feed.json'], + 'cloud metadata' => ['http://169.254.169.254/latest/meta-data/'], + 'ipv6 loopback' => ['http://[::1]/feed.json'], + 'ipv6 unique local' => ['http://[fd00::1]/feed.json'], + 'carrier-grade NAT' => ['http://100.64.0.1/feed.json'], + 'name that resolves to a private address' => ['https://internal.example.com/feed.json'], + 'user info naming a public host' => ['http://example.com@127.0.0.1/feed.json'], + ]; + } + + #[DataProvider('refusedUrls')] + public function test_a_feed_url_that_points_inside_is_never_fetched(string $url): void + { + Http::fake(); + + $html = MailBuilder::compile($this->feedDocument($url)); + + Http::assertNothingSent(); + $this->assertStringContainsString('Static item', $html); + } + + public function test_a_public_feed_is_fetched_without_following_redirects(): void + { + Http::fake(['https://api.example.com/*' => Http::response(['items' => [['title' => 'Live item']]], 200)]); + + $html = MailBuilder::compile($this->feedDocument('https://api.example.com/feed.json')); + + $this->assertStringContainsString('Live item', $html); + Http::assertSent(fn (Request $request): bool => $request->url() === 'https://api.example.com/feed.json'); + } + + public function test_a_redirect_is_not_followed_and_the_static_items_stay(): void + { + Http::fake(['https://api.example.com/*' => Http::response('', 302, ['Location' => 'http://169.254.169.254/'])]); + + $html = MailBuilder::compile($this->feedDocument('https://api.example.com/feed.json')); + + $this->assertStringContainsString('Static item', $html); + Http::assertSentCount(1); + } + + public function test_a_response_over_the_size_limit_is_ignored(): void + { + config(['mail-builder.feeds.max_bytes' => 50]); + Http::fake(['https://api.example.com/*' => Http::response(['items' => [['title' => str_repeat('Large ', 40)]]], 200)]); + + $html = MailBuilder::compile($this->feedDocument('https://api.example.com/feed.json')); + + $this->assertStringContainsString('Static item', $html); + $this->assertStringNotContainsString('Large Large', $html); + } + + public function test_an_allowed_host_skips_the_address_check(): void + { + config(['mail-builder.feeds.allowed_hosts' => ['internal.example.com']]); + Http::fake(['https://internal.example.com/*' => Http::response(['items' => [['title' => 'Own network item']]], 200)]); + + $html = MailBuilder::compile($this->feedDocument('https://internal.example.com/feed.json')); + + $this->assertStringContainsString('Own network item', $html); + } + + public function test_only_http_and_https_urls_pass_the_guard(): void + { + $guard = new OutboundUrlGuard; + + $this->assertFalse($guard->isSafe('file:///etc/passwd')); + $this->assertFalse($guard->isSafe('gopher://93.184.216.34/')); + $this->assertFalse($guard->isSafe('//93.184.216.34/feed')); + $this->assertTrue($guard->isSafe('https://93.184.216.34/feed')); + } + + public function test_public_and_reserved_addresses_are_told_apart(): void + { + foreach (['93.184.216.34', '2606:2800:220:1:248:1893:25c8:1946'] as $public) { + $this->assertTrue(OutboundUrlGuard::isPublicAddress($public), $public); + } + + foreach (['127.0.0.1', '10.0.0.1', '172.16.0.1', '192.168.1.1', '169.254.169.254', '100.64.0.1', '0.0.0.0', '::1', 'fe80::1', 'fd00::1', '::ffff:127.0.0.1'] as $private) { + $this->assertFalse(OutboundUrlGuard::isPublicAddress($private), $private); + } + } + + private function feedDocument(string $feedUrl): EmailDocument + { + $doc = new EmailDocument; + $doc->append(SlotType::DynamicFeed, [ + 'heading' => 'Feed', + 'feed_url' => $feedUrl, + 'items' => [['title' => 'Static item', 'price' => '$1', 'button_url' => 'https://example.com/static']], + ]); + + return $doc; + } +} diff --git a/tests/Support/FakeOutboundUrlGuard.php b/tests/Support/FakeOutboundUrlGuard.php new file mode 100644 index 0000000..634dcdd --- /dev/null +++ b/tests/Support/FakeOutboundUrlGuard.php @@ -0,0 +1,22 @@ + ['127.0.0.1', '::1'], + str_starts_with($host, 'internal.') => ['10.0.0.5'], + default => ['93.184.216.34'], + }; + } +} diff --git a/tests/TestCase.php b/tests/TestCase.php index 1ab06b8..2212261 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -5,10 +5,20 @@ namespace Odden\MailBuilder\Tests; use Odden\MailBuilder\MailBuilderServiceProvider; +use Odden\MailBuilder\Support\OutboundUrlGuard; +use Odden\MailBuilder\Tests\Support\FakeOutboundUrlGuard; use Orchestra\Testbench\TestCase as Orchestra; abstract class TestCase extends Orchestra { + protected function setUp(): void + { + parent::setUp(); + + // Tests never use real DNS: see FakeOutboundUrlGuard. + $this->app->bind(OutboundUrlGuard::class, FakeOutboundUrlGuard::class); + } + /** * @return list */