Skip to content
Merged
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
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
16 changes: 16 additions & 0 deletions config/mail-builder.php
Original file line number Diff line number Diff line change
Expand Up @@ -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,
],
];
10 changes: 6 additions & 4 deletions src/Compilers/EmailSlotCompiler.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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;
Expand All @@ -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;
Expand Down
9 changes: 9 additions & 0 deletions src/Exceptions/UnsafeOutboundUrlException.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
<?php

declare(strict_types=1);

namespace Odden\MailBuilder\Exceptions;

use RuntimeException;

class UnsafeOutboundUrlException extends RuntimeException {}
164 changes: 164 additions & 0 deletions src/Support/OutboundUrlGuard.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
<?php

declare(strict_types=1);

namespace Odden\MailBuilder\Support;

use Illuminate\Http\Client\PendingRequest;
use Illuminate\Support\Facades\Http;
use Odden\MailBuilder\Exceptions\UnsafeOutboundUrlException;
use Psr\Http\Message\ResponseInterface;
use RuntimeException;

/**
* Guards the server-side fetches whose URL comes from an email's content (the feed blocks) against server-side request forgery.
*
* The host is resolved, and every address it resolves to must be a public (globally routable) one: private, loopback,
* link-local (including the 169.254.169.254 cloud metadata endpoint), carrier-grade NAT, reserved and documentation
* ranges are refused, for IPv4 and IPv6. The request is then pinned to the checked address (so a second DNS answer
* cannot point it elsewhere), follows no redirects, has short timeouts, and is dropped if the response is too large.
*
* Hosts listed in `mail-builder.feeds.allowed_hosts` skip the address check, for feeds you run on your own network.
*/
class OutboundUrlGuard
{
public const CONNECT_TIMEOUT = 3;

public const TIMEOUT = 5;

/**
* A pending request for the URL, pinned to its checked address.
*
* @throws UnsafeOutboundUrlException
*/
public function request(string $url): PendingRequest
{
[$host, $port, $ip] = $this->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(

Check failure on line 118 in src/Support/OutboundUrlGuard.php

View workflow job for this annotation

GitHub Actions / Static analysis and code style

Strict comparison using !== between non-falsy-string and false will always evaluate to true.
$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<string>
*/
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<string>
*/
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 : [],
)));
}
}
121 changes: 121 additions & 0 deletions tests/Feature/FeedUrlGuardTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
<?php

declare(strict_types=1);

namespace Odden\MailBuilder\Tests\Feature;

use Illuminate\Http\Client\Request;
use Illuminate\Support\Facades\Http;
use Odden\MailBuilder\Data\EmailDocument;
use Odden\MailBuilder\Enums\SlotType;
use Odden\MailBuilder\MailBuilder;
use Odden\MailBuilder\Support\OutboundUrlGuard;
use Odden\MailBuilder\Tests\TestCase;
use PHPUnit\Framework\Attributes\DataProvider;

class FeedUrlGuardTest extends TestCase
{
/**
* @return array<string, array{string}>
*/
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;
}
}
22 changes: 22 additions & 0 deletions tests/Support/FakeOutboundUrlGuard.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
<?php

declare(strict_types=1);

namespace Odden\MailBuilder\Tests\Support;

use Odden\MailBuilder\Support\OutboundUrlGuard;

/**
* Resolves names without touching DNS: "localhost" to loopback, hosts starting with "internal." to a private address, everything else to a public one.
*/
class FakeOutboundUrlGuard extends OutboundUrlGuard
{
protected function resolveHost(string $host): array
{
return match (true) {
$host === 'localhost' => ['127.0.0.1', '::1'],
str_starts_with($host, 'internal.') => ['10.0.0.5'],
default => ['93.184.216.34'],
};
}
}
Loading
Loading