Since 4.0.4, passing the resolveDNSHost option makes getLinkPreview fail for effectively every HTTPS URL.
The DNS-rebinding hardening in #181 introduced getValidatedFetchUrl, which rewrites the fetch URL's hostname to the IP returned by the callback (build/index.js:166, parsedUrl.hostname = formatHostnameForUrl(resolvedAddress)). The fetch then goes to https://<ip>/..., so the TLS handshake sends the bare IP as the SNI server name. SNI-routed hosts (any CDN) can't route the connection, and certificate validation fails because certs are issued for hostnames. The result is fetch failed with ERR_SSL_SSL/TLS_ALERT_HANDSHAKE_FAILURE.
The README's SSRF example resolves to the bare IP address, so anyone following the documented usage hits this. (Callbacks that return a full http(s):// URL bypass the rewrite and still work, but that's not what the docs show.)
To be clear, this report is about a functional regression in the hardening, not a challenge to the security intent of #181.
To Reproduce
const { getLinkPreview } = require('link-preview-js'); // 4.0.4
const dns = require('node:dns/promises');
getLinkPreview('https://example.com', {
resolveDNSHost: async (url) => (await dns.lookup(new URL(url).hostname)).address,
}).then(console.log, console.error);
// 4.0.4: fetch failed (ERR_SSL_SSL/TLS_ALERT_HANDSHAKE_FAILURE)
// 4.0.3: resolves normally
The same call without resolveDNSHost succeeds on 4.0.4. Confirmed against multiple public HTTPS sites (example.com, guide.michelin.com).
| Version |
with resolveDNSHost |
without |
| 4.0.4 |
fails (TLS handshake) |
works |
| 4.0.3 and earlier |
works |
works |
Expected behavior
HTTPS previews succeed with resolveDNSHost set, as on <= 4.0.3. Through 4.0.3 the resolved IP was only validated (throwOnLoopback) and the fetch still used the original hostname URL.
Environment
- Node.js (server), reproduced on Node 22
- link-preview-js 4.0.4
Additional context
Suggested fix: keep the rebinding protection but pin at the connection layer instead of rewriting the URL. Resolve once, validate, then connect to the validated IP while keeping the original hostname for SNI and the Host header (e.g. an undici Agent with a custom lookup/connect, or got's dnsLookup hook).
Since 4.0.4, passing the
resolveDNSHostoption makesgetLinkPreviewfail for effectively every HTTPS URL.The DNS-rebinding hardening in #181 introduced
getValidatedFetchUrl, which rewrites the fetch URL's hostname to the IP returned by the callback (build/index.js:166,parsedUrl.hostname = formatHostnameForUrl(resolvedAddress)). The fetch then goes tohttps://<ip>/..., so the TLS handshake sends the bare IP as the SNI server name. SNI-routed hosts (any CDN) can't route the connection, and certificate validation fails because certs are issued for hostnames. The result isfetch failedwithERR_SSL_SSL/TLS_ALERT_HANDSHAKE_FAILURE.The README's SSRF example resolves to the bare IP address, so anyone following the documented usage hits this. (Callbacks that return a full
http(s)://URL bypass the rewrite and still work, but that's not what the docs show.)To be clear, this report is about a functional regression in the hardening, not a challenge to the security intent of #181.
To Reproduce
The same call without
resolveDNSHostsucceeds on 4.0.4. Confirmed against multiple public HTTPS sites (example.com, guide.michelin.com).Expected behavior
HTTPS previews succeed with
resolveDNSHostset, as on <= 4.0.3. Through 4.0.3 the resolved IP was only validated (throwOnLoopback) and the fetch still used the original hostname URL.Environment
Additional context
Suggested fix: keep the rebinding protection but pin at the connection layer instead of rewriting the URL. Resolve once, validate, then connect to the validated IP while keeping the original hostname for SNI and the Host header (e.g. an undici Agent with a custom
lookup/connect, or got'sdnsLookuphook).