Scala Play WebService SSRF Vulnerability

High Risk Server-Side Request Forgery (SSRF)
ScalaPlay FrameworkSSRFWebServiceHTTP ClientInternal Network Access

What it is

Application uses Play Framework's WebService client with user-controlled URLs, enabling Server-Side Request Forgery (SSRF) attacks that can access internal resources and services.

import play.api.mvc._ import play.api.libs.ws._ import scala.concurrent.ExecutionContext import javax.inject.Inject class ApiController @Inject()(ws: WSClient, cc: ControllerComponents) (implicit ec: ExecutionContext) extends AbstractController(cc) { def fetchData = Action.async { implicit request => // Vulnerable: User controls URL val url = request.getQueryString("url").getOrElse("") // DANGEROUS: Can access internal services ws.url(url).get().map { response => Ok(response.body) } } def proxyRequest = Action.async { implicit request => // Vulnerable: POST proxy without validation val targetUrl = request.body.asFormUrlEncoded .flatMap(_.get("target_url")) .flatMap(_.headOption) .getOrElse("") // Can be used to attack internal services ws.url(targetUrl) .post(request.body.asJson.getOrElse("")) .map { response => Ok(response.json) } } def webhook = Action.async { implicit request => // Vulnerable: Webhook URL from user input val webhookUrl = (request.body.asJson.get \ "webhook_url").as[String] val data = (request.body.asJson.get \ "data").as[String] // No validation - can target internal services ws.url(webhookUrl) .withHttpHeaders("Content-Type" -> "application/json") .post(data) .map { _ => Ok("Webhook sent") } } }
import play.api.mvc._ import play.api.libs.ws._ import scala.concurrent.{ExecutionContext, Future} import java.net.{InetAddress, URI} import javax.inject.Inject import scala.util.{Try, Success, Failure} class ApiController @Inject()(ws: WSClient, cc: ControllerComponents) (implicit ec: ExecutionContext) extends AbstractController(cc) { // Allowlist of permitted external domains private val allowedHosts = Set( "api.trusted.com", "secure.partner.com", "webhook.external.com" ) // Blocked internal network ranges private val blockedNetworks = Seq( "127.0.0.0/8", // Loopback "10.0.0.0/8", // Private Class A "172.16.0.0/12", // Private Class B "192.168.0.0/16", // Private Class C "169.254.0.0/16", // Link-local "::1/128", // IPv6 loopback "fc00::/7" // IPv6 private ) def validateUrl(url: String): Either[String, URI] = { if (url.isEmpty) { return Left("URL cannot be empty") } Try(new URI(url)) match { case Success(uri) => // Check scheme if (!Set("http", "https").contains(uri.getScheme)) { return Left("Only HTTP and HTTPS schemes are allowed") } // Require HTTPS for external calls if (uri.getScheme != "https") { return Left("Only HTTPS URLs are allowed") } // Check host val host = uri.getHost if (host == null || host.isEmpty) { return Left("Invalid host") } // Check against allowlist if (!allowedHosts.contains(host)) { return Left(s"Host $host not in allowlist") } // Check for IP addresses (should use domain names) Try(InetAddress.getByName(host)) match { case Success(addr) => val ip = addr.getHostAddress // Block internal IP ranges if (isInternalIP(ip)) { return Left("Access to internal networks is blocked") } case Failure(_) => return Left("Invalid host address") } Right(uri) case Failure(_) => Left("Malformed URL") } } private def isInternalIP(ip: String): Boolean = { // Simplified check - in production, use proper CIDR matching ip.startsWith("127.") || ip.startsWith("10.") || ip.startsWith("192.168.") || (ip.startsWith("172.") && { val octets = ip.split(".") octets.length >= 2 && { val second = Try(octets(1).toInt).getOrElse(0) second >= 16 && second <= 31 } }) || ip.startsWith("169.254.") || ip == "::1" } def fetchData = Action.async { implicit request => val url = request.getQueryString("url").getOrElse("") validateUrl(url) match { case Right(validUri) => // Secure: Validated URL with timeout ws.url(validUri.toString) .withRequestTimeout(10000) // 10 second timeout .get() .map { response => if (response.status == 200) { // Limit response size val limitedBody = response.body.take(1000000) // 1MB limit Ok(limitedBody) } else { BadRequest(s"External service returned ${response.status}") } } .recover { case _: java.util.concurrent.TimeoutException => RequestTimeout("Request timeout") case _ => InternalServerError("External request failed") } case Left(error) => Future.successful(BadRequest(s"Invalid URL: $error")) } } def proxyRequest = Action.async { implicit request => // Secure: Don't allow arbitrary proxying Future.successful(Forbidden("Proxy functionality disabled for security")) } def webhook = Action.async { implicit request => request.body.asJson match { case Some(json) => val webhookUrlOpt = (json \ "webhook_url").asOpt[String] val dataOpt = (json \ "data").asOpt[String] (webhookUrlOpt, dataOpt) match { case (Some(webhookUrl), Some(data)) => validateUrl(webhookUrl) match { case Right(validUri) => // Secure: Validated webhook with limits if (data.length > 10000) { Future.successful(BadRequest("Data payload too large")) } else { ws.url(validUri.toString) .withHttpHeaders( "Content-Type" -> "application/json", "User-Agent" -> "SecureApp/1.0" ) .withRequestTimeout(15000) // 15 second timeout .post(data) .map { response => if (response.status >= 200 && response.status < 300) { Ok("Webhook delivered successfully") } else { BadRequest(s"Webhook failed with status ${response.status}") } } .recover { case _: java.util.concurrent.TimeoutException => RequestTimeout("Webhook timeout") case _ => InternalServerError("Webhook delivery failed") } } case Left(error) => Future.successful(BadRequest(s"Invalid webhook URL: $error")) } case _ => Future.successful(BadRequest("Missing webhook_url or data")) } case None => Future.successful(BadRequest("Invalid JSON")) } } // Secure alternative: Predefined service calls def callTrustedService(serviceId: String) = Action.async { implicit request => val trustedServices = Map( "weather" -> "https://api.trusted.com/weather", "news" -> "https://api.trusted.com/news", "status" -> "https://api.trusted.com/status" ) trustedServices.get(serviceId) match { case Some(serviceUrl) => ws.url(serviceUrl) .withRequestTimeout(10000) .get() .map { response => Ok(response.json) } .recover { case _ => InternalServerError("Service call failed") } case None => Future.successful(BadRequest("Unknown service ID")) } } }

💡 Why This Fix Works

See fix suggestions for detailed explanation.

Why it happens

Play Framework code: ws.url(userInput).get(). WS (Web Service) client with user-supplied URL. Attackers control destination. SSRF to internal services, cloud metadata (169.254.169.254), localhost. Play WS makes arbitrary requests on server behalf.

Root causes

Using Play WS with User-Controlled URLs

Play Framework code: ws.url(userInput).get(). WS (Web Service) client with user-supplied URL. Attackers control destination. SSRF to internal services, cloud metadata (169.254.169.254), localhost. Play WS makes arbitrary requests on server behalf.

Not Validating URL Schemes in Web Service Calls

Missing scheme validation: ws.url(url).post(data). URL from request parameters or config. file://, gopher://, dict:// protocols possible. Non-HTTP schemes bypass network protections. Protocol smuggling enables file access and service probing.

Insufficient Hostname or IP Validation

Weak validation: if (!url.contains("localhost")) ws.url(url).get(). Incomplete blocklists. Alternative representations: 127.0.0.1, 0.0.0.0, [::1], decimal IPs. Missing private IP range validation. 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16 accessible.

Not Restricting HTTP Redirects in WS Client

Following redirects without validation: ws.url(url).withFollowRedirects(true).get(). Initial URL validated but redirect bypasses checks. Attacker's server redirects to internal services. Location header controlled. Redirect-based SSRF circumvents URL validation.

Building URLs with User Input Through String Operations

URL construction: ws.url(s"https://api.example.com?user=$userInput").get(). String interpolation with user input. At-symbol or other characters manipulate URL parsing. Intended parameters become hostname. URL building creates injection enabling host manipulation.

Fixes

1

Validate URLs Against Allowlist of Permitted Domains

Allowlist validation: val allowedDomains = Set("api.example.com", "cdn.example.com"); val uri = new URI(url); require(allowedDomains.contains(uri.getHost)); ws.url(url).get(). Parse URL. Check host allowlist. Reject unauthorized domains. Allowlist prevents all SSRF.

2

Block Private IP Ranges and Localhost

IP validation: import java.net.InetAddress; val addr = InetAddress.getByName(hostname); require(!addr.isLoopbackAddress && !addr.isSiteLocalAddress); ws.url(url).get(). Resolve hostname. Check if private or loopback. Block 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16, 127.0.0.0/8.

3

Restrict URL Schemes to HTTP/HTTPS Only

Scheme validation: val uri = new URI(url); require(uri.getScheme == "https" || uri.getScheme == "http"); ws.url(url).get(). Parse and validate scheme. Reject file://, gopher://, others. HTTPS preferred. HTTP/HTTPS only prevents protocol smuggling.

4

Disable Automatic Redirects or Validate Redirect Destinations

Disable redirects: ws.url(url).withFollowRedirects(false).get(). Or validate: check response status; if redirect, validate Location header against allowlist before following. Apply same validation to redirects as original URLs.

5

Use Indirect References Instead of Direct URL Input

Map user input to predefined URLs: val urlMap = Map("api" -> "https://api.example.com", "cdn" -> "https://cdn.example.com"); val serviceKey = request.getQueryString("service"); urlMap.get(serviceKey).map(ws.url(_).get()). Users select by key. Application controls destinations.

6

Configure Network Policies and Egress Filtering

Network-level controls: firewall rules blocking outbound to private IPs. Egress proxy with allowlist. Network policies restricting application outbound access. Defense-in-depth limiting SSRF impact even if application validation bypassed.

Detect This Vulnerability in Your Code

Sourcery automatically identifies scala play webservice ssrf vulnerability and many other security issues in your codebase.