Skip to content

Commit 589f02d

Browse files
authored
KTOR-3019 Improve CORS plugin logging to ease troubleshooting (#5194)
1 parent 5a66b47 commit 589f02d

File tree

2 files changed

+109
-15
lines changed
  • ktor-server/ktor-server-plugins/ktor-server-cors/common/src/io/ktor/server/plugins/cors

2 files changed

+109
-15
lines changed

ktor-server/ktor-server-plugins/ktor-server-cors/common/src/io/ktor/server/plugins/cors/CORS.kt

Lines changed: 51 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ import io.ktor.server.response.*
1212
import io.ktor.util.*
1313
import io.ktor.util.logging.*
1414

15-
private val LOGGER = KtorSimpleLogger("io.ktor.server.plugins.cors.CORS")
15+
internal val LOGGER = KtorSimpleLogger("io.ktor.server.plugins.cors.CORS")
1616

1717
/**
1818
* A plugin that allows you to configure handling cross-origin requests.
@@ -87,19 +87,27 @@ internal fun PluginBuilder<CORSConfig>.buildPlugin() {
8787
return@onCall
8888
}
8989

90+
LOGGER.info { "${call.request.id()}: Start handler" }
91+
9092
if (!allowsAnyHost || allowCredentials) {
9193
call.corsVary()
9294
}
9395

94-
val origin = call.request.headers.getAll(HttpHeaders.Origin)?.singleOrNull() ?: return@onCall
96+
val origin = call.request.headers.getAll(HttpHeaders.Origin)?.singleOrNull()
97+
98+
if (origin == null) {
99+
LOGGER.info { "${call.request.id()}: Skip CORS handler because request lacks the Origin header" }
100+
return@onCall
101+
}
95102

96103
val checkOrigin = checkOrigin(
97104
origin,
98-
call.request.origin,
105+
call.request,
99106
allowSameOrigin,
100107
allowsAnyHost,
101108
hostsNormalized,
102109
hostsWithWildcard,
110+
pluginConfig.hosts,
103111
originPredicates
104112
)
105113
when (checkOrigin) {
@@ -108,7 +116,7 @@ internal fun PluginBuilder<CORSConfig>.buildPlugin() {
108116

109117
OriginCheckResult.SkipCORS -> return@onCall
110118
OriginCheckResult.Failed -> {
111-
LOGGER.trace("Respond forbidden ${call.request.uri}: origin doesn't match ${call.request.origin}")
119+
LOGGER.info { "${call.request.id()}: CORS check fails because Origin $origin does not match" }
112120
call.respondCorsFailed()
113121
return@onCall
114122
}
@@ -118,15 +126,18 @@ internal fun PluginBuilder<CORSConfig>.buildPlugin() {
118126
val contentType = call.request.header(HttpHeaders.ContentType)?.let { ContentType.parse(it) }
119127
if (contentType != null) {
120128
if (contentType.withoutParameters() !in CORSConfig.CorsSimpleContentTypes) {
121-
LOGGER.trace("Respond forbidden ${call.request.uri}: Content-Type isn't allowed $contentType")
129+
LOGGER.info {
130+
"${call.request.id()}: CORS check fails because the requested content type $contentType " +
131+
"is not in the list of the only allowed simple types ${CORSConfig.CorsSimpleContentTypes}"
132+
}
122133
call.respondCorsFailed()
123134
return@onCall
124135
}
125136
}
126137
}
127138

128139
if (call.request.httpMethod == HttpMethod.Options) {
129-
LOGGER.trace("Respond preflight on OPTIONS for ${call.request.uri}")
140+
LOGGER.info { "${call.request.id()}: Start preflight handler" }
130141
call.respondPreflight(
131142
origin,
132143
methodsListHeaderValue,
@@ -142,11 +153,16 @@ internal fun PluginBuilder<CORSConfig>.buildPlugin() {
142153
}
143154

144155
if (!call.corsCheckCurrentMethod(methods)) {
145-
LOGGER.trace("Respond forbidden ${call.request.uri}: method doesn't match ${call.request.httpMethod}")
156+
LOGGER.info {
157+
"${call.request.id()}: CORS check fails because HTTP method ${call.request.httpMethod.value} " +
158+
"is not allowed. Allowed methods: $methods"
159+
}
146160
call.respondCorsFailed()
147161
return@onCall
148162
}
149163

164+
LOGGER.info { "${call.request.id()}: CORS check is succeeded" }
165+
150166
call.accessControlAllowOrigin(origin, allowsAnyHost, allowCredentials)
151167
call.accessControlAllowCredentials(allowCredentials)
152168

@@ -156,6 +172,10 @@ internal fun PluginBuilder<CORSConfig>.buildPlugin() {
156172
}
157173
}
158174

175+
internal fun ApplicationRequest.id(): String {
176+
return "${httpMethod.value} $uri"
177+
}
178+
159179
private enum class OriginCheckResult {
160180
OK,
161181
SkipCORS,
@@ -164,20 +184,29 @@ private enum class OriginCheckResult {
164184

165185
private fun checkOrigin(
166186
origin: String,
167-
point: RequestConnectionPoint,
187+
request: ApplicationRequest,
168188
allowSameOrigin: Boolean,
169189
allowsAnyHost: Boolean,
170190
hostsNormalized: Set<String>,
171191
hostsWithWildcard: Set<Pair<String, String>>,
192+
allowedHosts: Set<String>,
172193
originPredicates: List<(String) -> Boolean>
173194
): OriginCheckResult = when {
174-
!isValidOrigin(origin) -> OriginCheckResult.SkipCORS
175-
allowSameOrigin && isSameOrigin(origin, point) -> OriginCheckResult.SkipCORS
195+
!isValidOrigin(origin) -> {
196+
LOGGER.info { "${request.id()}: Skip CORS handler because Origin $origin is malformed" }
197+
OriginCheckResult.SkipCORS
198+
}
199+
allowSameOrigin && isSameOrigin(origin, request.origin) -> {
200+
LOGGER.info { "${request.id()}: Skip CORS handler because Origin $origin matches the server origin exactly" }
201+
OriginCheckResult.SkipCORS
202+
}
176203
!corsCheckOrigins(
204+
request,
177205
origin,
178206
allowsAnyHost,
179207
hostsNormalized,
180208
hostsWithWildcard,
209+
allowedHosts,
181210
originPredicates
182211
) -> OriginCheckResult.Failed
183212

@@ -204,13 +233,23 @@ private suspend fun ApplicationCall.respondPreflight(
204233
} ?: emptyList()
205234

206235
if (!corsCheckRequestMethod(methods)) {
207-
LOGGER.trace("Return Forbidden for ${this.request.uri}: CORS method doesn't match ${request.httpMethod}")
236+
LOGGER.info { "${request.id()}: Preflight: Request method check fails" }
208237
respond(HttpStatusCode.Forbidden)
209238
return
210239
}
211240

212241
if (!corsCheckRequestHeaders(requestHeaders, allHeadersSet, headerPredicates)) {
213-
LOGGER.trace("Return Forbidden for ${this.request.uri}: request has not allowed headers.")
242+
LOGGER.info {
243+
val disallowedHeaders = requestHeaders.filterNot { header ->
244+
header in allHeadersSet || headerMatchesAPredicate(header, headerPredicates)
245+
}
246+
247+
"${request.id()}: Preflight: Headers $disallowedHeaders are not allowed. " +
248+
"Allowed headers: $allHeadersSet. " +
249+
if (headerPredicates.isNotEmpty()) "Allowed Header predicates: $headerPredicates" else ""
250+
}
251+
252+
LOGGER.info { "${request.id()}: Preflight: Check on headers from Access-Control-Request-Headers fails" }
214253
respond(HttpStatusCode.Forbidden)
215254
return
216255
}

ktor-server/ktor-server-plugins/ktor-server-cors/common/src/io/ktor/server/plugins/cors/CORSUtils.kt

Lines changed: 58 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import io.ktor.http.*
88
import io.ktor.server.application.*
99
import io.ktor.server.request.*
1010
import io.ktor.server.response.*
11+
import io.ktor.util.logging.Logger
1112

1213
private val NUMBER_REGEX = "[0-9]+".toRegex()
1314

@@ -44,18 +45,56 @@ internal fun isSameOrigin(origin: String, point: RequestConnectionPoint): Boolea
4445
}
4546

4647
internal fun corsCheckOrigins(
48+
request: ApplicationRequest,
4749
origin: String,
4850
allowsAnyHost: Boolean,
4951
hostsNormalized: Set<String>,
5052
hostsWithWildcard: Set<Pair<String, String>>,
53+
allowedHosts: Set<String>,
5154
originPredicates: List<(String) -> Boolean>,
5255
): Boolean {
5356
val normalizedOrigin = normalizeOrigin(origin)
54-
return allowsAnyHost ||
57+
58+
val matchWildcardHosts = hostsWithWildcard
59+
.any { (prefix, suffix) -> normalizedOrigin.startsWith(prefix) && normalizedOrigin.endsWith(suffix) }
60+
61+
val allow = allowsAnyHost ||
5562
normalizedOrigin in hostsNormalized ||
56-
hostsWithWildcard
57-
.any { (prefix, suffix) -> normalizedOrigin.startsWith(prefix) && normalizedOrigin.endsWith(suffix) } ||
63+
matchWildcardHosts ||
5864
originPredicates.any { it(origin) }
65+
66+
if (!allow) {
67+
LOGGER.info { "${request.id()}: Any * host is not allowed" }
68+
LOGGER.info { "${request.id()}: Origin $normalizedOrigin does not match allowed hosts: $allowedHosts" }
69+
if (originPredicates.isNotEmpty()) {
70+
LOGGER.info {
71+
"${request.id()}: Origin $normalizedOrigin fulfills no allowed hosts predicates $originPredicates"
72+
}
73+
}
74+
} else {
75+
when {
76+
allowsAnyHost ->
77+
LOGGER.info { "${request.id()}: Any * host is allowed" }
78+
normalizedOrigin in hostsNormalized ->
79+
LOGGER.info { "${request.id()}: Origin $normalizedOrigin is allowed from $hostsNormalized" }
80+
matchWildcardHosts ->
81+
LOGGER.info {
82+
val (prefix, suffix) = hostsWithWildcard
83+
.find { (prefix, suffix) ->
84+
normalizedOrigin.startsWith(prefix) && normalizedOrigin.endsWith(suffix)
85+
}!!
86+
"${request.id()}: Origin $normalizedOrigin matches wildcard host $prefix*$suffix"
87+
}
88+
originPredicates.any { it(origin) } -> {
89+
LOGGER.info {
90+
"${request.id()}: Origin $normalizedOrigin fulfills " +
91+
"host predicate ${originPredicates.find { it(origin) }}"
92+
}
93+
}
94+
}
95+
}
96+
97+
return allow
5998
}
6099

61100
internal fun corsCheckRequestHeaders(
@@ -73,6 +112,18 @@ internal fun ApplicationCall.corsCheckCurrentMethod(methods: Set<HttpMethod>): B
73112

74113
internal fun ApplicationCall.corsCheckRequestMethod(methods: Set<HttpMethod>): Boolean {
75114
val requestMethod = request.header(HttpHeaders.AccessControlRequestMethod)?.let { HttpMethod(it) }
115+
val success = requestMethod != null && requestMethod in methods
116+
117+
if (!success) {
118+
LOGGER.info {
119+
if (requestMethod == null) {
120+
"${request.id()}: Preflight: The request header Access-Control-Request-Method is missing"
121+
} else {
122+
"${request.id()}: Preflight: Method ${requestMethod.value} is not allowed. Allowed methods: $methods"
123+
}
124+
}
125+
}
126+
76127
return requestMethod != null && requestMethod in methods
77128
}
78129

@@ -135,3 +186,7 @@ internal fun normalizeOrigin(origin: String): String {
135186

136187
return builder.toString()
137188
}
189+
190+
internal inline fun Logger.info(message: () -> String) {
191+
info(message())
192+
}

0 commit comments

Comments
 (0)