Repository: tipsy/javalin
Pull request #1214 (2021-04-07 15:06:36)

Bugfix/javalin state
Fix as described in issue #1211


Patch:
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 933aa6ff..f839b8f8 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
@@ -15,6 +15,7 @@ import io.javalin.plugin.json.JavalinJson
 import io.javalin.plugin.rendering.vue.FileInliner.inlineFiles
 import io.javalin.plugin.rendering.vue.JavalinVue.getAllDependencies
 import io.javalin.plugin.rendering.vue.JavalinVue.resourcesJarClass
+import java.net.URLEncoder
 import java.nio.file.FileSystems
 import java.nio.file.Files
 import java.nio.file.Path
@@ -68,23 +69,21 @@ object JavalinVue {
     internal fun getAllDependencies(paths: Set<Path>) = paths.filter { it.isVueFile() }
             .joinToString("") { "\n<!-- ${it.fileName} -->\n" + it.readText() }
 
-    // replace("\\", "\\\\") because we need the backslash to remain after evaluating the string
-    internal fun getState(ctx: Context, state: Any?) = "\n<script>\n" + """
-        |    Vue.prototype.${"$"}javalin = {
-        |        pathParams: `${htmlEscape(JavalinJson.toJson(ctx.pathParamMap()))?.replace("\\", "\\\\")}`,
-        |        queryParams: `${htmlEscape(JavalinJson.toJson(ctx.queryParamMap()))?.replace("\\", "\\\\")}`,
-        |        state: ${JavalinJson.toJson(state ?: stateFunction(ctx))}
-        |    }""".trimMargin() + "\n</script>\n" + decodeParams()
-
-    private fun decodeParams() = "\n<script>\n" + """
-        |    function ____decode(string) { // used for decoding HTML encoded params
-        |        let textArea = document.createElement("textarea");
-        |        textArea.innerHTML = string;
-        |        return textArea.value;
-        |    }
-        |    ["queryParams", "pathParams"].forEach((key) => {
-        |        Vue.prototype.${"$"}javalin[key] = JSON.parse(____decode(Vue.prototype.${"$"}javalin[key]));
-        |    });""".trimMargin() + "\n</script>\n"
+    internal fun getState(ctx: Context, state: Any?) = "\n<script>\n" +
+        "Vue.prototype.\$javalin = JSON.parse(decodeURIComponent(\"${
+            JavalinJson.toJson(
+                mapOf(
+                    "pathParams" to ctx.pathParamMap(),
+                    "queryParams" to ctx.queryParamMap(),
+                    "state" to (state ?: stateFunction(ctx))
+                )
+            ).uriEncodeForJavascript()}\"))\n</script>\n"
+
+    // Unfortunately, Java's URLEncoder does not encode the space character in the same way as Javascript.
+    // Javascript expects a space character to be encoded as "%20", whereas Java encodes it as "+".
+    // All other encodings are implemented correctly, therefore we can simply replace the character in the encoded String.
+    private fun String.uriEncodeForJavascript() =
+        URLEncoder.encode(this, Charsets.UTF_8.name()).replace("+","%20")
 
     private fun String.replaceWebjarsWithCdn() =
             this.replace("@cdnWebjar/", if (isDev == true) "/webjars/" else "https://cdn.jsdelivr.net/webjars/org.webjars.npm/")
