flawopen.com/ssrf/Kotlin
Kotlin SSRF: secure Ktor and OkHttp
Ktor SSRF can reach internal services through user URLs. Filter listed ranges in OkHttp DNS, disable redirects, set timeouts, and enforce separate egress controls; the code filter is not a complete global-reachability test.
A parcel-sorting hub checks the first depot on a label, then blindly follows any forwarding notice the depot sends. A courier carrying the company badge can end up at a staff-only control room even though the original label looked public. A Ktor service that fetches a caller-supplied URL has the same boundary: validating one DNS answer is not enough if OkHttp resolves again or follows a redirect. Validate the actual destination at connection time, restrict redirects, and limit outbound network access.
Core Concepts & Subsystem Terms
Ktor client followRedirects- The Ktor client installs its redirect handling by default, so
followRedirectsistrueunless you turn it off. A public URL answeringLocation: http://169.254.169.254/reaches the metadata service after any check on the original URL. A loopback-only checkisLoopbackAddressrejects only127.0.0.0/8and::1. Private ranges,169.254.169.254,0.0.0.0and CGNAT addresses pass, andInetAddress.getByNamereturns only one of the host's addresses.Check-then-fetch and DNS rebinding- The check resolves the hostname once and the HTTP engine resolves it again when it connects. A DNS server with a short TTL can hand the check a public address and the engine
127.0.0.1, so the check proves nothing about the connection. OkHttp engine with a custom DnsHttpClient(OkHttp)withengine { preconfigured = ... }lets you pass anOkHttpClientwhoseDnsreturns only public addresses; OkHttp connects only to those, for every request. IP-literal hosts do not go throughDns, so run the same check on the parsed host first, and setProxy.NO_PROXY. This sample blocks only the classes shown; it is not a complete IANA global-reachability test. Keep the range policy current and enforce egress independently.HttpTimeout and response size- The
HttpTimeoutplugin bounds connect and total request time, and reading at most a fixed number of bytes frombodyAsChannel()stops a huge or never-ending response from tying up the service.
Framework mappings
- CWE reference for the weakness described in this guide: CWE-918
- OWASP Top 10:2025 — A01:2025 Broken Access Control
- OWASP API Security Top 10 — API7:2023 Server Side Request Forgery
- D3-OTF Outbound Traffic Filtering — This guide's outbound network restriction limits which destinations the service or host can reach.
Step-by-Step Attack Flow
A user supplies a URL
The route reads FetchRequest with call.receive and passes its caller-controlled URL to the Ktor HttpClient backed by CIO or OkHttp.
The service connects to an internal address
A loopback-only precheck misses other private destinations. The client resolves the hostname again, and its default redirect behavior can follow a public URL to an internal address; DNS answers can also change between checks.
The internal service answers the trusted caller
The request originates from the application’s network position, where internal APIs, sidecars, or cloud metadata endpoints may be reachable. The preview route can return response text; the webhook route exposes response status and can cause a request-side effect.
The attacker reads secrets or pivots
Allow only required schemes and approved hosts, validate and pin resolved public addresses in the client’s DNS/connection path, disable redirects or revalidate every hop, bound response size and time, and restrict egress at the network layer.
Source Code: Flaw vs. Secure Implementation
// FetchRoutes.kt: both routes let the caller choose the destination
package com.example.fetch
import io.ktor.client.HttpClient
import io.ktor.client.engine.cio.CIO
import io.ktor.client.request.get
import io.ktor.client.request.post
import io.ktor.client.request.setBody
import io.ktor.client.statement.bodyAsText
import io.ktor.http.ContentType
import io.ktor.http.HttpStatusCode
import io.ktor.http.contentType
import io.ktor.server.request.receive
import io.ktor.server.response.respond
import io.ktor.server.response.respondText
import io.ktor.server.routing.Route
import io.ktor.server.routing.post
import java.net.InetAddress
import java.net.URI
import kotlinx.serialization.Serializable
@Serializable
data class FetchRequest(val url: String)
val client = HttpClient(CIO)
fun Route.fetchRoutes() {
// any URL is fetched, and the Ktor client follows redirects
post("/preview") {
val input = call.receive<FetchRequest>()
call.respondText(client.get(input.url).bodyAsText().take(5000))
}
// checks one address for loopback only; the engine resolves again
post("/webhooks/test") {
val input = call.receive<FetchRequest>()
if (InetAddress.getByName(URI(input.url).host).isLoopbackAddress) {
call.respond(HttpStatusCode.BadRequest)
return@post
}
val response = client.post(input.url) {
contentType(ContentType.Application.Json)
setBody("{\"ping\":true}")
}
call.respond(mapOf("status" to response.status.value))
}
}
// OkHttp applies a baseline address filter; independently enforce current network egress policy
package com.example.fetch
import io.ktor.client.HttpClient
import io.ktor.client.engine.okhttp.OkHttp
import io.ktor.client.plugins.HttpTimeout
import io.ktor.client.request.get
import io.ktor.client.request.post
import io.ktor.client.request.setBody
import io.ktor.client.statement.HttpResponse
import io.ktor.client.statement.bodyAsChannel
import io.ktor.http.ContentType
import io.ktor.http.HttpStatusCode
import io.ktor.http.contentType
import io.ktor.server.request.receive
import io.ktor.server.response.respond
import io.ktor.server.response.respondText
import io.ktor.server.routing.Route
import io.ktor.server.routing.post
import io.ktor.utils.io.jvm.javaio.toInputStream
import java.net.InetAddress
import java.net.Proxy
import java.net.UnknownHostException
import kotlinx.serialization.Serializable
import okhttp3.Dns
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
import okhttp3.OkHttpClient
@Serializable
data class FetchRequest(val url: String)
// baseline filter only; this is not a complete IANA global-reachability classifier
fun InetAddress.passesBaselineFilter(): Boolean {
val b = address
val cgnat = b.size == 4 && (b[0].toInt() and 0xff) == 100 && (b[1].toInt() and 0xc0) == 64
val uniqueLocal = b.size == 16 && (b[0].toInt() and 0xfe) == 0xfc
return !(isAnyLocalAddress || isLoopbackAddress || isLinkLocalAddress || isSiteLocalAddress ||
isMulticastAddress || cgnat || uniqueLocal)
}
// OkHttp connects only to what this returns
object FilteredDns : Dns {
override fun lookup(hostname: String): List<InetAddress> {
val addresses = Dns.SYSTEM.lookup(hostname)
if (addresses.isEmpty() || !addresses.all { it.passesBaselineFilter() }) {
throw UnknownHostException("blocked destination $hostname")
}
return addresses
}
}
// no proxy, no redirects, timeouts; IP literals are checked in checkedUrl
val client = HttpClient(OkHttp) {
followRedirects = false
install(HttpTimeout) {
connectTimeoutMillis = 3_000
requestTimeoutMillis = 10_000
}
engine {
preconfigured = OkHttpClient.Builder()
.dns(FilteredDns)
.proxy(Proxy.NO_PROXY)
.followRedirects(false)
.followSslRedirects(false)
.build()
}
}
fun checkedUrl(raw: String): String {
val url = raw.toHttpUrlOrNull() ?: throw IllegalArgumentException("only http and https URLs are allowed")
FilteredDns.lookup(url.host)
return url.toString()
}
suspend fun HttpResponse.firstBytes(limit: Int): String =
bodyAsChannel().toInputStream().use { String(it.readNBytes(limit)) }
// at most 5000 bytes are read from any response
fun Route.fetchRoutes() {
post("/preview") {
val target = runCatching { checkedUrl(call.receive<FetchRequest>().url) }.getOrElse {
call.respond(HttpStatusCode.BadRequest)
return@post
}
call.respondText(client.get(target).firstBytes(5000))
}
post("/webhooks/test") {
val target = runCatching { checkedUrl(call.receive<FetchRequest>().url) }.getOrElse {
call.respond(HttpStatusCode.BadRequest)
return@post
}
val response = client.post(target) {
contentType(ContentType.Application.Json)
setBody("{\"ping\":true}")
}
call.respond(mapOf("status" to response.status.value))
}
}
Engineering & System Hardening Checklist
- ✓Add a Semgrep or Detekt rule that flags request data reaching Ktor
HttpClientcalls, OkHttpRequest.Builder().urlorjava.net.URL, and review every hit. - ✓Use the OkHttp engine with a
Dnsthat rejects loopback, link-local, site-local, any-local, CGNAT andfc00::/7, check IP-literal hosts with the same function, and setProxy.NO_PROXY. - ✓Parse URLs with
toHttpUrlOrNull(), which accepts onlyhttpandhttps, and setfollowRedirects = falseon the Ktor client and the OkHttp client. - ✓Install
HttpTimeoutand read only a fixed number of bytes from each response. - ✓Enforce IMDSv2 with a hop limit of 1 and restrict egress with a network policy or firewall as a second layer.
- ✓The address checks above are a baseline, not proof that every accepted destination is globally reachable. Maintain an IANA-informed special-purpose range policy, prefer an explicit host allowlist where feasible, and enforce egress restrictions independently.
Sources
- CWE-918: Server-Side Request Forgery (SSRF)
- Ktor — Client configuration (followRedirects)
- Ktor — OkHttp engine (preconfigured)
- Ktor — HttpTimeout plugin
- OkHttp — Dns interface
- OWASP Server-Side Request Forgery Prevention Cheat Sheet
- IANA IPv4 Special-Purpose Address Registry
- IANA IPv6 Special-Purpose Address Registry