Example finding How it works Coverage PENTEST METHODOLOGY DOCS PRICING FAQ MCP CONTACT LOG IN SIGN UP →

Link preview fetches internal URLs: how to fix the SSRF

FIX / LINK PREVIEW SSRF / 9 MIN READ

A link preview that fetches any URL lets an attacker read your private network, so resolve each hostname yourself, refuse private and local IP addresses at connect time, stop redirects, and limit the size of the response. The attacker does not need a password. The attacker only submits a URL, and your server makes the request from inside your network.

This guide shows how to confirm the problem, how to fix a Node server, and how to remove the worst target: the cloud metadata service. It also shows how to check your logs.

A link preview feature takes a URL from a user. The server downloads the page and reads the title, the description, and the image. The app shows these values as a card. Chat apps, note apps, and link boards often have this feature.

The server makes the request, not the browser. Your server sits inside your network. It can reach addresses that the public internet cannot reach. Examples are localhost, other services in the same private network, and the metadata service of your cloud host.

This attack has a name: Server-Side Request Forgery (SSRF). The OWASP cheat sheet describes SSRF as an attack that abuses an application to interact with the internal or external network, or with the machine itself. The weakness has the identifier CWE-918.

The attacker submits http://127.0.0.1:3000/admin as the link. Your server fetches the page from itself. Your app then shows the title of an internal admin page, or it returns the full response. The attacker reads data that no public user may read.

Vibe coding tools often write this feature in a few lines. The code calls fetch(url) on the text that the user sent. The feature works with each public site, so nobody tests it with a private address.

Why is this dangerous?

The impact depends on what your server can reach. These are the common targets:

Target Example address What the attacker can read
The app itself http://localhost:3000/ Internal routes, debug pages, admin panels
Private services http://10.0.0.5:6379/ Databases, caches, and queues without a password
Cloud metadata http://169.254.169.254/ Instance data and temporary role credentials
Local files file:///etc/passwd Files, if the client supports the file: scheme

The cloud metadata service is the most serious target. The OWASP cheat sheet states that attackers often use SSRF to steal credentials from metadata services such as the AWS Instance Metadata Service, the Azure service, and the GCP server. A stolen role credential lets the attacker call cloud APIs with the permissions of your server.

Submit private addresses to the preview feature and watch what the app does. Use test addresses on your own server only.

Warning: Test only your own app. Do not submit private addresses to an app that you do not own. Do not run the metadata test on a server that you do not control.

  1. Find the feature. Look for any input that takes a URL: link previews, “import from URL”, avatar by URL, webhook URL, and RSS import. Each of them can have the problem.
  2. Test a public address first. Submit a normal page. Make sure that the feature works.
  3. Test the loopback address. Submit http://127.0.0.1/ and http://localhost/. If the app can show a title or text, the server reached itself.
  4. Test other forms of the same address. Submit http://[::1]/ and http://2130706433/. The second value is the decimal form of 127.0.0.1.
  5. Test the redirect. Host a page on a server that you own. Make it answer with a 302 redirect to http://127.0.0.1/. Submit your public URL. If the app follows the redirect, the check on the first URL is not enough.
  6. Test the metadata address on your own AWS server. Submit http://169.254.169.254/latest/meta-data/. A response that contains names such as ami-id means that the service answered.

You can also check from the code. Search for the HTTP calls that use a variable:

grep -rn -E "fetch\((url|link|href|req\.|body\.)|axios\.(get|post)\((url|link)|got\((url|link)|requests\.get\((url|link)" src app pages lib api 2>/dev/null

Check each result. Ask one question: “Can a user control this value?” If the answer is yes, the call needs the checks in the next section.

How do I fix it? Fix it now, in order of urgency

Do the steps in this order. Steps 1 and 2 reduce the damage on a server that is already exposed. The other steps remove the cause.

  1. Turn off the feature if you cannot fix it today. A feature that is off cannot be abused. Show a plain link without a preview until the fix is live.
  2. Require IMDSv2 on AWS servers. This step makes metadata theft harder. See the section on IMDSv2 below.
  3. Add the address check to the fetch code. Use the code in the next section.
  4. Stop redirects. A redirect can send your server to a private address after the first check.
  5. Limit the time, the size, and the type of the response. These limits stop slow and large responses.
  6. Show only the fields that you need. Return the title, the description, and the image URL. Do not return the raw response.
  7. Deploy and test again. Repeat the tests from the previous section. Each private address must fail.