@@ -154,14 +153,3 @@ object FileInliner {
 fun Path.readText() = String(Files.readAllBytes(this))
 fun Path.isVueFile() = this.toString().endsWith(".vue")
 
-fun htmlEscape(string: String?) = string?.toCharArray()?.map {
-    when (it) {
-        '<' -> "&lt;"
-        '>' -> "&gt;"
-        '&' -> "&amp;"
-        '"' -> "&quot;"
-        '\'' -> "&#x27;"
-        '/' -> "&#x2F;"
-        else -> it
-    }
-}?.joinToString("")


Test patch:
diff --git a/javalin/src/test/java/io/javalin/TestJavalinVue.kt b/javalin/src/test/java/io/javalin/TestJavalinVue.kt
index 2be3d8a9..feafe4ac 100644
--- a/javalin/src/test/java/io/javalin/TestJavalinVue.kt
+++ b/javalin/src/test/java/io/javalin/TestJavalinVue.kt
@@ -18,6 +18,7 @@ import io.mockk.verify
 import org.assertj.core.api.Assertions.assertThat
 import org.junit.Before
 import org.junit.Test
+import java.net.URLEncoder
 import java.nio.file.Paths
 
 class TestJavalinVue {
@@ -42,40 +43,42 @@ class TestJavalinVue {
 
     private val state = State(User("tipsy", "tipsy@tipsy.tipsy"), Role("Maintainer"))
 
+    private fun String.uriEncodeForJavascript() =
+        URLEncoder.encode(this, Charsets.UTF_8.name()).replace("+","%20")
+
     @Test
     fun `vue component with state`() = TestUtil.test { app, http ->
-        val q = "&quot;"
+        val encodedState = """{"pathParams":{"my-param":"test-path-param"},"queryParams":{"qp":["test-query-param"]},"state":{"user":{"name":"tipsy","email":"tipsy@tipsy.tipsy"},"role":{"name":"Maintainer"}}}""".uriEncodeForJavascript()
         JavalinVue.stateFunction = { ctx -> state }
         app.get("/vue/:my-param", VueComponent("<test-component></test-component>"))
         val res = http.getBody("/vue/test-path-param?qp=test-query-param")
-        assertThat(res).contains("""
-                |    Vue.prototype.${"$"}javalin = {
-                |        pathParams: `{${q}my-param${q}:${q}test-path-param${q}}`,
-                |        queryParams: `{${q}qp${q}:[${q}test-query-param${q}]}`,
-                |        state: {"user":{"name":"tipsy","email":"tipsy@tipsy.tipsy"},"role":{"name":"Maintainer"}}
-                |    }""".trimMargin())
+        assertThat(res).contains(encodedState)
         assertThat(res).contains("""Vue.component("test-component", {template: "#test-component"});""")
         assertThat(res).contains("<body><test-component></test-component></body>")
     }
 
     @Test
     fun `vue component without state`() = TestUtil.test { app, http ->
+        val encodedEmptyState = """{"pathParams":{},"queryParams":{},"state":{}}""".uriEncodeForJavascript()
+
         app.get("/no-state", VueComponent("<test-component></test-component>"))
         val res = http.getBody("/no-state")
-        assertThat(res).contains("""pathParams: `{}`""")
-        assertThat(res).contains("""queryParams: `{}`""")
-        assertThat(res).contains("""state: {}""")
+        assertThat(res).contains(encodedEmptyState)
         assertThat(res).contains("<body><test-component></test-component></body>")
     }
 
     @Test
     fun `vue component with component-specific state`() = TestUtil.test { app, http ->
+        val encodedEmptyState = """{"pathParams":{},"queryParams":{},"state":{}}""".uriEncodeForJavascript()
+
+        val encodedTestState = """{"pathParams":{},"queryParams":{},"state":{"test":"tast"}}""".uriEncodeForJavascript()
+
         app.get("/no-state", VueComponent("<test-component></test-component>"))
         val noStateRes = http.getBody("/no-state")
         app.get("/specific-state", VueComponent("<test-component></test-component>", mapOf("test" to "tast")))
         val specificStateRes = http.getBody("/specific-state")
-        assertThat(noStateRes).contains("""state: {}""")
-        assertThat(specificStateRes).contains("""state: {"test":"tast"}""")
+        assertThat(noStateRes).contains(encodedEmptyState)
+        assertThat(specificStateRes).contains(encodedTestState)
     }
 
     @Test
@@ -93,21 +96,22 @@ class TestJavalinVue {
 
     @Test
     fun `default params are escaped`() = TestUtil.test { app, http ->
-        val xss = "%3Cscript%3Ealert%281%29%3Cscript%3E"
+        val encodedXSS = "%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;")
+        assertThat(http.getBody("/escaped?${encodedXSS}=value")).doesNotContain("<script>alert(1)<script>")
+        assertThat(http.getBody("/escaped?${encodedXSS}=value")).contains(encodedXSS)
         // 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;")
+        assertThat(http.getBody("/escaped?key=${encodedXSS}")).doesNotContain("<script>alert(1)<script>")
+        assertThat(http.getBody("/escaped?key=${encodedXSS}")).contains(encodedXSS)
     }
 
     @Test
     fun `quotes are handled correctly`() = TestUtil.test { app, http ->
-        val q = "&quot;"
+        val encodedTestObject = """"test":["\"cool\""]""".uriEncodeForJavascript()
         app.get("/escaped", VueComponent("<test-component></test-component>"))
-        assertThat(http.getBody("""/escaped?test=%22cool%22""")).contains("""`{${q}test${q}:[${q}\\${q}cool\\${q}${q}]}`""")
+
+        assertThat(http.getBody("""/escaped?test=%22cool%22""")).contains(encodedTestObject)
     }
 
     @Test
diff --git a/javalin/src/test/java/io/javalin/TestWebBrowser.kt b/javalin/src/test/java/io/javalin/TestWebBrowser.kt
index 2f324138..50b7c956 100644
--- a/javalin/src/test/java/io/javalin/TestWebBrowser.kt
+++ b/javalin/src/test/java/io/javalin/TestWebBrowser.kt
@@ -10,6 +10,7 @@ import io.github.bonigarcia.wdm.WebDriverManager
 import io.javalin.core.compression.Brotli
 import io.javalin.core.util.Header
 import io.javalin.http.util.SeekableWriter.chunkSize
+import io.javalin.plugin.rendering.vue.JavalinVue
 import io.javalin.plugin.rendering.vue.VueComponent
 import io.javalin.testing.TestLoggingUtil.captureStdOut
 import io.javalin.testing.TestUtil
@@ -99,15 +100,64 @@ class TestWebBrowser {
 
     @Test
     fun `path params are not html-encoded on the Vue prototype`() = TestUtil.test { app, http ->
-        val q = "&quot;"
         TestJavalinVue.before()
         app.get("/vue/:my-param", VueComponent("<test-component></test-component>"))
         driver.get(http.origin + "/vue/odd&co")
-        assertThat(driver.pageSource).contains("""pathParams: `{${q}my-param${q}:${q}odd&amp;co${q}}`,""")
+
         val pathParam = driver.executeScript("""return Vue.prototype.${"$"}javalin.pathParams["my-param"]""") as String
         assertThat(pathParam).isEqualTo("odd&co")
         val nullParam = driver.executeScript("""return Vue.prototype.${"$"}javalin.queryParams["my-param"]""") as String?
         assertThat(nullParam).isNull()
     }
 
+    @Test
+    fun `script tags in state function does not break page rendering`() = TestUtil.test { app, http ->
+        TestJavalinVue.before()
+        val testValue = "some value with <script></script> tags in it"
+        JavalinVue.stateFunction = {
+            mapOf("some_key" to testValue)
+        }
+        app.get("/script_in_state", VueComponent("<test-component></test-component>"))
+        driver.get(http.origin + "/script_in_state")
+        val stateValue = driver.executeScript("""return Vue.prototype.${"$"}javalin.state["some_key"]""") as String
+        assertThat(stateValue).isEqualTo(testValue)
+    }
+
+    @Test
+    fun `utf8 characters in state and parameters`() = TestUtil.test { app, http ->
+        TestJavalinVue.before()
+        val testValue = "some value with weird ✔️ \uD83C\uDF89 characters in it"
+        JavalinVue.stateFunction = {
+            mapOf("some_key" to testValue)
+        }
+        app.get("/script_in_state/:param", VueComponent("<test-component></test-component>"))
+        driver.get(http.origin + "/script_in_state/my_path_param_with_\uD83D\uDE80?☕=\uD83D\uDC95")
+        val stateValue = driver.executeScript("""return Vue.prototype.${"$"}javalin.state["some_key"]""") as String
+        val pathParam = driver.executeScript("""return Vue.prototype.${"$"}javalin.pathParams["param"]""") as String
+        val queryParam = driver.executeScript("""return Vue.prototype.${"$"}javalin.queryParams["☕"]""") as List<String>
+
+        assertThat(stateValue).isEqualTo(testValue)
+        assertThat(pathParam).isEqualTo("my_path_param_with_\uD83D\uDE80")
+        assertThat(queryParam).isEqualTo(listOf("\uD83D\uDC95"))
+    }
+
+    @Test
+    fun `problematic characters in state and parameters`() = TestUtil.test { app, http ->
+        TestJavalinVue.before()
+        val testValue = "-_.!~*'()"
+
+        JavalinVue.stateFunction = {
+            mapOf("some_key" to testValue)
+        }
+        app.get("/script_in_state/:param", VueComponent("<test-component></test-component>"))
+        driver.get(http.origin + "/script_in_state/$testValue?my_key=$testValue")
+        val stateValue = TestWebBrowser.driver.executeScript("""return Vue.prototype.${"$"}javalin.state["some_key"]""") as String
+        val pathParam = TestWebBrowser.driver.executeScript("""return Vue.prototype.${"$"}javalin.pathParams["param"]""") as String
+        val queryParam = TestWebBrowser.driver.executeScript("""return Vue.prototype.${"$"}javalin.queryParams["my_key"]""") as List<String>
+
+        assertThat(stateValue).isEqualTo(testValue)
+        assertThat(pathParam).isEqualTo(testValue)
+        assertThat(queryParam).isEqualTo(listOf(testValue))
+    }
+
 }