Limit lyng.io HTTP redirect chains

This commit is contained in:
sergeych committed 2026-08-18 13:39:25 +04:00
1 parent 4fa9b80592
commit 8b0d847776
12 files changed
+238 -16

No files matched your search

+15
View File
@@ -42,6 +42,21 @@ suspend fun bootstrapHttp() {
}
```
Redirects are followed transparently, up to 10 hops by default. Hosts can set a different
limit when installing the module:
```kotlin
createHttpModule(
PermitAllHttpAccessPolicy,
scope,
LyngHttpConfig(maxRedirects = 5),
)
```
Exceeding the limit raises an `HTTP_REDIRECT_LIMIT_EXCEEDED` error containing the configured
limit, initial URL, and last redirect target. HTTPS-to-HTTP redirects remain disabled, and the
`Authorization` header is removed when a redirect changes authority.
---
## Using from Lyng scripts
@@ -2,4 +2,4 @@ package net.sergeych.lyngio.http
import io.ktor.client.engine.cio.CIO
actual fun getSystemHttpEngine(): LyngHttpEngine = createKtorHttpEngine(CIO)
actual fun getSystemHttpEngine(config: LyngHttpConfig): LyngHttpEngine = createKtorHttpEngine(CIO, config)
@@ -41,6 +41,7 @@ import net.sergeych.lyng.requireNoArgs
import net.sergeych.lyng.requireScope
import net.sergeych.lyngio.http.LyngHttpRequest
import net.sergeych.lyngio.http.LyngHttpResponse
import net.sergeych.lyngio.http.LyngHttpConfig
import net.sergeych.lyngio.http.getSystemHttpEngine
import net.sergeych.lyngio.http.security.HttpAccessDeniedException
import net.sergeych.lyngio.http.security.HttpAccessOp
@@ -51,21 +52,36 @@ import net.sergeych.lyngio.stdlib_included.http_typesLyng
private const val HTTP_MODULE_NAME = "lyng.io.http"
internal const val HTTP_TYPES_MODULE_NAME = "lyng.io.http.types"
fun createHttpModule(policy: HttpAccessPolicy, scope: Scope): Boolean =
createHttpModule(policy, scope.importManager)
fun createHttpModule(
policy: HttpAccessPolicy,
scope: Scope,
config: LyngHttpConfig = LyngHttpConfig(),
): Boolean = createHttpModule(policy, scope.importManager, config)
fun createHttp(policy: HttpAccessPolicy, scope: Scope): Boolean = createHttpModule(policy, scope)
fun createHttp(
policy: HttpAccessPolicy,
scope: Scope,
config: LyngHttpConfig = LyngHttpConfig(),
): Boolean = createHttpModule(policy, scope, config)
fun createHttpModule(policy: HttpAccessPolicy, manager: ImportManager): Boolean {
fun createHttpModule(
policy: HttpAccessPolicy,
manager: ImportManager,
config: LyngHttpConfig = LyngHttpConfig(),
): Boolean {
createHttpTypesModule(manager)
if (manager.packageNames.contains(HTTP_MODULE_NAME)) return false
manager.addPackage(HTTP_MODULE_NAME) { module ->
buildHttpModule(module, policy)
buildHttpModule(module, policy, config)
}
return true
}
fun createHttp(policy: HttpAccessPolicy, manager: ImportManager): Boolean = createHttpModule(policy, manager)
fun createHttp(
policy: HttpAccessPolicy,
manager: ImportManager,
config: LyngHttpConfig = LyngHttpConfig(),
): Boolean = createHttpModule(policy, manager, config)
internal fun createHttpTypesModule(manager: ImportManager): Boolean {
if (manager.packageNames.contains(HTTP_TYPES_MODULE_NAME)) return false
@@ -80,9 +96,9 @@ private suspend fun buildHttpTypesModule(module: ModuleScope) {
module.addConst("HttpHeaders", ObjHttpHeaders.type)
}
private suspend fun buildHttpModule(module: ModuleScope, policy: HttpAccessPolicy) {
private suspend fun buildHttpModule(module: ModuleScope, policy: HttpAccessPolicy, config: LyngHttpConfig) {
module.eval(Source(HTTP_MODULE_NAME, httpLyng))
val engine = getSystemHttpEngine()
val engine = getSystemHttpEngine(config)
val headersType = ObjHttpHeaders.type
val requestType = ObjHttpRequest.type
@@ -0,0 +1,100 @@
@file:OptIn(io.ktor.utils.io.InternalAPI::class)
package net.sergeych.lyngio.http
import io.ktor.client.HttpClient
import io.ktor.client.call.HttpClientCall
import io.ktor.client.plugins.HttpResponseRedirectEvent
import io.ktor.client.plugins.api.ClientPlugin
import io.ktor.client.plugins.api.Send
import io.ktor.client.plugins.api.createClientPlugin
import io.ktor.client.request.HttpRequestBuilder
import io.ktor.http.HttpHeaders
import io.ktor.http.HttpMethod
import io.ktor.http.HttpStatusCode
import io.ktor.http.authority
import io.ktor.http.isSecure
import io.ktor.http.takeFrom
private val redirectMethods = setOf(HttpMethod.Get, HttpMethod.Head)
internal class HttpRedirectLimitExceededException(
val limit: Int,
val initialUrl: String,
val lastUrl: String,
) : IllegalStateException(
"HTTP_REDIRECT_LIMIT_EXCEEDED: limit=$limit, initialUrl=$initialUrl, lastUrl=$lastUrl"
)
internal class CappedHttpRedirectConfig {
var maxRedirects: Int = LyngHttpConfig.DEFAULT_MAX_REDIRECTS
var checkHttpMethod: Boolean = true
var allowHttpsDowngrade: Boolean = false
}
internal val CappedHttpRedirect: ClientPlugin<CappedHttpRedirectConfig> = createClientPlugin(
"CappedHttpRedirect",
::CappedHttpRedirectConfig,
) {
val maxRedirects = pluginConfig.maxRedirects.also {
require(it >= 0) { "maxRedirects must not be negative" }
}
val checkHttpMethod = pluginConfig.checkHttpMethod
val allowHttpsDowngrade = pluginConfig.allowHttpsDowngrade
suspend fun Send.Sender.followRedirects(
context: HttpRequestBuilder,
origin: HttpClientCall,
client: HttpClient,
): HttpClientCall {
var call = origin
var requestBuilder = context
val initialUrl = origin.request.url.toString()
var redirectCount = 0
while (call.response.status.isRedirectStatus()) {
val location = call.response.headers[HttpHeaders.Location] ?: return call
val previousProtocol = call.request.url.protocol
val previousAuthority = call.request.url.authority
requestBuilder = HttpRequestBuilder().apply {
takeFrom(requestBuilder)
url.parameters.clear()
url.takeFrom(location)
if (!allowHttpsDowngrade && previousProtocol.isSecure() && !url.protocol.isSecure()) {
return call
}
if (previousAuthority != url.authority) {
headers.remove(HttpHeaders.Authorization)
}
}
if (redirectCount >= maxRedirects) {
throw HttpRedirectLimitExceededException(
maxRedirects,
initialUrl,
requestBuilder.url.buildString(),
)
}
redirectCount += 1
client.monitor.raise(HttpResponseRedirectEvent, call.response)
call = proceed(requestBuilder)
}
return call
}
on(Send) { request ->
val origin = proceed(request)
if (checkHttpMethod && origin.request.method !in redirectMethods) {
return@on origin
}
followRedirects(request, origin, client)
}
}
private fun HttpStatusCode.isRedirectStatus(): Boolean = when (value) {
HttpStatusCode.MovedPermanently.value,
HttpStatusCode.Found.value,
HttpStatusCode.TemporaryRedirect.value,
HttpStatusCode.PermanentRedirect.value,
HttpStatusCode.SeeOther.value -> true
else -> false
}
@@ -14,14 +14,20 @@ import io.ktor.http.takeFrom
internal fun createKtorHttpEngine(
engineFactory: HttpClientEngineFactory<HttpClientEngineConfig>,
): LyngHttpEngine = KtorLyngHttpEngine(engineFactory)
config: LyngHttpConfig,
): LyngHttpEngine = KtorLyngHttpEngine(engineFactory, config)
private class KtorLyngHttpEngine(
engineFactory: HttpClientEngineFactory<HttpClientEngineConfig>,
config: LyngHttpConfig,
) : LyngHttpEngine {
private val clientResult = runCatching {
HttpClient(engineFactory) {
expectSuccess = false
followRedirects = false
install(CappedHttpRedirect) {
maxRedirects = config.maxRedirects
}
}
}
@@ -33,6 +33,18 @@ data class LyngHttpResponse(
val bodyBytes: ByteArray,
)
data class LyngHttpConfig(
val maxRedirects: Int = DEFAULT_MAX_REDIRECTS,
) {
init {
require(maxRedirects >= 0) { "maxRedirects must not be negative" }
}
companion object {
const val DEFAULT_MAX_REDIRECTS: Int = 10
}
}
interface LyngHttpEngine {
val isSupported: Boolean
suspend fun request(request: LyngHttpRequest): LyngHttpResponse
@@ -46,4 +58,4 @@ internal object UnsupportedHttpEngine : LyngHttpEngine {
}
}
expect fun getSystemHttpEngine(): LyngHttpEngine
expect fun getSystemHttpEngine(config: LyngHttpConfig = LyngHttpConfig()): LyngHttpEngine
@@ -2,4 +2,4 @@ package net.sergeych.lyngio.http
import io.ktor.client.engine.darwin.Darwin
actual fun getSystemHttpEngine(): LyngHttpEngine = createKtorHttpEngine(Darwin)
actual fun getSystemHttpEngine(config: LyngHttpConfig): LyngHttpEngine = createKtorHttpEngine(Darwin, config)
@@ -2,4 +2,4 @@ package net.sergeych.lyngio.http
import io.ktor.client.engine.js.Js
actual fun getSystemHttpEngine(): LyngHttpEngine = createKtorHttpEngine(Js)
actual fun getSystemHttpEngine(config: LyngHttpConfig): LyngHttpEngine = createKtorHttpEngine(Js, config)
@@ -2,4 +2,4 @@ package net.sergeych.lyngio.http
import io.ktor.client.engine.cio.CIO
actual fun getSystemHttpEngine(): LyngHttpEngine = createKtorHttpEngine(CIO)
actual fun getSystemHttpEngine(config: LyngHttpConfig): LyngHttpEngine = createKtorHttpEngine(CIO, config)
@@ -0,0 +1,73 @@
package net.sergeych.lyngio.http
import com.sun.net.httpserver.HttpExchange
import com.sun.net.httpserver.HttpServer
import io.ktor.client.engine.cio.CIO
import kotlinx.coroutines.runBlocking
import java.net.InetSocketAddress
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFailsWith
import kotlin.test.assertTrue
class KtorHttpRedirectTest {
@Test
fun redirectsWithinLimitRemainTransparent() = runBlocking {
val server = newServer { exchange ->
when (exchange.requestURI.path) {
"/start" -> redirect(exchange, "/middle")
"/middle" -> redirect(exchange, "/result")
else -> writeResponse(exchange, 200, "redirected")
}
}
try {
val engine = createKtorHttpEngine(CIO, LyngHttpConfig(maxRedirects = 2))
val response = engine.request(LyngHttpRequest("GET", url(server, "/start")))
assertEquals(200, response.status)
assertEquals("redirected", response.bodyBytes.decodeToString())
} finally {
server.stop(0)
}
}
@Test
fun redirectLimitProducesDiagnosticError() = runBlocking {
val server = newServer { exchange ->
val next = exchange.requestURI.path.removePrefix("/").toIntOrNull()?.plus(1) ?: 1
redirect(exchange, "/$next")
}
try {
val engine = createKtorHttpEngine(CIO, LyngHttpConfig(maxRedirects = 2))
val error = assertFailsWith<HttpRedirectLimitExceededException> {
engine.request(LyngHttpRequest("GET", url(server, "/0")))
}
assertEquals(2, error.limit)
assertTrue(error.initialUrl.endsWith("/0"), error.initialUrl)
assertTrue(error.lastUrl.endsWith("/3"), error.lastUrl)
assertTrue(error.message.orEmpty().startsWith("HTTP_REDIRECT_LIMIT_EXCEEDED:"), error.message)
} finally {
server.stop(0)
}
}
private fun newServer(handler: (HttpExchange) -> Unit): HttpServer =
HttpServer.create(InetSocketAddress("127.0.0.1", 0), 0).apply {
createContext("/") { exchange -> handler(exchange) }
start()
}
private fun url(server: HttpServer, path: String) =
"http://127.0.0.1:${server.address.port}$path"
private fun redirect(exchange: HttpExchange, location: String) {
exchange.responseHeaders.add("Location", location)
exchange.sendResponseHeaders(302, -1)
exchange.close()
}
private fun writeResponse(exchange: HttpExchange, status: Int, body: String) {
val bytes = body.encodeToByteArray()
exchange.sendResponseHeaders(status, bytes.size.toLong())
exchange.responseBody.use { it.write(bytes) }
}
}
@@ -2,4 +2,4 @@ package net.sergeych.lyngio.http
import io.ktor.client.engine.curl.Curl
actual fun getSystemHttpEngine(): LyngHttpEngine = createKtorHttpEngine(Curl)
actual fun getSystemHttpEngine(config: LyngHttpConfig): LyngHttpEngine = createKtorHttpEngine(Curl, config)
@@ -2,4 +2,4 @@ package net.sergeych.lyngio.http
import io.ktor.client.engine.winhttp.WinHttp
actual fun getSystemHttpEngine(): LyngHttpEngine = createKtorHttpEngine(WinHttp)
actual fun getSystemHttpEngine(config: LyngHttpConfig): LyngHttpEngine = createKtorHttpEngine(WinHttp, config)