In the previous lesson, we learned what Server-Side Request Forgery (SSRF) is and why it poses serious security risks. We explored how attackers can trick servers into making HTTP requests to unintended destinations, potentially accessing internal networks and cloud infrastructure.
Now it is time to move from theory to practice. In this lesson, you will exploit a real SSRF vulnerability in our pastebin's URL import feature, accessing resources that should be completely off-limits. Then, you will learn how to properly defend against these attacks by implementing three layers of protection: URL validation, hostname blocklists, and DNS resolution checks. By the end of this lesson, you will understand both the attacker's perspective and the defender's toolkit.
Let's start by examining the code that makes our pastebin's import feature vulnerable. This is located in the import.ts route handler, and it is a perfect example of how well-intentioned features can introduce critical security flaws. The feature allows users to create a new snippet by providing a URL to existing code hosted elsewhere, which sounds useful and harmless at first glance.
Here is the vulnerable endpoint:
The endpoint starts by extracting the url and optional title from the request body. Notice the axios interceptor at the top — this simulates a real-world scenario where the AWS metadata endpoint at 169.254.169.254 is accessible from within the server's network. In production AWS environments, this endpoint is actually reachable from EC2 instances, but in our learning environment, we redirect these requests to a mock service running on port 3002. This allows you to practice the attack safely while understanding how it would work in reality.
Now look at what happens next:
This is where the vulnerability lives. The code takes the url parameter directly from user input and passes it straight to axios.get() without any validation whatsoever. There are no checks on what protocol the URL uses, what hostname it points to, or whether that hostname resolves to an internal IP address. The server blindly trusts that the user provided a legitimate external URL.
After fetching the content, the code handles various response types (converting JSON objects to strings, for example) and creates a snippet with a randomly generated ID. Notice that userId is set to 0 to represent anonymous or imported content — this is important because the Snippet model requires a userId field even for content that doesn't belong to any particular user.
Why is this dangerous? Remember that your server has privileges that external users do not have. It can access internal network resources, localhost services, and cloud metadata endpoints. When you pass a user-controlled URL directly to axios.get(), you are essentially giving attackers the ability to make HTTP requests from inside your trusted network. They can target any resource your server can reach, completely bypassing firewalls and network segmentation that would normally protect those resources.
The critical mistake here is treating user input as trustworthy. In security, we always assume user input is malicious until proven otherwise. This endpoint does the opposite — it assumes the URL is safe and legitimate without performing any validation checks.
Now let's exploit this vulnerability to access one of the most sensitive targets: cloud metadata services. If you are running on AWS, Azure, or Google Cloud, these cloud providers expose metadata about your instance at a special IP address. This metadata includes temporary security credentials that grant access to your cloud resources. Stealing these credentials can lead to a complete infrastructure compromise.
AWS exposes its metadata service at 169.254.169.254, which is a link-local address that is only accessible from within the instance itself. External attackers cannot reach this IP directly over the internet, but through SSRF, they can trick your server into making the request on their behalf.
Here is how an attacker would exploit our vulnerable endpoint to steal AWS credentials:
Let's break down what this attack does. The attacker sends a POST request to our import endpoint, providing a malicious URL that points to AWS's metadata service. Specifically, they are targeting the IAM security credentials path, which contains temporary access keys for the instance's IAM role.
When our vulnerable server receives this request, it does not validate the url. It simply calls axios.get("http://169.254.169.254/latest/meta-data/iam/security-credentials/") and fetches whatever content is there. Thanks to our axios interceptor (which simulates real AWS behavior in our learning environment), the request reaches the mock metadata service, which responds with the name of the IAM role attached to the instance.
The response would look something like:
The attacker can then make a second request to get the actual credentials:
This second request returns a JSON response containing AccessKeyId, SecretAccessKey, and Token — everything needed to authenticate as your application and access your AWS resources. The attacker can now view S3 buckets, modify databases, launch instances, or perform any action that your application's IAM role permits. This is exactly how the Capital One breach happened in 2019, exposing data from over 100 million customers.
The scariest part? This attack requires no authentication, no complex exploitation, and no special tools. The attacker just needs to know that the metadata endpoint exists and that your application has an SSRF vulnerability.
Now that we have seen how devastating SSRF can be, let's build proper defenses. We will implement protection in three layers, starting with the foundation: safe URL parsing and protocol validation. This ensures we only accept well-formed URLs using safe protocols.
First, we need to safely parse the user-provided url. JavaScript's built-in URL class is perfect for this because it throws an error if the input is not a valid URL. This prevents bypass attempts using malformed strings:
Why wrap this in try-catch? The URL constructor throws a TypeError if you pass it something that is not a valid URL. By catching this error, we prevent our server from crashing and give the user a clear error message. This is better than letting the error propagate, which could expose stack traces or cause unexpected behavior.
Now we have a properly parsed URL object with separate properties for protocol, hostname, path, and other components. This makes validation much easier and more reliable than trying to parse URLs with regular expressions or string manipulation.
Next, we validate that the protocol is one we explicitly allow:
This simple check prevents several attack vectors. Without it, attackers could use protocols like file:// to read local files, gopher:// to interact with internal services in unusual ways, or dict:// to probe internal network services. By limiting our allowed protocols to just http: and https:, we significantly reduce the attack surface.
Notice that the URL object's protocol property includes the colon (:) at the end, so we check for http: and https: rather than http and https. This is a small detail, but getting it wrong would make the validation ineffective.
Protocol validation helps, but it is not enough. An attacker can still use http://localhost/admin or http://169.254.169.254/ to reach internal resources. Our second layer of defense is a hostname blocklist that explicitly rejects known dangerous targets.
Here is how we implement it:
This blocklist prevents the most common SSRF targets. We block localhost and 127.0.0.1 to prevent accessing services on the server itself. We block 0.0.0.0 because it can sometimes be used as an alias for localhost, depending on the system configuration. Most importantly, we block 169.254.169.254, which is the AWS metadata endpoint we exploited earlier.
Why is this important? Even with protocol validation, an attacker could request http://localhost:3002/admin/users using a perfectly valid HTTP URL. The blocklist catches this and rejects it before the request is made. This prevents the internal service exploitation we demonstrated earlier.
However, blocklists have significant limitations. Attackers can often bypass them using alternative representations of the same address. For example, 127.0.0.1 can also be written as 127.1, 0x7f000001 (hexadecimal), or even 2130706433 (decimal). The hostname localhost might have alternatives like localhost.localdomain or could be bypassed using DNS rebinding attacks.
Additionally, the blocklist only covers the specific hostnames we explicitly added. It does not protect against other internal IP ranges like 10.0.0.5 (a common private network address) or 192.168.1.1 (a typical router address). An attacker could still target these addresses unless we add them all to the blocklist, which is not practical.
This is why we need a third, more robust layer of defense that actually resolves hostnames and checks the resulting IP addresses.
The most effective SSRF defense resolves the hostname to its actual IP address and checks whether that IP belongs to a private or internal network range. This catches bypass attempts that use alternative hostname representations or DNS tricks.
First, we will create a helper function that checks if an IP address is internal:
This function does several important things. First, it uses dns.resolve4() to perform a DNS lookup and get all IPv4 addresses that the hostname resolves to. Note that a single hostname can resolve to multiple IP addresses for load balancing or redundancy, which is why we use addresses.some() to check if any of them are internal.
The IP range checks cover all private network ranges defined in RFC 1918 plus the link-local range. The 10.0.0.0/8 range covers any IP starting with 10. The 127.0.0.0/8 range is the loopback range, including all addresses that reference the local machine. The 172.16.0.0/12 range requires checking that the second octet is between 16 and 31. The 192.168.0.0/16 range covers typical home and small business networks. Finally, 169.254.0.0/16 is the link-local range that includes cloud metadata endpoints.
Why return true on DNS failure? If DNS resolution fails, it might indicate a malicious hostname or network issue. Either way, we should reject the request rather than allowing it through. This is a fail-secure approach — when in doubt, block the request.
Note: This implementation only handles
IPv4addresses because it usesdns.resolve4(). It does not checkIPv6addresses at all, meaning a hostname that resolves to anIPv6loopback (::1) or link-local address (fe80::/10) would slip through. In a real production environment, you would need to also calldns.resolve6()and add equivalent range checks forIPv6private and reserved ranges. For brevity, this lesson focuses on theIPv4case to illustrate the core concept.
Now we can use this function in our endpoint:
This check happens after protocol validation and blocklist checking, creating defense in depth. Even if an attacker finds a hostname that bypasses our blocklist, the DNS resolution check will catch it when the hostname resolves to an internal IP.
For example, if an attacker tries to use 127.1 instead of 127.0.0.1, our blocklist will not catch it because it is not explicitly listed. However, when we resolve 127.1, it resolves to 127.0.0.1, which our isInternalIP() function correctly identifies as being in the 127.0.0.0/8 range.
Finally, we add one more safeguard when making the actual request and creating the snippet:
Setting maxRedirects: 0 prevents attackers from using redirects to bypass our validation. Without this, an attacker could host a malicious server at evil.com that immediately redirects to http://169.254.169.254/latest/meta-data/. Our validation would approve evil.com as safe, but the redirect would lead to the metadata endpoint. By disabling redirects entirely, we ensure that the URL we validated is exactly the URL we fetch.
The timeout: 5000 setting (5 seconds) prevents an attacker from making your server hang indefinitely by pointing to a server that never responds. This protects against denial-of-service attacks where the attacker exhausts your server's connection pool.
After fetching the content, we handle different response types appropriately — converting JSON objects to formatted strings, ensuring all content becomes string type before storage. The snippet creation uses a randomly generated ID (since our model uses string IDs rather than auto-incrementing integers), sets the language field to 'text' as required by the model, and uses userId: 0 to represent anonymous or imported content.
You have now learned how to exploit SSRF vulnerabilities and how to defend against them properly. We started by exploiting the unprotected URL import endpoint to access cloud metadata services and internal admin panels, demonstrating the real-world impact of SSRF attacks.
Then we built three layers of defense: URL parsing with protocol validation to ensure well-formed, safe URLs; hostname blocklists to reject known dangerous targets; and DNS resolution checks to catch sophisticated bypass attempts. These defensive layers work together to create robust protection against SSRF. In the upcoming practice exercises, you will apply these concepts hands-on, exploiting vulnerable endpoints and implementing proper defenses yourself to solidify your understanding.
