diff --git a/docs/lyng.io.http.md b/docs/lyng.io.http.md index d998397..160b626 100644 --- a/docs/lyng.io.http.md +++ b/docs/lyng.io.http.md @@ -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 diff --git a/lyngio/src/androidMain/kotlin/net/sergeych/lyngio/http/PlatformAndroid.kt b/lyngio/src/androidMain/kotlin/net/sergeych/lyngio/http/PlatformAndroid.kt index 13df027..7d89e7b 100644 --- a/lyngio/src/androidMain/kotlin/net/sergeych/lyngio/http/PlatformAndroid.kt +++ b/lyngio/src/androidMain/kotlin/net/sergeych/lyngio/http/PlatformAndroid.kt @@ -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) diff --git a/lyngio/src/commonMain/kotlin/net/sergeych/lyng/io/http/LyngHttpModule.kt b/lyngio/src/commonMain/kotlin/net/sergeych/lyng/io/http/LyngHttpModule.kt index be48e15..96784a6 100644 --- a/lyngio/src/commonMain/kotlin/net/sergeych/lyng/io/http/LyngHttpModule.kt +++ b/lyngio/src/commonMain/kotlin/net/sergeych/lyng/io/http/LyngHttpModule.kt @@ -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 diff --git a/lyngio/src/commonMain/kotlin/net/sergeych/lyngio/http/CappedHttpRedirect.kt b/lyngio/src/commonMain/kotlin/net/sergeych/lyngio/http/CappedHttpRedirect.kt new file mode 100644 index 0000000..6362909 --- /dev/null +++ b/lyngio/src/commonMain/kotlin/net/sergeych/lyngio/http/CappedHttpRedirect.kt @@ -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 = 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 +} diff --git a/lyngio/src/commonMain/kotlin/net/sergeych/lyngio/http/KtorHttpEngine.kt b/lyngio/src/commonMain/kotlin/net/sergeych/lyngio/http/KtorHttpEngine.kt index ab0e9b5..a2803b4 100644 --- a/lyngio/src/commonMain/kotlin/net/sergeych/lyngio/http/KtorHttpEngine.kt +++ b/lyngio/src/commonMain/kotlin/net/sergeych/lyngio/http/KtorHttpEngine.kt @@ -14,14 +14,20 @@ import io.ktor.http.takeFrom internal fun createKtorHttpEngine( engineFactory: HttpClientEngineFactory, -): LyngHttpEngine = KtorLyngHttpEngine(engineFactory) + config: LyngHttpConfig, +): LyngHttpEngine = KtorLyngHttpEngine(engineFactory, config) private class KtorLyngHttpEngine( engineFactory: HttpClientEngineFactory, + config: LyngHttpConfig, ) : LyngHttpEngine { private val clientResult = runCatching { HttpClient(engineFactory) { expectSuccess = false + followRedirects = false + install(CappedHttpRedirect) { + maxRedirects = config.maxRedirects + } } } diff --git a/lyngio/src/commonMain/kotlin/net/sergeych/lyngio/http/LyngHttp.kt b/lyngio/src/commonMain/kotlin/net/sergeych/lyngio/http/LyngHttp.kt index a0da774..51a5c61 100644 --- a/lyngio/src/commonMain/kotlin/net/sergeych/lyngio/http/LyngHttp.kt +++ b/lyngio/src/commonMain/kotlin/net/sergeych/lyngio/http/LyngHttp.kt @@ -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 diff --git a/lyngio/src/darwinMain/kotlin/net/sergeych/lyngio/http/PlatformDarwin.kt b/lyngio/src/darwinMain/kotlin/net/sergeych/lyngio/http/PlatformDarwin.kt index 61b2cee..7f6c9d2 100644 --- a/lyngio/src/darwinMain/kotlin/net/sergeych/lyngio/http/PlatformDarwin.kt +++ b/lyngio/src/darwinMain/kotlin/net/sergeych/lyngio/http/PlatformDarwin.kt @@ -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) diff --git a/lyngio/src/jsMain/kotlin/net/sergeych/lyngio/http/PlatformJs.kt b/lyngio/src/jsMain/kotlin/net/sergeych/lyngio/http/PlatformJs.kt index 097c748..a8b7f7f 100644 --- a/lyngio/src/jsMain/kotlin/net/sergeych/lyngio/http/PlatformJs.kt +++ b/lyngio/src/jsMain/kotlin/net/sergeych/lyngio/http/PlatformJs.kt @@ -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) diff --git a/lyngio/src/jvmMain/kotlin/net/sergeych/lyngio/http/PlatformJvm.kt b/lyngio/src/jvmMain/kotlin/net/sergeych/lyngio/http/PlatformJvm.kt index 13df027..7d89e7b 100644 --- a/lyngio/src/jvmMain/kotlin/net/sergeych/lyngio/http/PlatformJvm.kt +++ b/lyngio/src/jvmMain/kotlin/net/sergeych/lyngio/http/PlatformJvm.kt @@ -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) diff --git a/lyngio/src/jvmTest/kotlin/net/sergeych/lyngio/http/KtorHttpRedirectTest.kt b/lyngio/src/jvmTest/kotlin/net/sergeych/lyngio/http/KtorHttpRedirectTest.kt new file mode 100644 index 0000000..020aded --- /dev/null +++ b/lyngio/src/jvmTest/kotlin/net/sergeych/lyngio/http/KtorHttpRedirectTest.kt @@ -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 { + 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) } + } +} diff --git a/lyngio/src/linuxMain/kotlin/net/sergeych/lyngio/http/PlatformLinux.kt b/lyngio/src/linuxMain/kotlin/net/sergeych/lyngio/http/PlatformLinux.kt index 0b1ba9c..a4985bb 100644 --- a/lyngio/src/linuxMain/kotlin/net/sergeych/lyngio/http/PlatformLinux.kt +++ b/lyngio/src/linuxMain/kotlin/net/sergeych/lyngio/http/PlatformLinux.kt @@ -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) diff --git a/lyngio/src/mingwMain/kotlin/net/sergeych/lyngio/http/PlatformMingw.kt b/lyngio/src/mingwMain/kotlin/net/sergeych/lyngio/http/PlatformMingw.kt index d8e1e0a..281aa86 100644 --- a/lyngio/src/mingwMain/kotlin/net/sergeych/lyngio/http/PlatformMingw.kt +++ b/lyngio/src/mingwMain/kotlin/net/sergeych/lyngio/http/PlatformMingw.kt @@ -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)