Caution: Do not check only the text of the URL. A check for the words localhost and 127.0.0.1 fails against http://2130706433/, against http://[::1]/, and against a domain name that points to 127.0.0.1. The check must look at the IP address that the connection uses.

Check the IP address when the connection opens

The OWASP cheat sheet says that a check of the domain name alone does not prevent DNS rebinding. An attacker controls a domain. The domain first resolves to a public address and passes your check. Then it resolves to a private address when your server connects. The cheat sheet recommends that you validate the resolved IP addresses and that the HTTP client connects only to the validated addresses.

The code below does this in Node. It uses the net.BlockList class from the Node.js documentation. It also uses the lookup option of the HTTP client, so the check runs on the same lookup that the connection uses.

import dns from "node:dns";
import http from "node:http";
import https from "node:https";
import net from "node:net";

const blocked = new net.BlockList();
for (const [a, p] of [["0.0.0.0", 8], ["10.0.0.0", 8], ["100.64.0.0", 10], ["127.0.0.0", 8],
  ["169.254.0.0", 16], ["172.16.0.0", 12], ["192.168.0.0", 16]]) blocked.addSubnet(a, p, "ipv4");
for (const [a, p] of [["::", 128], ["::1", 128], ["fc00::", 7], ["fe80::", 10]]) blocked.addSubnet(a, p, "ipv6");

const isBlocked = (ip) => blocked.check(ip, net.isIPv6(ip) ? "ipv6" : "ipv4");

function safeLookup(host, opts, cb) {
  dns.lookup(host, { ...opts, all: true }, (err, addrs) => {
    if (err) return cb(err);
    if (addrs.length === 0 || addrs.some((a) => isBlocked(a.address))) return cb(new Error("blocked address"));
    if (opts.all) return cb(null, addrs);
    cb(null, addrs[0].address, addrs[0].family);
  });
}

export function fetchHtml(rawUrl) {
  return new Promise((resolve, reject) => {
    const url = new URL(rawUrl);
    if (url.protocol !== "https:" && url.protocol !== "http:") return reject(new Error("bad scheme"));
    const host = url.hostname.replace(/^\[|\]$/g, "");
    if (net.isIP(host) && isBlocked(host)) return reject(new Error("blocked address"));

    const client = url.protocol === "https:" ? https : http;
    const req = client.get(url, { lookup: safeLookup, timeout: 5000 }, (res) => {
      if (res.statusCode !== 200) {
        res.resume();
        return reject(new Error(`status ${res.statusCode}`));
      }
      const chunks = [];
      let size = 0;
      res.on("data", (chunk) => {
        size += chunk.length;
        if (size > 512 * 1024) return req.destroy(new Error("too large"));
        chunks.push(chunk);
      });
      res.on("end", () => resolve(Buffer.concat(chunks).toString("utf8")));
    });
    req.on("timeout", () => req.destroy(new Error("timeout")));
    req.on("error", reject);
  });
}

This code does six things:

  • It accepts only the http: and https: schemes. This rule blocks file: and similar schemes.
  • It checks a hostname that is already an IP address. Node does not call the lookup function for such a host, so the code checks it first.
  • It resolves the hostname and rejects the request if any returned address is private or local.
  • It connects with the same lookup, so a second lookup cannot return a different address.
  • It treats every status other than 200 as an error. This rule also stops redirects, because the Node http module does not follow them.
  • It stops after five seconds and after 512 KB.

We ran this code against 127.0.0.1, localhost, [::1], 169.254.169.254, the decimal form 2130706433, and [::ffff:127.0.0.1]. The function rejected each one. It fetched a public site without an error.

If you use another language, the design stays the same. Resolve the name, check every address, and connect to the checked address. The fetch function of Node and most libraries follow redirects by default, so turn that off, or check each redirect target with the same function.

Use an allowlist when you can

The OWASP cheat sheet says that deny lists are bypass-prone and that you should prefer allow lists. A link preview cannot use an allowlist, because users may submit any public site. Other features can. If your app fetches only from a few known hosts, such as an image CDN, accept only those hosts. Compare the parsed host against a fixed list, and build the request yourself.

Require IMDSv2 on AWS

If your server runs on EC2, the metadata service is the main prize. The AWS documentation says that, by default, you can use IMDSv1 or IMDSv2 or both. IMDSv2 needs a session token. A client gets the token with a PUT request and sends the token in a header on each GET request. A simple SSRF that can only send a GET request cannot get the token.

You can set an instance to accept only IMDSv2. The AWS CLI example from the command reference is:

