{
    "type": "article",
    "title": "Fetching a feed from a URL you don’t trust",
    "canonical": "https://www.dealmobile.co.uk/writing/fetching-feeds-from-untrusted-urls",
    "path": "/writing/fetching-feeds-from-untrusted-urls",
    "url": "https://www.dealmobile.co.uk/writing/fetching-feeds-from-untrusted-urls",
    "markdown": "https://www.dealmobile.co.uk/writing/fetching-feeds-from-untrusted-urls.md",
    "json": "https://www.dealmobile.co.uk/writing/fetching-feeds-from-untrusted-urls.json",
    "summary": "How RSS to JSON stops a feed URL from reaching private addresses on the server’s own network, by checking every address a host resolves to, pinning the connection to the one it checked, and checking every redirect again.",
    "published": "2026-10-20T00:00:00+01:00",
    "modified": "2026-10-25T18:32:07+00:00",
    "image": {
        "url": "https://www.dealmobile.co.uk/sites/default/files/content/images/posts/fetching-feeds-from-untrusted-urls/hero.png",
        "alt": "A terminal showing RSS to JSON refusing three addresses, including the cloud metadata address written as IPv6, each with the message “Refusing to connect ... private or reserved address”."
    },
    "fields": {
        "image_light": null
    },
    "tags": [
        {
            "name": "PHP",
            "url": "https://www.dealmobile.co.uk/writing/tag/php",
            "json": "https://www.dealmobile.co.uk/writing/tag/php.json"
        },
        {
            "name": "Security",
            "url": "https://www.dealmobile.co.uk/writing/tag/security",
            "json": "https://www.dealmobile.co.uk/writing/tag/security.json"
        },
        {
            "name": "Testing",
            "url": "https://www.dealmobile.co.uk/writing/tag/testing",
            "json": "https://www.dealmobile.co.uk/writing/tag/testing.json"
        },
        {
            "name": "RSS to JSON",
            "url": "https://www.dealmobile.co.uk/writing/tag/rss-json",
            "json": "https://www.dealmobile.co.uk/writing/tag/rss-json.json"
        }
    ],
    "projects": [
        {
            "name": "RSS to JSON",
            "url": "https://www.dealmobile.co.uk/projects/rss-to-json",
            "json": "https://www.dealmobile.co.uk/projects/rss-to-json.json"
        }
    ],
    "body": {
        "html": "<p><a href=\"/projects/rss-to-json\">RSS to JSON</a> reads a feed from a URL and gives back an array ready for <code>json_encode()</code>. That’s harmless when I choose the URL. It’s a different matter when a visitor types it into a form, because the request comes from the server, and the server can reach places the visitor can’t: an admin page on 127.0.0.1, a service on the private network, or 169.254.169.254, where cloud servers read their own settings and, on some setups, their credentials. Getting a server to make requests like that on your behalf is called server-side request forgery.</p>\n<p>Version 2.1 added an option for it, <code>block_private_ips</code>. It’s off by default, so existing code behaves as before, and it’s meant for any URL that comes from someone you don’t trust. Here’s what it does, in the order it does it.</p>\n<h2 id=\"which-addresses-count-as-private\">Which addresses count as private</h2>\n<p>The list lives in <code>src/IpAddress.php</code>. I wrote the ranges out rather than rely on PHP’s <code>FILTER_FLAG_NO_PRIV_RANGE</code> and <code>FILTER_FLAG_NO_RES_RANGE</code>, whose coverage has changed between PHP versions. The IPv4 list has fifteen ranges, including these:</p>\n<pre><code class=\"language-php\">'10.0.0.0/8',       // Private\n'100.64.0.0/10',    // Carrier-grade NAT (includes 100.100.100.200, Alibaba Cloud metadata)\n'127.0.0.0/8',      // Loopback\n'169.254.0.0/16',   // Link-local (includes 169.254.169.254, AWS/GCP/Azure metadata)\n'172.16.0.0/12',    // Private\n'192.168.0.0/16',   // Private\n</code></pre>\n<p>The others are the “this network” block, the documentation and benchmarking ranges, the old 6to4 relay range, multicast, and the reserved block that ends at 255.255.255.255. IPv6 has its own list: the unspecified and loopback addresses, unique local addresses (where AWS puts its IPv6 metadata address, <code>fd00:ec2::254</code>), link-local, site-local, multicast, documentation, discard and Teredo.</p>\n<p>An IPv6 address can also carry an IPv4 address inside it. <code>::ffff:169.254.169.254</code> is the metadata address written as IPv6, and a check that only looked at the IPv6 list would let it through. So four IPv6 ranges are marked as wrapping IPv4, and for those the embedded four bytes are checked against the IPv4 list:</p>\n<pre><code class=\"language-php\">private const EMBEDDED_V4 = [\n    '::ffff:0:0/96' =&gt; 12,  // IPv4-mapped\n    '::/96' =&gt; 12,          // IPv4-compatible (deprecated)\n    '64:ff9b::/96' =&gt; 12,   // NAT64\n    '2002::/16' =&gt; 2,       // 6to4\n];\n</code></pre>\n<p>The number is the byte where the IPv4 address starts. 6to4 puts it straight after the first two bytes, so <code>2002:7f00:0001::1</code> is 127.0.0.1 in disguise.</p>\n<p>Addresses are compared as bytes, after <code>inet_pton()</code>, and anything that function can’t parse is treated as not public. <code>localhost</code>, the decimal form <code>2130706433</code> and <code>999.1.1.1</code> all fail, and the tests list each one.</p>\n<h2 id=\"resolve-first-and-check-every-address\">Resolve first, and check every address</h2>\n<p>A host name isn’t an address, so <code>checkUrl()</code> resolves it before anything connects: <code>gethostbynamel()</code> for IPv4 and <code>dns_get_record()</code> for IPv6. Whoever controls a domain controls its DNS, and can return one public address and one private one. So every address has to pass, not just the first:</p>\n<pre><code class=\"language-php\">$addresses = $isIpLiteral ? [$host] : $this-&gt;resolveHost($host);\nif ($addresses === []) {\n    $this-&gt;lastError = \"Could not resolve host: $host\";\n    return null;\n}\nforeach ($addresses as $address) {\n    if (!$this-&gt;isAddressAllowed($address)) {\n        $this-&gt;lastError = sprintf('Refusing to connect to %s (%s): private or reserved address', $host, $address);\n        return null;\n    }\n}\n</code></pre>\n<p>Before the lookup, some URLs are refused outright in this mode. A URL with a user name and password, whitespace or a backslash goes, because PHP’s <code>parse_url()</code> and libcurl can disagree about which part of it is the host. A URL that ends in <code>\\@169.254.169.254/</code> is the kind of thing that rule is for. Host names that aren’t plain ASCII, such as <code>local%68ost</code>, go too. A review found that libcurl decodes a percent-encoded or Unicode host name before it connects. The pin described below was keyed on the name as written, so it didn’t apply, and curl made its own lookup of the decoded name, which nothing had checked. International domains still work in their <code>xn--</code> form.</p>\n<h2 id=\"pinning-the-connection\">Pinning the connection</h2>\n<p>Checking the address and then letting curl connect means two DNS lookups, and the answer can change between them. That’s DNS rebinding: the attacker’s DNS server answers the first lookup with a public address and a very short lifetime, and the second with 127.0.0.1. The check passes, and curl connects somewhere else.</p>\n<p>So curl never does its own lookup. <code>CURLOPT_RESOLVE</code> tells it which address to use for a host and port, and it’s given the address that was checked:</p>\n<pre><code class=\"language-php\">if ($target['ip'] !== null) {\n    // A proxy would make its own connection, out of our sight.\n    curl_setopt($ch, CURLOPT_PROXY, '');\n\n    // Connect to the address that was checked, not a fresh lookup.\n    // An IP literal involves no lookup, so there is nothing to pin.\n    if (filter_var($target['host'], FILTER_VALIDATE_IP) === false) {\n        $address = strpos($target['ip'], ':') === false ? $target['ip'] : '[' . $target['ip'] . ']';\n        curl_setopt($ch, CURLOPT_RESOLVE, [sprintf('%s:%d:%s', $target['host'], $target['port'], $address)]);\n    }\n}\n</code></pre>\n<p>The host name stays in the URL, so the <code>Host</code> header and the TLS certificate check still use it. When a host has both kinds of address, the pin prefers IPv4, which every version of libcurl accepts in <code>CURLOPT_RESOLVE</code>. The empty proxy setting matters too: curl picks up proxy settings from the environment, and a proxy would make its own connection, out of the check’s sight.</p>\n<p>The same review found that an IP literal shouldn’t be pinned at all. There’s no lookup to protect, and an IPv6 literal produced a <code>CURLOPT_RESOLVE</code> entry that libcurl couldn’t parse, so every URL with one failed.</p>\n<p>On PHP 8.4 and later, with libcurl 7.80 or later, there’s a second line of defence. <code>CURLOPT_PREREQFUNCTION</code> runs once curl has connected and before it sends the request, with the address it connected to, and the same check can abort the request there. Older versions get a check of <code>CURLINFO_PRIMARY_IP</code> afterwards, which throws the response away. By then the request has gone, so that one is only a backstop. The pin is what does the work.</p>\n<h2 id=\"redirects\">Redirects</h2>\n<p>curl can follow redirects itself, but then the second hop would skip every check above. So <code>CURLOPT_FOLLOWLOCATION</code> is off, and <code>fetchRemoteFeed()</code> follows redirects in its own loop. Each new URL goes back through <code>checkUrl()</code>: the scheme, the lookup, the addresses and a fresh pin. Only then is it requested.</p>\n<p>The new URL comes from <code>CURLINFO_REDIRECT_URL</code>, so a relative redirect arrives as a full URL. A feed that redirects to <code>http://169.254.169.254/latest/meta-data/</code> is refused at the second hop, and so is one that redirects to <code>http://[::ffff:169.254.169.254]/</code>. There’s a test for each. Five redirects are allowed by default, and one deadline covers the whole fetch, so a chain of slow redirects can’t stretch the ten-second timeout.</p>\n<p>Some of this applies with the option off. Only <code>http</code> and <code>https</code> are fetched, for the first URL and for every redirect, so a redirect to <code>file:///etc/passwd</code>, <code>gopher://</code> or <code>dict://</code> is refused either way. curl is also limited to those two protocols, as a second check.</p>\n<h2 id=\"testing-it\">Testing it</h2>\n<p>The remote tests run against PHP’s built-in web server on 127.0.0.1, which is exactly the kind of address the option refuses. The class has a hook for this, the protected <code>isAddressAllowed()</code>, and the tests use a subclass that lets 127.0.0.1 through and nothing else on loopback:</p>\n<pre><code class=\"language-php\">return new class (['block_private_ips' =&gt; true]) extends RSSToJson {\n    protected function isAddressAllowed(string $ip): bool\n    {\n        return $ip === '127.0.0.1' || parent::isAddressAllowed($ip);\n    }\n};\n</code></pre>\n<p>So a redirect test can start at the test server and check that a hop to 10.0.0.1 or 127.0.0.2 is refused. The rebinding defence has its own test, with a subclass whose <code>isAddressAllowed()</code> says yes the first time and no after that, as if DNS had changed between the check and the connection. The fetch has to fail, with the connected address in the error. The suite has 112 tests, and PHPStan checks the code at its strictest level.</p>\n<h2 id=\"what-it-doesnt-cover\">What it doesn’t cover</h2>\n<p>PHP’s DNS lookups can’t be interrupted, so a slow resolver can push a fetch past its timeout. And the option only covers fetching. <code>parse()</code> treats anything that isn’t a valid URL as a file path, and PHP’s file functions accept stream wrappers, so untrusted input belongs in <code>parseUrl()</code>, which refuses anything but <code>http</code> and <code>https</code>.</p>\n<h2 id=\"using-it\">Using it</h2>\n<pre><code class=\"language-bash\">composer require tomgould/rsstojson\n</code></pre>\n<pre><code class=\"language-php\">use TomGould\\RSSToJson\\RSSToJson;\n\n$parser = new RSSToJson(['block_private_ips' =&gt; true]);\n$feed = $parser-&gt;parseUrl($urlFromTheForm);\n\nif ($feed === null) {\n    // For example: Refusing to connect to localhost (127.0.0.1): private or reserved address\n    echo $parser-&gt;getLastError(), PHP_EOL;\n}\n</code></pre>\n<p>To let one internal feed server through, extend the class and override <code>isAddressAllowed(string $ip): bool</code>, the way the tests do. The code is on <a href=\"https://github.com/tomgould/rss-to-json\">GitHub</a>.</p>"
    },
    "markdown_text": "---\ntitle: 'Fetching a feed from a URL you don’t trust'\ncanonical: 'https://www.dealmobile.co.uk/writing/fetching-feeds-from-untrusted-urls'\nurl: 'https://www.dealmobile.co.uk/writing/fetching-feeds-from-untrusted-urls'\ntype: Post\npublished: '2026-10-20'\nsummary: 'How RSS to JSON stops a feed URL from reaching private addresses on the server’s own network, by checking every address a host resolves to, pinning the connection to the one it checked, and checking every redirect again.'\n---\n\n# Fetching a feed from a URL you don’t trust\n\nPublished 2026-10-20\n\n![A terminal showing RSS to JSON refusing three addresses, including the cloud metadata address written as IPv6, each with the message “Refusing to connect ... private or reserved address”.](https://www.dealmobile.co.uk/sites/default/files/styles/almanac_shot_md/public/content/images/posts/fetching-feeds-from-untrusted-urls/hero.png.webp?itok=o9pTYUZz)\n\n[RSS to JSON](https://www.dealmobile.co.uk/projects/rss-to-json.md) reads a feed from a URL and gives back an array ready for `json_encode()`. That’s harmless when I choose the URL. It’s a different matter when a visitor types it into a form, because the request comes from the server, and the server can reach places the visitor can’t: an admin page on 127.0.0.1, a service on the private network, or 169.254.169.254, where cloud servers read their own settings and, on some setups, their credentials. Getting a server to make requests like that on your behalf is called server-side request forgery.\n\nVersion 2.1 added an option for it, `block_private_ips`. It’s off by default, so existing code behaves as before, and it’s meant for any URL that comes from someone you don’t trust. Here’s what it does, in the order it does it.\n\n## Which addresses count as private\n\nThe list lives in `src/IpAddress.php`. I wrote the ranges out rather than rely on PHP’s `FILTER_FLAG_NO_PRIV_RANGE` and `FILTER_FLAG_NO_RES_RANGE`, whose coverage has changed between PHP versions. The IPv4 list has fifteen ranges, including these:\n\n```php\n'10.0.0.0/8',       // Private\n'100.64.0.0/10',    // Carrier-grade NAT (includes 100.100.100.200, Alibaba Cloud metadata)\n'127.0.0.0/8',      // Loopback\n'169.254.0.0/16',   // Link-local (includes 169.254.169.254, AWS/GCP/Azure metadata)\n'172.16.0.0/12',    // Private\n'192.168.0.0/16',   // Private\n\n```\n\nThe others are the “this network” block, the documentation and benchmarking ranges, the old 6to4 relay range, multicast, and the reserved block that ends at 255.255.255.255. IPv6 has its own list: the unspecified and loopback addresses, unique local addresses (where AWS puts its IPv6 metadata address, `fd00:ec2::254`), link-local, site-local, multicast, documentation, discard and Teredo.\n\nAn IPv6 address can also carry an IPv4 address inside it. `::ffff:169.254.169.254` is the metadata address written as IPv6, and a check that only looked at the IPv6 list would let it through. So four IPv6 ranges are marked as wrapping IPv4, and for those the embedded four bytes are checked against the IPv4 list:\n\n```php\nprivate const EMBEDDED_V4 = [\n    '::ffff:0:0/96' => 12,  // IPv4-mapped\n    '::/96' => 12,          // IPv4-compatible (deprecated)\n    '64:ff9b::/96' => 12,   // NAT64\n    '2002::/16' => 2,       // 6to4\n];\n\n```\n\nThe number is the byte where the IPv4 address starts. 6to4 puts it straight after the first two bytes, so `2002:7f00:0001::1` is 127.0.0.1 in disguise.\n\nAddresses are compared as bytes, after `inet_pton()`, and anything that function can’t parse is treated as not public. `localhost`, the decimal form `2130706433` and `999.1.1.1` all fail, and the tests list each one.\n\n## Resolve first, and check every address\n\nA host name isn’t an address, so `checkUrl()` resolves it before anything connects: `gethostbynamel()` for IPv4 and `dns_get_record()` for IPv6. Whoever controls a domain controls its DNS, and can return one public address and one private one. So every address has to pass, not just the first:\n\n```php\n$addresses = $isIpLiteral ? [$host] : $this->resolveHost($host);\nif ($addresses === []) {\n    $this->lastError = \"Could not resolve host: $host\";\n    return null;\n}\nforeach ($addresses as $address) {\n    if (!$this->isAddressAllowed($address)) {\n        $this->lastError = sprintf('Refusing to connect to %s (%s): private or reserved address', $host, $address);\n        return null;\n    }\n}\n\n```\n\nBefore the lookup, some URLs are refused outright in this mode. A URL with a user name and password, whitespace or a backslash goes, because PHP’s `parse_url()` and libcurl can disagree about which part of it is the host. A URL that ends in `\\@169.254.169.254/` is the kind of thing that rule is for. Host names that aren’t plain ASCII, such as `local%68ost`, go too. A review found that libcurl decodes a percent-encoded or Unicode host name before it connects. The pin described below was keyed on the name as written, so it didn’t apply, and curl made its own lookup of the decoded name, which nothing had checked. International domains still work in their `xn--` form.\n\n## Pinning the connection\n\nChecking the address and then letting curl connect means two DNS lookups, and the answer can change between them. That’s DNS rebinding: the attacker’s DNS server answers the first lookup with a public address and a very short lifetime, and the second with 127.0.0.1. The check passes, and curl connects somewhere else.\n\nSo curl never does its own lookup. `CURLOPT_RESOLVE` tells it which address to use for a host and port, and it’s given the address that was checked:\n\n```php\nif ($target['ip'] !== null) {\n    // A proxy would make its own connection, out of our sight.\n    curl_setopt($ch, CURLOPT_PROXY, '');\n\n    // Connect to the address that was checked, not a fresh lookup.\n    // An IP literal involves no lookup, so there is nothing to pin.\n    if (filter_var($target['host'], FILTER_VALIDATE_IP) === false) {\n        $address = strpos($target['ip'], ':') === false ? $target['ip'] : '[' . $target['ip'] . ']';\n        curl_setopt($ch, CURLOPT_RESOLVE, [sprintf('%s:%d:%s', $target['host'], $target['port'], $address)]);\n    }\n}\n\n```\n\nThe host name stays in the URL, so the `Host` header and the TLS certificate check still use it. When a host has both kinds of address, the pin prefers IPv4, which every version of libcurl accepts in `CURLOPT_RESOLVE`. The empty proxy setting matters too: curl picks up proxy settings from the environment, and a proxy would make its own connection, out of the check’s sight.\n\nThe same review found that an IP literal shouldn’t be pinned at all. There’s no lookup to protect, and an IPv6 literal produced a `CURLOPT_RESOLVE` entry that libcurl couldn’t parse, so every URL with one failed.\n\nOn PHP 8.4 and later, with libcurl 7.80 or later, there’s a second line of defence. `CURLOPT_PREREQFUNCTION` runs once curl has connected and before it sends the request, with the address it connected to, and the same check can abort the request there. Older versions get a check of `CURLINFO_PRIMARY_IP` afterwards, which throws the response away. By then the request has gone, so that one is only a backstop. The pin is what does the work.\n\n## Redirects\n\ncurl can follow redirects itself, but then the second hop would skip every check above. So `CURLOPT_FOLLOWLOCATION` is off, and `fetchRemoteFeed()` follows redirects in its own loop. Each new URL goes back through `checkUrl()`: the scheme, the lookup, the addresses and a fresh pin. Only then is it requested.\n\nThe new URL comes from `CURLINFO_REDIRECT_URL`, so a relative redirect arrives as a full URL. A feed that redirects to `http://169.254.169.254/latest/meta-data/` is refused at the second hop, and so is one that redirects to `http://[::ffff:169.254.169.254]/`. There’s a test for each. Five redirects are allowed by default, and one deadline covers the whole fetch, so a chain of slow redirects can’t stretch the ten-second timeout.\n\nSome of this applies with the option off. Only `http` and `https` are fetched, for the first URL and for every redirect, so a redirect to `file:///etc/passwd`, `gopher://` or `dict://` is refused either way. curl is also limited to those two protocols, as a second check.\n\n## Testing it\n\nThe remote tests run against PHP’s built-in web server on 127.0.0.1, which is exactly the kind of address the option refuses. The class has a hook for this, the protected `isAddressAllowed()`, and the tests use a subclass that lets 127.0.0.1 through and nothing else on loopback:\n\n```php\nreturn new class (['block_private_ips' => true]) extends RSSToJson {\n    protected function isAddressAllowed(string $ip): bool\n    {\n        return $ip === '127.0.0.1' || parent::isAddressAllowed($ip);\n    }\n};\n\n```\n\nSo a redirect test can start at the test server and check that a hop to 10.0.0.1 or 127.0.0.2 is refused. The rebinding defence has its own test, with a subclass whose `isAddressAllowed()` says yes the first time and no after that, as if DNS had changed between the check and the connection. The fetch has to fail, with the connected address in the error. The suite has 112 tests, and PHPStan checks the code at its strictest level.\n\n## What it doesn’t cover\n\nPHP’s DNS lookups can’t be interrupted, so a slow resolver can push a fetch past its timeout. And the option only covers fetching. `parse()` treats anything that isn’t a valid URL as a file path, and PHP’s file functions accept stream wrappers, so untrusted input belongs in `parseUrl()`, which refuses anything but `http` and `https`.\n\n## Using it\n\n```bash\ncomposer require tomgould/rsstojson\n\n```\n\n```php\nuse TomGould\\RSSToJson\\RSSToJson;\n\n$parser = new RSSToJson(['block_private_ips' => true]);\n$feed = $parser->parseUrl($urlFromTheForm);\n\nif ($feed === null) {\n    // For example: Refusing to connect to localhost (127.0.0.1): private or reserved address\n    echo $parser->getLastError(), PHP_EOL;\n}\n\n```\n\nTo let one internal feed server through, extend the class and override `isAddressAllowed(string $ip): bool`, the way the tests do. The code is on [GitHub](https://github.com/tomgould/rss-to-json).\n\n- **Projects:** [RSS to JSON](https://www.dealmobile.co.uk/projects/rss-to-json.md)\n- **Tags:** [PHP](https://www.dealmobile.co.uk/writing/tag/php.md), [Security](https://www.dealmobile.co.uk/writing/tag/security.md), [Testing](https://www.dealmobile.co.uk/writing/tag/testing.md), [RSS to JSON](https://www.dealmobile.co.uk/writing/tag/rss-json.md)\n\n---\n\nIndex of this site: [llms.txt](https://www.dealmobile.co.uk/llms.txt)\n"
}