Limit lyng.io HTTP redirect chains
This commit is contained in:
1 parent
4fa9b80592
commit
8b0d847776
12 files changed
+238
-16
No files matched your search
@@ -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)
|
||||
Reference in new issue
Block a user