aws ec2 modify-instance-metadata-options \
    --instance-id i-1234567898abcdef0 \
    --http-tokens required \
    --http-endpoint enabled

Replace the instance ID with your own. After this change, requests without a valid token receive a 401 error. Old software that uses IMDSv1 stops working, so use a current AWS SDK. The AWS documentation lists the minimum SDK versions.

The OWASP cheat sheet calls IMDSv2 a defense-in-depth measure. It reduces the risk, but it does not replace the address checks. Also give the instance role only the permissions that the app needs. A role with full access makes each SSRF much worse.

How do I check the damage?

Look for requests that your server sent to private addresses. Also look for use of your cloud credentials from outside.

  1. Read the application logs. Search for submitted URLs that contain 127., localhost, 169.254, 10., 192.168, 172.16 to 172.31, [::1], or a decimal number as the host. Log files of the preview feature are the best source.
  2. Look for odd URLs. Search for the schemes file:, gopher:, and ftp:, and for unusual ports such as 6379, 5432, and 9200.
  3. Look at the users who submitted them. One account that tests many private addresses and ports points to a scan. Block the account and check what else it did.
  4. Check the cloud audit logs. On AWS, read the CloudTrail events for the role of the server. Look for API calls from IP addresses that are not your servers, and for calls to services that your app never uses.
  5. Check the private services. Look at the logs of internal databases, caches, and admin tools. Look for requests from your app server at the times of the suspicious submissions.

If you find a request to the metadata address, treat the role credentials as stolen. Credentials from the metadata service are temporary, but an attacker can use them while they are valid. Replace the role or the instance, and rotate every secret that the server can read. That list includes the environment variables of the app.

Read the environment variables guide for how to rotate and limit secrets.

How do I stop it from happening again?

Use these controls so that no new URL fetcher opens the same hole.

  1. Use one safe fetch function. Put the code from this guide in one module. Every feature that fetches a user URL must call it. Do not call fetch directly on user input anywhere else.
  2. Apply network limits. The OWASP cheat sheet recommends limits at the network layer, in addition to the application layer. Run the fetching code in a service that has no route to your databases and to your admin tools.
  3. Add tests for the bad addresses. Test the loopback address, the metadata address, the IPv6 forms, a redirect to a private address, and a large response. Run the tests in each build.
  4. Give the server role minimum permissions. If an attacker gets the credentials, the damage stays small.
  5. Limit the rate. Allow only a few preview requests per user per minute. A limit makes scans slow. See API abuse protection.
  6. Add a rule for your coding tool. Write in the project rules: “Never call fetch on a user URL. Use fetchHtml.” Search each pull request for this pattern.
  7. Scan the deployed app after each release. A scan sends private addresses to the live inputs and shows the real result.

The SSRF and open redirect pattern page shows more cases, such as webhooks and image URLs.

When should I get help?

Get help in these cases:

  • Your logs show a request to the metadata address or to an internal database from the preview feature.
  • Cloud audit logs show use of your server role from an address that is not yours.
  • Your app stores secrets that the server could read, and you cannot tell if an attacker saw them.
  • Your preview feature must render pages in a headless browser. This design has more risks than a plain HTTP fetch, and it needs a careful review.
  • Your app stores personal data and you saw signs of access to internal services. You may have a duty to report a breach.

After you finish the fix, run the private-address tests one more time on the live app. Each one must fail, and the preview of a public page must still work.

Common questions

What is SSRF in a link preview?
SSRF means that an attacker makes your server send a request. A link preview sends such a request for each URL that a user submits. If the URL points to a private address, your server reads private data.
Can I block localhost with a string check?
No. Attackers use other forms of the same address, such as a decimal number, an IPv6 address, or a domain that resolves to 127.0.0.1. Check the resolved IP address, not the text.
Why is the cloud metadata address dangerous?
On AWS, the address 169.254.169.254 returns instance data. It can include temporary credentials of the instance role. An SSRF can read this data when IMDSv1 is on.
Do I need the allowlist or the blocklist?
Use an allowlist when you know the target hosts. A link preview must fetch unknown hosts, so use a blocklist at connect time and add network limits.
Is a serverless host safe from this problem?
Not fully. The host may not have a metadata service, but the function can still reach other private services. Apply the same address checks.

Test your URL fetchers

A VibeEval scan submits private and local addresses to the URL inputs of your deployed app and reports each fetch that your server completes.

14-day free trial · No credit card · Cancel anytime

SCAN YOUR APP →