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
|
## Using from Lyng scripts
|
||||||
|
|||||||
@@ -2,4 +2,4 @@ package net.sergeych.lyngio.http
|
|||||||
|
|
||||||
import io.ktor.client.engine.cio.CIO
|
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.lyng.requireScope
|
||||||
import net.sergeych.lyngio.http.LyngHttpRequest
|
import net.sergeych.lyngio.http.LyngHttpRequest
|
||||||
import net.sergeych.lyngio.http.LyngHttpResponse
|
import net.sergeych.lyngio.http.LyngHttpResponse
|
||||||
|
import net.sergeych.lyngio.http.LyngHttpConfig
|
||||||
import net.sergeych.lyngio.http.getSystemHttpEngine
|
import net.sergeych.lyngio.http.getSystemHttpEngine
|
||||||
import net.sergeych.lyngio.http.security.HttpAccessDeniedException
|
import net.sergeych.lyngio.http.security.HttpAccessDeniedException
|
||||||
import net.sergeych.lyngio.http.security.HttpAccessOp
|
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"
|
private const val HTTP_MODULE_NAME = "lyng.io.http"
|
||||||
internal const val HTTP_TYPES_MODULE_NAME = "lyng.io.http.types"
|
internal const val HTTP_TYPES_MODULE_NAME = "lyng.io.http.types"
|
||||||
|
|
||||||
fun createHttpModule(policy: HttpAccessPolicy, scope: Scope): Boolean =
|
fun createHttpModule(
|
||||||
createHttpModule(policy, scope.importManager)
|
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)
|
createHttpTypesModule(manager)
|
||||||
if (manager.packageNames.contains(HTTP_MODULE_NAME)) return false
|
if (manager.packageNames.contains(HTTP_MODULE_NAME)) return false
|
||||||
manager.addPackage(HTTP_MODULE_NAME) { module ->
|
manager.addPackage(HTTP_MODULE_NAME) { module ->
|
||||||
buildHttpModule(module, policy)
|
buildHttpModule(module, policy, config)
|
||||||
}
|
}
|
||||||
return true
|
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 {
|
internal fun createHttpTypesModule(manager: ImportManager): Boolean {
|
||||||
if (manager.packageNames.contains(HTTP_TYPES_MODULE_NAME)) return false
|
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)
|
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))
|
module.eval(Source(HTTP_MODULE_NAME, httpLyng))
|
||||||
val engine = getSystemHttpEngine()
|
val engine = getSystemHttpEngine(config)
|
||||||
|
|
||||||
val headersType = ObjHttpHeaders.type
|
val headersType = ObjHttpHeaders.type
|
||||||
val requestType = ObjHttpRequest.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(
|
internal fun createKtorHttpEngine(
|
||||||
engineFactory: HttpClientEngineFactory<HttpClientEngineConfig>,
|
engineFactory: HttpClientEngineFactory<HttpClientEngineConfig>,
|
||||||
): LyngHttpEngine = KtorLyngHttpEngine(engineFactory)
|
config: LyngHttpConfig,
|
||||||
|
): LyngHttpEngine = KtorLyngHttpEngine(engineFactory, config)
|
||||||
|
|
||||||
private class KtorLyngHttpEngine(
|
private class KtorLyngHttpEngine(
|
||||||
engineFactory: HttpClientEngineFactory<HttpClientEngineConfig>,
|
engineFactory: HttpClientEngineFactory<HttpClientEngineConfig>,
|
||||||
|
config: LyngHttpConfig,
|
||||||
) : LyngHttpEngine {
|
) : LyngHttpEngine {
|
||||||
private val clientResult = runCatching {
|
private val clientResult = runCatching {
|
||||||
HttpClient(engineFactory) {
|
HttpClient(engineFactory) {
|
||||||
expectSuccess = false
|
expectSuccess = false
|
||||||
|
followRedirects = false
|
||||||
|
install(CappedHttpRedirect) {
|
||||||
|
maxRedirects = config.maxRedirects
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -33,6 +33,18 @@ data class LyngHttpResponse(
|
|||||||
val bodyBytes: ByteArray,
|
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 {
|
interface LyngHttpEngine {
|
||||||
val isSupported: Boolean
|
val isSupported: Boolean
|
||||||
suspend fun request(request: LyngHttpRequest): LyngHttpResponse
|
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
|
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
|
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
|
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
|
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
|
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