Overview
Server-Side Request Forgery (SSRF) is a vulnerability where an attacker can coerce a server-side application to make HTTP requests to an arbitrary location. Instead of attacking the user, the attacker uses the application’s server as a proxy to send crafted requests. This is especially dangerous in cloud environments, as it can be used to access internal-only services or cloud provider metadata endpoints.Business Impact
A successful SSRF attack can lead to the complete compromise of the server’s cloud account by stealing credentials from metadata services. It allows an attacker to map out and interact with the internal network, bypass firewalls, and access sensitive internal services like databases, admin panels, or internal APIs that are not exposed to the internet.Reference Details
CWE ID: CWE-918
OWASP Top 10 (2021): A10:2021 - Server-Side Request Forgery
Severity: Critical
Framework-Specific Analysis and Remediation
No web framework provides built-in protection against SSRF because making outbound HTTP requests is a fundamental feature. The responsibility lies entirely with the developer to validate and sanitize any user-supplied data that is used to construct the URL for an outbound request. The most robust defense is a strict allow-list of permitted hosts.- Python
- Java
- .NET(C#)
- PHP
- Node.js
- Ruby
Framework Context
Django applications commonly use therequests library to make outbound HTTP calls. The vulnerability arises when a URL, or part of a URL, is constructed from user input without proper validation.Vulnerable Scenario 1: Image Importer
A feature allows users to import a profile picture by providing a URL. The server then fetches the image.Copy
# profiles/views.py
import requests
from django.http import HttpResponse
def import_avatar(request):
image_url = request.GET.get('url')
# DANGEROUS: The server makes a request to any URL the user provides.
# An attacker can provide "[http://169.254.169.254/latest/meta-data/iam/security-credentials/ec2-instance-role](http://169.254.169.254/latest/meta-data/iam/security-credentials/ec2-instance-role)"
# to steal AWS credentials.
try:
response = requests.get(image_url, timeout=5)
# ... process image ...
return HttpResponse("Image imported.")
except requests.RequestException:
return HttpResponse("Could not fetch image.", status=400)
Vulnerable Scenario 2: Webhook Notification
A service allows users to configure a webhook URL to be notified of events.Copy
# webhooks/services.py
def send_webhook_notification(webhook_url, data):
# DANGEROUS: The server blindly sends a POST request to any configured URL.
# This can be used to scan internal ports or attack internal services.
# For example, "http://localhost:8080/admin/delete-all-users"
requests.post(webhook_url, json=data)
Mitigation and Best Practices
Parse the user-provided URL, extract the hostname, and validate it against a strict allow-list of trusted domains. Never make a request to an IP address directly.Secure Code Example
Copy
# profiles/views.py (Secure Version)
import requests
from urllib.parse import urlparse
ALLOWED_IMAGE_HOSTS = {
'images.unsplash.com',
'pbs.twimg.com',
'cloudinary.com'
}
def import_avatar(request):
image_url = request.GET.get('url')
try:
parsed_url = urlparse(image_url)
# 1. Check scheme
if parsed_url.scheme not in ['http', 'https']:
return HttpResponse("Invalid URL scheme.", status=400)
# 2. Check hostname against allow-list
if parsed_url.hostname not in ALLOWED_IMAGE_HOSTS:
return HttpResponse("Host not allowed.", status=400)
# SAFE: The request is only made after validation.
response = requests.get(image_url, timeout=5)
# ... process image ...
return HttpResponse("Image imported.")
except (ValueError, requests.RequestException):
return HttpResponse("Invalid URL or could not fetch image.", status=400)
Testing Strategy
Write tests that attempt to request URLs pointing tolocalhost, internal IP ranges (e.g., 127.0.0.1, 10.0.0.1, 192.168.1.1), and the cloud metadata service IP (169.254.169.254). Use a mocking library like responses or unittest.mock to intercept the outbound HTTP request and assert that a request is not made for disallowed hosts.Copy
# profiles/tests.py
import responses
@responses.activate
def test_import_avatar_ssrf_attempt_is_blocked(self):
# The AWS metadata service IP
ssrf_payload_url = "[http://169.254.169.254/metadata](http://169.254.169.254/metadata)"
# We don't need to mock the request itself because a properly
# secured function should never even try to make the call.
response = self.client.get(reverse('import-avatar'), {'url': ssrf_payload_url})
self.assertEqual(response.status_code, 400)
self.assertIn("Host not allowed", response.content.decode())
# Verify that no outbound HTTP call was actually made.
self.assertEqual(len(responses.calls), 0)
Framework Context
Java applications typically useHttpClient, RestTemplate, or WebClient for outbound requests. The vulnerability is language-agnostic and depends on the developer’s failure to validate URLs before passing them to these clients.Vulnerable Scenario 1: PDF Generation from Webpage
A service that converts a webpage to a PDF by fetching its HTML content.Copy
// service/PdfGeneratorService.java
@Service
public class PdfGeneratorService {
@Autowired
private RestTemplate restTemplate;
public byte[] generateFromUrl(String urlString) {
// DANGEROUS: The server will make a GET request to any URL passed in,
// including internal ones like "[http://internal-dashboard.service.local](http://internal-dashboard.service.local)"
String htmlContent = restTemplate.getForObject(urlString, String.class);
// ... logic to convert HTML to PDF ...
}
}
Vulnerable Scenario 2: API Data Proxy
An endpoint that acts as a proxy to fetch data from another API, specified by the user.Copy
// controller/ProxyController.java
@GetMapping("/proxy")
public String proxyRequest(@RequestParam String targetApi) {
// DANGEROUS: An attacker can point this to internal APIs.
return new RestTemplate().getForObject(targetApi, String.class);
}
Mitigation and Best Practices
Use Java’sjava.net.URI class to robustly parse the URL. Extract the host and validate it against a predefined Set of allowed domains. Additionally, configure the HTTP client to prevent it from following redirects, which can be used to bypass validation.Secure Code Example
Copy
// service/PdfGeneratorService.java (Secure Version)
import java.net.URI;
import java.net.URISyntaxException;
import java.util.Set;
@Service
public class PdfGeneratorService {
private final RestTemplate restTemplate;
private final Set<String> allowedHosts = Set.of("en.wikipedia.org", "[www.bbc.com](https://www.bbc.com)");
public PdfGeneratorService(RestTemplateBuilder restTemplateBuilder) {
// Configure RestTemplate to not follow redirects
this.restTemplate = restTemplateBuilder
.requestFactory(() -> new SimpleClientHttpRequestFactory() {
@Override
protected void prepareConnection(HttpURLConnection connection, String httpMethod) throws IOException {
super.prepareConnection(connection, httpMethod);
connection.setInstanceFollowRedirects(false);
}
}).build();
}
public byte[] generateFromUrl(String urlString) throws URISyntaxException {
URI uri = new URI(urlString);
String host = uri.getHost();
if (host == null || !allowedHosts.contains(host.toLowerCase())) {
throw new IllegalArgumentException("Host is not allowed: " + host);
}
// SAFE: Request is made only after host validation.
String htmlContent = restTemplate.getForObject(uri.toString(), String.class);
// ... logic to convert HTML to PDF ...
}
}
Testing Strategy
Write JUnit tests that call the service method with various malicious URLs (e.g.,file:///etc/passwd, http://127.0.0.1:8080, http://169.254.169.254). The tests should assert that an IllegalArgumentException (or a similar custom exception) is thrown and that no outbound request is attempted.Copy
// src/test/java/com/example/PdfGeneratorServiceTest.java
@Test
void generateFromUrl_withInternalIp_shouldThrowException() {
String internalUrl = "[http://127.0.0.1/admin](http://127.0.0.1/admin)";
assertThrows(IllegalArgumentException.class, () -> {
pdfGeneratorService.generateFromUrl(internalUrl);
});
}
@Test
void generateFromUrl_withMetadataService_shouldThrowException() {
String metadataUrl = "[http://169.254.169.254/](http://169.254.169.254/)";
var exception = assertThrows(IllegalArgumentException.class, () -> {
pdfGeneratorService.generateFromUrl(metadataUrl);
});
assertTrue(exception.getMessage().contains("Host is not allowed"));
}
Framework Context
SSRF vulnerabilities in ASP.NET Core applications are common when usingHttpClient or the legacy WebClient with user-controlled URLs. The remediation pattern is consistent: robust URL parsing and hostname validation.Vulnerable Scenario 1: A “Site Screenshot” Service
A service that takes a screenshot of a webpage by fetching it.Copy
// Services/ScreenshotService.cs
public class ScreenshotService
{
private readonly HttpClient _httpClient;
public ScreenshotService(HttpClient httpClient) { _httpClient = httpClient; }
public async Task<byte[]> TakeScreenshot(string url)
{
// DANGEROUS: The HttpClient will request any URL, including internal ones.
var response = await _httpClient.GetStringAsync(url);
// ... logic to render HTML and take screenshot ...
}
}
Vulnerable Scenario 2: Checking for robots.txt
A tool for SEO analysis that fetches the robots.txt file from a user-submitted domain.Copy
// Controllers/SeoController.cs
[HttpGet("check-robots")]
public async Task<IActionResult> CheckRobots([FromQuery] string domain)
{
var url = $"http://{domain}/robots.txt";
// DANGEROUS: Attacker can provide "localhost:5000" or an internal service name.
var robotsContent = await _httpClient.GetStringAsync(url);
return Ok(robotsContent);
}
Mitigation and Best Practices
Use theSystem.Uri class to parse and validate URLs. Compare the uri.Host against a configured allow-list of domains. Ensure the HTTP client is configured to disallow redirects, which could otherwise be used to circumvent hostname checks.Secure Code Example
Copy
// Services/ScreenshotService.cs (Secure Version)
public class ScreenshotService
{
private readonly HttpClient _httpClient;
private readonly IConfiguration _config;
public ScreenshotService(IHttpClientFactory httpClientFactory, IConfiguration config)
{
// Best practice: configure HttpClient to not follow redirects for SSRF protection
var handler = new HttpClientHandler { AllowAutoRedirect = false };
_httpClient = httpClientFactory.CreateClient();
_httpClient.DefaultRequestHeaders.Clear(); // Example setup
_config = config;
}
public async Task<byte[]> TakeScreenshot(string url)
{
if (!Uri.TryCreate(url, UriKind.Absolute, out var uri))
{
throw new ArgumentException("Invalid URL format");
}
var allowedHosts = _config.GetSection("AllowedScreenshotHosts").Get<string[]>();
if (uri.HostNameType != UriHostNameType.Dns || !allowedHosts.Contains(uri.Host))
{
throw new ArgumentException("Host is not allowed.");
}
// SAFE: Request is only made after validation.
var response = await _httpClient.GetStringAsync(uri);
// ... logic to render HTML and take screenshot ...
}
}
Testing Strategy
Use xUnit and a mocking library likeMoq or a test handler like MockHttpMessageHandler to test the service. The tests should confirm that an ArgumentException is thrown for disallowed hosts and that the HttpClient’s SendAsync method is never called in those cases.Copy
// Tests/ScreenshotServiceTests.cs
[Fact]
public async Task TakeScreenshot_WithDisallowedHost_ShouldThrowArgumentException()
{
// Arrange
var mockHttpFactory = new Mock<IHttpClientFactory>();
var config = new ConfigurationBuilder().AddInMemoryCollection(new Dictionary<string, string> {
{"AllowedScreenshotHosts:0", "example.com"}
}).Build();
var service = new ScreenshotService(mockHttpFactory.Object, config);
var ssrfUrl = "[http://internal-service.local/admin](http://internal-service.local/admin)";
// Act & Assert
await Assert.ThrowsAsync<ArgumentException>(() => service.TakeScreenshot(ssrfUrl));
}
Framework Context
Laravel’sHttp client (a wrapper around Guzzle) makes outbound requests easy. The vulnerability lies in the business logic that constructs the URL, not the client itself.Vulnerable Scenario 1: Social Media Profile Importer
A feature to import user data by providing a link to their public profile on another platform.Copy
// app/Http/Controllers/ImportController.php
use Illuminate\Support\Facades\Http;
class ImportController extends Controller
{
public function import(Request $request)
{
$profileUrl = $request->input('url');
// DANGEROUS: The server makes a request to any URL the user provides.
$response = Http::get($profileUrl);
// ... logic to parse and import data ...
}
}
Vulnerable Scenario 2: RSS Feed Parser
A blog aggregator fetches and parses an RSS feed from a URL submitted by a user.Copy
// app/Jobs/FetchRssFeed.php
class FetchRssFeed implements ShouldQueue
{
protected $feedUrl;
public function __construct(string $feedUrl) { $this->feedUrl = $feedUrl; }
public function handle(): void
{
// DANGEROUS: The job will make a request to any URL passed to it.
$response = Http::get($this->feedUrl);
// ... parse RSS ...
}
}
Mitigation and Best Practices
Use PHP’s built-inparse_url() function to extract the host from the user-provided URL. Validate this host against an allow-list stored in your application’s configuration. Configure the HTTP client to disable redirects.Secure Code Example
Copy
// app/Http/Controllers/ImportController.php (Secure Version)
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
class ImportController extends Controller
{
public function import(Request $request)
{
$profileUrl = $request->input('url');
$host = parse_url($profileUrl, PHP_URL_HOST);
// Allow-list is stored in config/services.php
$allowedHosts = config('services.allowed_import_hosts');
if (!$host || !in_array($host, $allowedHosts)) {
Log::warning('SSRF attempt blocked for host: ' . $host);
return back()->withErrors(['url' => 'The provided URL is not from an allowed source.']);
}
// SAFE: Request is made without redirects and only to allowed hosts.
$response = Http::withoutRedirecting()->get($profileUrl);
// ... logic to parse and import data ...
}
}
Testing Strategy
Write a PHPUnit feature test that posts a disallowed URL to the import endpoint. Assert that the application returns a validation error and that no outbound request was actually made by using Laravel’sHttp::fake() facade.Copy
// tests/Feature/ImportControllerTest.php
public function test_import_blocks_ssrf_attempts()
{
Http::fake(); // Prevent any real HTTP requests from being made
$user = User::factory()->create();
$disallowedUrl = '[http://127.0.0.1/server-status](http://127.0.0.1/server-status)';
$response = $this->actingAs($user)->post('/import-profile', ['url' => $disallowedUrl]);
$response->assertSessionHasErrors('url');
// Assert that no request was sent to the disallowed URL
Http::assertNothingSent();
}
Framework Context
In the Node.js ecosystem, libraries likeaxios or the built-in https module are used for requests. The principles remain the same: validate URLs before use. The built-in URL class is the standard tool for parsing.Vulnerable Scenario 1: A URL Preview Generator
An API endpoint that fetches a URL’s metadata (title, description, image) to generate a preview card.Copy
// routes/preview.js
const express = require('express');
const router = express.Router();
const axios = require('axios');
router.get('/', async (req, res) => {
const { url } = req.query;
try {
// DANGEROUS: Makes a GET request to any user-provided URL.
const response = await axios.get(url);
// ... logic to parse HTML and extract metadata ...
res.json({ title: "Some Title" });
} catch (error) {
res.status(500).send('Error fetching URL');
}
});
module.exports = router;
Vulnerable Scenario 2: Dynamic Badge Service
A service that generates a status badge by querying a user-provided API endpoint.Copy
// services/badgeService.js
async function getBadgeStatus(apiUrl) {
// DANGEROUS: Can be pointed to internal monitoring endpoints.
const { data } = await axios.get(apiUrl);
return data.status;
}
Mitigation and Best Practices
Use the WHATWGURL object for robust parsing. Check the protocol and hostname properties against a strict allow-list. Configure your HTTP client to disable redirects.Secure Code Example
Copy
// routes/preview.js (Secure Version)
const { URL } = require('url');
const ALLOWED_HOSTS = new Set(['github.com', 'developer.mozilla.org']);
router.get('/', async (req, res) => {
const { url } = req.query;
let parsedUrl;
try {
parsedUrl = new URL(url);
} catch (err) {
return res.status(400).send('Invalid URL format');
}
if (!['http:', 'https:'].includes(parsedUrl.protocol)) {
return res.status(400).send('Invalid protocol');
}
if (!ALLOWED_HOSTS.has(parsedUrl.hostname)) {
return res.status(400).send('Host not allowed');
}
try {
// SAFE: Request is made only after validation, and redirects are disabled.
const response = await axios.get(parsedUrl.toString(), { maxRedirects: 0 });
// ... logic to parse HTML ...
res.json({ title: "Some Title" });
} catch (error) {
res.status(500).send('Error fetching URL');
}
});
Testing Strategy
Use Jest and a mocking library likenock or Jest’s built-in mocking to test the endpoint. The test should attempt to request a disallowed URL and assert that the HTTP client (axios.get) was never called with that URL.Copy
// tests/preview.test.js
const axios = require('axios');
jest.mock('axios'); // Mock the entire axios library
it('should not make a request to a disallowed host', async () => {
const app = require('../app'); // Your express app
const request = require('supertest');
const response = await request(app).get('/api/preview?url=[http://127.0.0.1](http://127.0.0.1)');
expect(response.statusCode).toBe(400);
expect(response.text).toContain('Host not allowed');
// Verify that the mocked axios.get was never called.
expect(axios.get).not.toHaveBeenCalled();
});
Framework Context
Outbound requests in Rails are typically made with the built-inNet::HTTP library or higher-level gems like HTTParty or Faraday. As with other frameworks, the vulnerability lies in the logic that constructs and validates the URL before the request is made.Vulnerable Scenario 1: A URL Expander Service
A simple service that takes a shortened URL and returns the final destination URL after following redirects.Copy
# app/controllers/url_expander_controller.rb
require 'net/http'
class UrlExpanderController < ApplicationController
def expand
short_url = params[:url]
# DANGEROUS: The application will connect to any host and follow redirects
# to any other host, allowing an attacker to probe the internal network.
response = Net::HTTP.get_response(URI(short_url))
final_url = response['location'] || short_url
render json: { final_url: final_url }
end
end
Vulnerable Scenario 2: Webmention Receiver
A blog feature that receives a “webmention” (a notification that another site has linked to it) and fetches the source URL to verify the link.Copy
# app/services/webmention_verifier.rb
class WebmentionVerifier
def self.verify(source_url, target_url)
# DANGEROUS: The `source_url` is provided by an external, untrusted actor.
response = HTTParty.get(source_url)
# ... logic to check if the response body contains a link to target_url ...
end
end
Mitigation and Best Practices
Use Ruby’sURI module to parse URLs. Check the parsed host against an allow-list. When making requests, explicitly disable following redirects if they are not necessary, or validate each redirect location if they are.Secure Code Example
Copy
# app/services/webmention_verifier.rb (Secure Version)
class WebmentionVerifier
ALLOWED_DOMAINS = ['some-blog.com', 'another-trusted-site.org'].freeze
def self.verify(source_url, target_url)
begin
uri = URI.parse(source_url)
rescue URI::InvalidURIError
return { valid: false, reason: "Invalid URL" }
end
unless ALLOWED_DOMAINS.include?(uri.host)
return { valid: false, reason: "Host not allowed" }
end
# SAFE: Make the request only after validation.
# HTTParty can be configured not to follow redirects as well.
response = HTTParty.get(uri.to_s, no_follow: true)
# ... logic to check response body ...
end
end
Testing Strategy
Use RSpec to test the service object directly. Use a mocking library likeWebMock to control the HTTP requests. The test should attempt to verify a URL from a disallowed host and assert that WebMock never received a request to that host.Copy
# spec/services/webmention_verifier_spec.rb
require 'rails_helper'
RSpec.describe WebmentionVerifier do
it "does not make an HTTP request to a disallowed host" do
disallowed_url = "[http://internal.service/private-data](http://internal.service/private-data)"
# WebMock will raise an error if an unexpected HTTP request is made.
result = WebmentionVerifier.verify(disallowed_url, "[http://my-site.com/post](http://my-site.com/post)")
expect(result[:valid]).to be(false)
expect(result[:reason]).to eq("Host not allowed")
end
end

