Repository: tipsy/javalin
Pull request #1053 (2020-08-31 07:48:54)

Added ignoreTrailingSlashes options (FR #1050)
Currently true by default, maybe false as it's seem to follow the uri spec (https://tools.ietf.org/html/rfc3986#section-6.2.4)
(@tipsy May check how it's handle in OpenApiHandler, can't find config here, and set param as true in hard)


Patch:
diff --git a/README.md b/README.md
index 5bca8249..d550a3f4 100644
--- a/README.md
+++ b/README.md
@@ -31,14 +31,14 @@ General information:
 <dependency>
     <groupId>io.javalin</groupId>
     <artifactId>javalin</artifactId>
-    <version>3.10.0</version>
+    <version>3.10.1</version>
 </dependency>
 ```
 
 #### Gradle
 
 ```groovy
-compile "io.javalin:javalin:3.10.0"
+compile "io.javalin:javalin:3.10.1"
 ```
 
 ### Start programming (Java)
diff --git a/javalin-bundle/pom.xml b/javalin-bundle/pom.xml
index c71a3cc0..5e0ac891 100644
--- a/javalin-bundle/pom.xml
+++ b/javalin-bundle/pom.xml
@@ -3,7 +3,7 @@
     <parent>
         <artifactId>javalin-parent</artifactId>
         <groupId>io.javalin</groupId>
-        <version>3.10.1-SNAPSHOT</version>
+        <version>3.10.2-SNAPSHOT</version>
     </parent>
     <modelVersion>4.0.0</modelVersion>
 
diff --git a/javalin-graphql/pom.xml b/javalin-graphql/pom.xml
index e02f152c..ecb68fc1 100644
--- a/javalin-graphql/pom.xml
+++ b/javalin-graphql/pom.xml
@@ -3,7 +3,7 @@
     <parent>
         <artifactId>javalin-parent</artifactId>
         <groupId>io.javalin</groupId>
-        <version>3.10.1-SNAPSHOT</version>
+        <version>3.10.2-SNAPSHOT</version>
     </parent>
     <modelVersion>4.0.0</modelVersion>
 
diff --git a/javalin-openapi/pom.xml b/javalin-openapi/pom.xml
index 7caf05ea..21c91405 100644
--- a/javalin-openapi/pom.xml
+++ b/javalin-openapi/pom.xml
@@ -3,7 +3,7 @@
     <parent>
         <artifactId>javalin-parent</artifactId>
         <groupId>io.javalin</groupId>
-        <version>3.10.1-SNAPSHOT</version>
+        <version>3.10.2-SNAPSHOT</version>
     </parent>
     <modelVersion>4.0.0</modelVersion>
 
diff --git a/javalin-openapi/src/main/java/io/javalin/plugin/openapi/OpenApiHandler.kt b/javalin-openapi/src/main/java/io/javalin/plugin/openapi/OpenApiHandler.kt
index 12243733..a51e6df8 100644
--- a/javalin-openapi/src/main/java/io/javalin/plugin/openapi/OpenApiHandler.kt
+++ b/javalin-openapi/src/main/java/io/javalin/plugin/openapi/OpenApiHandler.kt
@@ -37,7 +37,7 @@ class OpenApiHandler(app: Javalin, val options: OpenApiOptions) : Handler {
                 CreateSchemaOptions(
                         handlerMetaInfoList = handlerMetaInfoList.filter { handler ->
                             options.ignoredPaths.none { (path, methods) ->
-                                PathParser(path).matches(handler.path) && methods.any { method ->
+                                PathParser(path, true).matches(handler.path) && methods.any { method ->
                                     // HttpMethod is implemented two times :(
                                     method.name == handler.httpMethod.name
                                 }
diff --git a/javalin-openapi/src/main/java/io/javalin/plugin/openapi/dsl/openApiBuilderDsl.kt b/javalin-openapi/src/main/java/io/javalin/plugin/openapi/dsl/openApiBuilderDsl.kt
index d4231ecd..190ee40b 100644
--- a/javalin-openapi/src/main/java/io/javalin/plugin/openapi/dsl/openApiBuilderDsl.kt
+++ b/javalin-openapi/src/main/java/io/javalin/plugin/openapi/dsl/openApiBuilderDsl.kt
@@ -27,7 +27,7 @@ fun overridePaths(
 ): List<HandlerMetaInfo> {
     return overridenPaths.plus(handlerMetaInfoList.filter { handler ->
         overridenPaths.none { overridenHandler ->
-            PathParser(overridenHandler.path).matches(handler.path) && overridenHandler.httpMethod == handler.httpMethod
+            PathParser(overridenHandler.path, true).matches(handler.path) && overridenHandler.httpMethod == handler.httpMethod
         }
     })
 }
@@ -45,7 +45,7 @@ fun Paths.applyMetaInfoList(handlerMetaInfoList: List<HandlerMetaInfo>, options:
             .filter { it.extractDocumentation(options).isIgnored != true }
             .groupBy { it.path }
             .forEach { (url, metaInfos) ->
-                val pathParser = PathParser(url)
+                val pathParser = PathParser(url, true)
                 updatePath(pathParser.getOpenApiUrl()) {
                     applyMetaInfoList(options, pathParser, metaInfos)
                 }
diff --git a/javalin-without-jetty/pom.xml b/javalin-without-jetty/pom.xml
index ac0ef2ab..ad67de2e 100644
--- a/javalin-without-jetty/pom.xml
+++ b/javalin-without-jetty/pom.xml
@@ -3,7 +3,7 @@
     <parent>
         <artifactId>javalin-parent</artifactId>
         <groupId>io.javalin</groupId>
-        <version>3.10.1-SNAPSHOT</version>
+        <version>3.10.2-SNAPSHOT</version>
     </parent>
     <modelVersion>4.0.0</modelVersion>
 
diff --git a/javalin/pom.xml b/javalin/pom.xml
index 9fb94e4c..2a6462cf 100644
--- a/javalin/pom.xml
+++ b/javalin/pom.xml
@@ -3,7 +3,7 @@
     <parent>
         <artifactId>javalin-parent</artifactId>
         <groupId>io.javalin</groupId>
-        <version>3.10.1-SNAPSHOT</version>
+        <version>3.10.2-SNAPSHOT</version>
     </parent>
     <modelVersion>4.0.0</modelVersion>
 
diff --git a/javalin/src/main/java/io/javalin/core/JavalinConfig.java b/javalin/src/main/java/io/javalin/core/JavalinConfig.java
index 489a335e..f4bdd05c 100644
--- a/javalin/src/main/java/io/javalin/core/JavalinConfig.java
+++ b/javalin/src/main/java/io/javalin/core/JavalinConfig.java
@@ -51,6 +51,7 @@ public class JavalinConfig {
     public boolean precompressStaticFiles = false;
     public boolean showJavalinBanner = true;
     public boolean logIfServerNotStarted = true;
+    public boolean ignoreTrailingSlashes = true;
     @NotNull public String defaultContentType = "text/plain";
     @NotNull public String contextPath = "/";
     @NotNull public Long requestCacheSize = 4096L;
diff --git a/javalin/src/main/java/io/javalin/core/PathParser.kt b/javalin/src/main/java/io/javalin/core/PathParser.kt
index 7700973f..5263b71b 100644
--- a/javalin/src/main/java/io/javalin/core/PathParser.kt
+++ b/javalin/src/main/java/io/javalin/core/PathParser.kt
@@ -8,10 +8,10 @@ package io.javalin.core
 
 import io.javalin.http.util.ContextUtil
 
-class PathParser(path: String) {
+class PathParser(path: String, ignoreTrailingSlashes: Boolean) {
 
     val segments: List<PathSegment> = path.split("/")
-            .filter { it.isNotEmpty() }
+            .filter { !ignoreTrailingSlashes || it.isNotEmpty() } //if not ignoreTrailingSlashes process every token, else only process not empty token
             .map {
                 when {
                     it.startsWith(":") -> PathSegment.Parameter(it.removePrefix(":"))
diff --git a/javalin/src/main/java/io/javalin/core/util/ReflectionUtil.kt b/javalin/src/main/java/io/javalin/core/util/ReflectionUtil.kt
index 8ddafc1d..d796f55c 100644
--- a/javalin/src/main/java/io/javalin/core/util/ReflectionUtil.kt
+++ b/javalin/src/main/java/io/javalin/core/util/ReflectionUtil.kt
@@ -1,5 +1,6 @@
 package io.javalin.core.util
 
+import io.javalin.apibuilder.CrudFunctionHandler
 import java.lang.reflect.Field
 import java.lang.reflect.Method
 
@@ -38,6 +39,8 @@ internal val Any.isClass: Boolean get() = this is Class<*>
 
 internal val Any.isKotlinAnonymousLambda: Boolean get() = this.javaClass.enclosingMethod != null
 
+internal val Any.isCrudFunction: Boolean get() = (this is CrudFunctionHandler)
+
 internal val Any.isKotlinMethodReference: Boolean get() = this.javaClass.declaredFields.any { it.name == "function" }
 
 internal val Any.isKotlinField: Boolean get() = this.javaClass.fields.any { it.name == "INSTANCE" }
diff --git a/javalin/src/main/java/io/javalin/core/util/RouteOverviewUtil.kt b/javalin/src/main/java/io/javalin/core/util/RouteOverviewUtil.kt
index 98f9d029..c4200341 100644
--- a/javalin/src/main/java/io/javalin/core/util/RouteOverviewUtil.kt
+++ b/javalin/src/main/java/io/javalin/core/util/RouteOverviewUtil.kt
@@ -7,6 +7,7 @@
 package io.javalin.core.util
 
 import io.javalin.Javalin
+import io.javalin.apibuilder.CrudFunctionHandler
 import io.javalin.core.event.HandlerMetaInfo
 import io.javalin.core.event.WsHandlerMetaInfo
 import io.javalin.core.security.Role
@@ -221,6 +222,7 @@ object RouteOverviewUtil {
             // this is just guesswork...
             return when {
                 isClass -> (this as Class<*>).name + ".class"
+                isCrudFunction -> "ApiBuilder#crud::${(this as CrudFunctionHandler).function.value}"
                 isKotlinMethodReference -> {
                     val f = this.javaClass.getDeclaredField("function")
                             .apply { isAccessible = true }
diff --git a/javalin/src/main/java/io/javalin/http/JavalinServlet.kt b/javalin/src/main/java/io/javalin/http/JavalinServlet.kt
index 53ee428c..ea672f3b 100644
--- a/javalin/src/main/java/io/javalin/http/JavalinServlet.kt
+++ b/javalin/src/main/java/io/javalin/http/JavalinServlet.kt
@@ -119,7 +119,7 @@ class JavalinServlet(val config: JavalinConfig) : HttpServlet() {
 
     fun addHandler(handlerType: HandlerType, path: String, handler: Handler, roles: Set<Role>) {
         val protectedHandler = if (handlerType.isHttpMethod()) Handler { ctx -> config.inner.accessManager.manage(handler, ctx, roles) } else handler
-        matcher.add(HandlerEntry(handlerType, path, protectedHandler, handler))
+        matcher.add(HandlerEntry(handlerType, path, config.ignoreTrailingSlashes, protectedHandler, handler))
     }
 
     private fun isCorsEnabled(config: JavalinConfig) = config.inner.plugins[CorsPlugin::class.java] != null
diff --git a/javalin/src/main/java/io/javalin/http/PathMatcher.kt b/javalin/src/main/java/io/javalin/http/PathMatcher.kt
index e207faa0..510fb10a 100644
--- a/javalin/src/main/java/io/javalin/http/PathMatcher.kt
+++ b/javalin/src/main/java/io/javalin/http/PathMatcher.kt
@@ -9,13 +9,13 @@ package io.javalin.http
 import io.javalin.core.PathParser
 import java.util.*
 
-data class HandlerEntry(val type: HandlerType, val path: String, val handler: Handler, val rawHandler: Handler) {
-    private val pathParser = PathParser(path)
+data class HandlerEntry(val type: HandlerType, val path: String, val ignoreTrailingSlashes: Boolean, val handler: Handler, val rawHandler: Handler) {
+    private val pathParser = PathParser(path, ignoreTrailingSlashes)
     fun matches(requestUri: String) = pathParser.matches(requestUri)
     fun extractPathParams(requestUri: String) = pathParser.extractPathParams(requestUri)
 }
 
-class PathMatcher {
+class PathMatcher() {
 
     private val handlerEntries = HandlerType.values().associateTo(EnumMap<HandlerType, ArrayList<HandlerEntry>>(HandlerType::class.java)) {
         it to arrayListOf()
diff --git a/javalin/src/main/java/io/javalin/http/util/RedirectToLowercasePathPlugin.kt b/javalin/src/main/java/io/javalin/http/util/RedirectToLowercasePathPlugin.kt
index 5b398136..bc946fdd 100644
--- a/javalin/src/main/java/io/javalin/http/util/RedirectToLowercasePathPlugin.kt
+++ b/javalin/src/main/java/io/javalin/http/util/RedirectToLowercasePathPlugin.kt
@@ -25,7 +25,7 @@ class RedirectToLowercasePathPlugin : Plugin, PluginLifecycleInit {
     override fun init(app: Javalin) {
         app.events { e ->
             e.handlerAdded { h ->
-                PathParser(h.path).segments.filterIsInstance<PathSegment.Normal>().map { it.asRegexString() }.forEach {
+                PathParser(h.path, app.config.ignoreTrailingSlashes).segments.filterIsInstance<PathSegment.Normal>().map { it.asRegexString() }.forEach {
                     if (it != it.toLowerCase()) throw IllegalArgumentException("Paths must be lowercase when using RedirectToLowercasePathPlugin")
                 }
             }
@@ -42,7 +42,7 @@ class RedirectToLowercasePathPlugin : Plugin, PluginLifecycleInit {
             }
             matcher.findEntries(type, requestUri.toLowerCase()).firstOrNull()?.let { entry ->
                 val clientSegments = requestUri.split("/").filter { it.isNotEmpty() }.toTypedArray()
-                val serverSegments = PathParser(entry.path).segments
+                val serverSegments = PathParser(entry.path, app.config.ignoreTrailingSlashes).segments
                 serverSegments.forEachIndexed { i, _ ->
                     if (serverSegments[i] is PathSegment.Normal) {
                         clientSegments[i] = clientSegments[i].toLowerCase() // this is also a "Normal" segment
diff --git a/javalin/src/main/java/io/javalin/plugin/rendering/vue/JavalinVue.kt b/javalin/src/main/java/io/javalin/plugin/rendering/vue/JavalinVue.kt
index 723eddb6..cfa3044c 100644
--- a/javalin/src/main/java/io/javalin/plugin/rendering/vue/JavalinVue.kt
+++ b/javalin/src/main/java/io/javalin/plugin/rendering/vue/JavalinVue.kt
@@ -55,8 +55,8 @@ object JavalinVue {
 
     internal fun getState(ctx: Context, state: Any?) = "\n<script>\n" + """
         |    Vue.prototype.${"$"}javalin = {
-        |        pathParams: ${JavalinJson.toJson(ctx.pathParamMap())},
-        |        queryParams: ${JavalinJson.toJson(ctx.queryParamMap())},
+        |        pathParams: ${JavalinJson.toJson(ctx.pathParamMap().mapKeys { escape(it.key) }.mapValues { escape(it.value) })},
+        |        queryParams: ${JavalinJson.toJson(ctx.queryParamMap().mapKeys { escape(it.key) }.mapValues { it.value.map { escape(it) } })},
         |        state: ${JavalinJson.toJson(state ?: stateFunction(ctx))}
         |    }""".trimMargin() + "\n</script>\n"
 
@@ -103,3 +103,15 @@ object PathMaster {
 }
 
 fun Path.readText() = String(Files.readAllBytes(this))
+
+fun escape(string: String?) = string?.toCharArray()?.map {
+    when (it) {
+        '<' -> "&lt;"
+        '>' -> "&gt;"
+        '&' -> "&amp;"
+        '"' -> "&quot;"
+        '\'' -> "&#x27;"
+        '/' -> "&#x2F;"
+        else -> it
+    }
+}?.joinToString("")
diff --git a/javalin/src/main/java/io/javalin/websocket/JavalinWsServlet.kt b/javalin/src/main/java/io/javalin/websocket/JavalinWsServlet.kt
index 812d9f1a..4b6632de 100644
--- a/javalin/src/main/java/io/javalin/websocket/JavalinWsServlet.kt
+++ b/javalin/src/main/java/io/javalin/websocket/JavalinWsServlet.kt
@@ -76,7 +76,7 @@ class JavalinWsServlet(val config: JavalinConfig, private val httpServlet: Javal
     }
 
     fun addHandler(handlerType: WsHandlerType, path: String, ws: Consumer<WsHandler>, permittedRoles: Set<Role>) {
-        wsPathMatcher.add(WsEntry(handlerType, path, WsHandler().apply { ws.accept(this) }, permittedRoles))
+        wsPathMatcher.add(WsEntry(handlerType, path, config.ignoreTrailingSlashes, WsHandler().apply { ws.accept(this) }, permittedRoles))
     }
 }
 
diff --git a/javalin/src/main/java/io/javalin/websocket/WsPathMatcher.kt b/javalin/src/main/java/io/javalin/websocket/WsPathMatcher.kt
index 66629cad..d90ea4b8 100644
--- a/javalin/src/main/java/io/javalin/websocket/WsPathMatcher.kt
+++ b/javalin/src/main/java/io/javalin/websocket/WsPathMatcher.kt
@@ -10,8 +10,8 @@ import io.javalin.core.PathParser
 import io.javalin.core.security.Role
 import java.util.*
 
-data class WsEntry(val type: WsHandlerType, val path: String, val handler: WsHandler, val permittedRoles: Set<Role>) {
-    private val pathParser = PathParser(path)
+data class WsEntry(val type: WsHandlerType, val path: String, val ignoreTrailingSlashes: Boolean, val handler: WsHandler, val permittedRoles: Set<Role>) {
+    private val pathParser = PathParser(path, ignoreTrailingSlashes)
     fun matches(path: String) = pathParser.matches(path)
     fun extractPathParams(path: String) = pathParser.extractPathParams(path)
 }
diff --git a/pom.xml b/pom.xml
index fd836d01..ddfada86 100644
--- a/pom.xml
+++ b/pom.xml
@@ -17,7 +17,7 @@
 
     <groupId>io.javalin</groupId>
     <artifactId>javalin-parent</artifactId>
-    <version>3.10.1-SNAPSHOT</version>
+    <version>3.10.2-SNAPSHOT</version>
 
     <name>Javalin (parent)</name>
     <description>Javalin: Simple REST APIs for Java and Kotlin</description>


Test patch:
diff --git a/javalin/src/test/java/io/javalin/TestJavalinVue.kt b/javalin/src/test/java/io/javalin/TestJavalinVue.kt
index a25add6d..ea695fae 100644
--- a/javalin/src/test/java/io/javalin/TestJavalinVue.kt
+++ b/javalin/src/test/java/io/javalin/TestJavalinVue.kt
@@ -76,6 +76,18 @@ class TestJavalinVue {
         assertThat(http.getBody("/unicode")).contains("<div>Test ÆØÅ</div>")
     }
 
+    @Test
+    fun `default params are escaped`() = TestUtil.test { app, http ->
+        val xss = "%3Cscript%3Ealert%281%29%3Cscript%3E"
+        app.get("/escaped", VueComponent("<test-component></test-component>"))
+        // keys
+        assertThat(http.getBody("/escaped?${xss}=value")).doesNotContain("<script>alert(1)<script>")
+        assertThat(http.getBody("/escaped?${xss}=value")).contains("&lt;script&gt;alert(1)&lt;script&gt;")
+        // values
+        assertThat(http.getBody("/escaped?key=${xss}")).doesNotContain("<script>alert(1)<script>")
+        assertThat(http.getBody("/escaped?key=${xss}")).contains("&lt;script&gt;alert(1)&lt;script&gt;")
+    }
+
     @Test
     fun `component shorthand works`() = TestUtil.test { app, http ->
         app.get("/shorthand", VueComponent("test-component"))
diff --git a/javalin/src/test/java/io/javalin/TestTrailingSlashes.kt b/javalin/src/test/java/io/javalin/TestTrailingSlashes.kt
index c10aeb85..d1d5c2e4 100644
--- a/javalin/src/test/java/io/javalin/TestTrailingSlashes.kt
+++ b/javalin/src/test/java/io/javalin/TestTrailingSlashes.kt
@@ -34,4 +34,15 @@ class TestTrailingSlashes {
         assertThat(http.getBody("/a/")).isEqualTo("a")
     }
 
+    @Test
+    fun `trailing slashes are treat as different url, if configuration is set - ApiBuilder`() {
+        val javalin = Javalin.create { it.ignoreTrailingSlashes = false; }
+        TestUtil.test(javalin) { app, http ->
+            app.get("/a") { ctx -> ctx.result("a") }
+            app.get("/a/") { ctx -> ctx.result("a-slash") }
+            assertThat(http.getBody("/a")).isEqualTo("a")
+            assertThat(http.getBody("/a/")).isEqualTo("a-slash")
+        }
+    }
+
 }
diff --git a/javalin/src/test/java/io/javalin/performance/RouteMatcherPerformanceTest.kt b/javalin/src/test/java/io/javalin/performance/RouteMatcherPerformanceTest.kt
index b1bb27e2..f37bbb9c 100644
--- a/javalin/src/test/java/io/javalin/performance/RouteMatcherPerformanceTest.kt
+++ b/javalin/src/test/java/io/javalin/performance/RouteMatcherPerformanceTest.kt
@@ -79,7 +79,7 @@ class RouteMatcherPerformanceTest {
         )
 
         val oldEntries = routes.map { OldHandlerEntry(HandlerType.AFTER, it, Handler { }) }
-        val newEntries = routes.map { NewHandlerEntry(HandlerType.AFTER, it, Handler { }, Handler { }) }
+        val newEntries = routes.map { NewHandlerEntry(HandlerType.AFTER, it, false, Handler { }, Handler { }) }
 
         val testEntries = listOf(
                 "/test/1234/some/path/here",
diff --git a/javalin/src/test/java/io/javalin/routeoverview/CrudHandlerImpl.kt b/javalin/src/test/java/io/javalin/routeoverview/CrudHandlerImpl.kt
new file mode 100644
index 00000000..d61d11f4
--- /dev/null
+++ b/javalin/src/test/java/io/javalin/routeoverview/CrudHandlerImpl.kt
@@ -0,0 +1,12 @@
+package io.javalin.routeoverview
+
+import io.javalin.apibuilder.CrudHandler
+import io.javalin.http.Context
+
+class CrudHandlerImpl : CrudHandler {
+    override fun getAll(ctx: Context) {}
+    override fun getOne(ctx: Context, resourceId: String) {}
+    override fun create(ctx: Context) {}
+    override fun update(ctx: Context, resourceId: String) {}
+    override fun delete(ctx: Context, resourceId: String) {}
+}
diff --git a/javalin/src/test/java/io/javalin/routeoverview/VisualTest.java b/javalin/src/test/java/io/javalin/routeoverview/VisualTest.java
index c8a3524d..ea589510 100644
--- a/javalin/src/test/java/io/javalin/routeoverview/VisualTest.java
+++ b/javalin/src/test/java/io/javalin/routeoverview/VisualTest.java
@@ -15,6 +15,7 @@ import io.javalin.websocket.WsHandler;
 import static io.javalin.TestAccessManager.MyRoles.ROLE_ONE;
 import static io.javalin.TestAccessManager.MyRoles.ROLE_THREE;
 import static io.javalin.TestAccessManager.MyRoles.ROLE_TWO;
+import static io.javalin.apibuilder.ApiBuilder.crud;
 import static io.javalin.apibuilder.ApiBuilder.delete;
 import static io.javalin.apibuilder.ApiBuilder.get;
 import static io.javalin.apibuilder.ApiBuilder.patch;
@@ -69,6 +70,7 @@ public class VisualTest {
                     delete(new HandlerImplementation());
                 });
             });
+            crud("/movies/:movie-id", new CrudHandlerImpl());
         });
     }