Skip to content

Commit a938519

Browse files
authored
Decode wiki page names in URLs only once (#4144)
1 parent b98dd32 commit a938519

4 files changed

Lines changed: 134 additions & 11 deletions

File tree

‎src/main/scala/gitbucket/core/controller/WikiController.scala‎

Lines changed: 16 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -74,7 +74,7 @@ trait WikiControllerBase extends ControllerBase {
7474
})
7575

7676
get("/:owner/:repository/wiki/:page")(referrersOnly { repository =>
77-
val pageName = StringUtil.urlDecode(params("page"))
77+
val pageName = requestPageName
7878
val branch = getWikiBranch(repository.owner, repository.name)
7979

8080
getWikiPage(repository.owner, repository.name, pageName, branch).map { page =>
@@ -92,7 +92,7 @@ trait WikiControllerBase extends ControllerBase {
9292
})
9393

9494
get("/:owner/:repository/wiki/:page/_history")(referrersOnly { repository =>
95-
val pageName = StringUtil.urlDecode(params("page"))
95+
val pageName = requestPageName
9696
val branch = getWikiBranch(repository.owner, repository.name)
9797

9898
Using.resource(Git.open(getWikiRepositoryDir(repository.owner, repository.name))) { git =>
@@ -104,7 +104,7 @@ trait WikiControllerBase extends ControllerBase {
104104
})
105105

106106
get("/:owner/:repository/wiki/:page/_compare/:commitId")(referrersOnly { repository =>
107-
val pageName = StringUtil.urlDecode(params("page"))
107+
val pageName = requestPageName
108108
val Array(from, to) = params("commitId").split("\\.\\.\\.")
109109

110110
Using.resource(Git.open(getWikiRepositoryDir(repository.owner, repository.name))) { git =>
@@ -157,7 +157,7 @@ trait WikiControllerBase extends ControllerBase {
157157
get("/:owner/:repository/wiki/:page/_revert/:commitId")(readableUsersOnly { repository =>
158158
context.withLoginAccount { loginAccount =>
159159
if (isEditable(repository)) {
160-
val pageName = StringUtil.urlDecode(params("page"))
160+
val pageName = requestPageName
161161
val Array(from, to) = params("commitId").split("\\.\\.\\.")
162162
val branch = getWikiBranch(repository.owner, repository.name)
163163

@@ -191,7 +191,7 @@ trait WikiControllerBase extends ControllerBase {
191191

192192
get("/:owner/:repository/wiki/:page/_edit")(readableUsersOnly { repository =>
193193
if (isEditable(repository)) {
194-
val pageName = StringUtil.urlDecode(params("page"))
194+
val pageName = requestPageName
195195
val branch = getWikiBranch(repository.owner, repository.name)
196196

197197
html.edit(pageName, getWikiPage(repository.owner, repository.name, pageName, branch), repository)
@@ -272,7 +272,7 @@ trait WikiControllerBase extends ControllerBase {
272272
get("/:owner/:repository/wiki/:page/_delete")(readableUsersOnly { repository =>
273273
context.withLoginAccount { loginAccount =>
274274
if (isEditable(repository)) {
275-
val pageName = StringUtil.urlDecode(params("page"))
275+
val pageName = requestPageName
276276
deleteWikiPage(
277277
repository.owner,
278278
repository.name,
@@ -322,6 +322,16 @@ trait WikiControllerBase extends ControllerBase {
322322
}
323323
})
324324

325+
/**
326+
* Returns the page name of the request, decoded once from the raw request URI (/:owner/:repository/wiki/:page/...).
327+
* params("page") can't be used: it is already decoded, so decoding it again turned "%2B" into a space and failed on "%".
328+
* A raw "+" still means a space, as in links created before GitBucket 3.8.
329+
*/
330+
private def requestPageName: String = {
331+
val segment = request.getRequestURI.stripPrefix(request.getContextPath).split("/")(4)
332+
StringUtil.urlDecode(segment.takeWhile(_ != ';')) // drop path parameters such as ;jsessionid=
333+
}
334+
325335
private def unique: Constraint = new Constraint() {
326336
override def validate(
327337
name: String,

‎src/main/twirl/gitbucket/core/helper/activities.scala.html‎

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -75,7 +75,7 @@
7575
@helpers.activityMessage(activity.message)
7676
</div>
7777
<div class="small activity-message">
78-
Created <a href="@{context.path}/@{activity.userName}/@{activity.repositoryName}/wiki/@{activity.additionalInfo}">@{activity.additionalInfo}</a>.
78+
Created <a href="@{context.path}/@{activity.userName}/@{activity.repositoryName}/wiki/@helpers.urlEncode(activity.additionalInfo)">@{activity.additionalInfo}</a>.
7979
</div>
8080
</div>
8181
}
@@ -91,15 +91,15 @@
9191
@if(additionalInfo.length == 2) {
9292
@defining((additionalInfo(0), additionalInfo(1))) { case (pageName, commitId) =>
9393
<div class="small activity-message">
94-
Edited <a href="@{context.path}/@{activity.userName}/@{activity.repositoryName}/wiki/@pageName">@pageName</a>.
95-
<a href="@{context.path}/@{activity.userName}/@{activity.repositoryName}/wiki/@{pageName}/_compare/@{commitId.substring(0, 7)}^...@{commitId.substring(0, 7)}">View the diff »</a>
94+
Edited <a href="@{context.path}/@{activity.userName}/@{activity.repositoryName}/wiki/@helpers.urlEncode(pageName)">@pageName</a>.
95+
<a href="@{context.path}/@{activity.userName}/@{activity.repositoryName}/wiki/@helpers.urlEncode(pageName)/_compare/@{commitId.substring(0, 7)}^...@{commitId.substring(0, 7)}">View the diff »</a>
9696
</div>
9797
}
9898
}
9999
@if(additionalInfo.length == 1) {
100100
@defining(additionalInfo(0)) { pageName =>
101101
<div class="small activity-message">
102-
Edited <a href="@{context.path}/@{activity.userName}/@{activity.repositoryName}/wiki/@{pageName}">@pageName</a>.
102+
Edited <a href="@{context.path}/@{activity.userName}/@{activity.repositoryName}/wiki/@helpers.urlEncode(pageName)">@pageName</a>.
103103
</div>
104104
}
105105
}

‎src/main/twirl/gitbucket/core/search/wiki.scala.html‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ <h4>We've found @wikis.size @helpers.plural(wikis.size, "page")</h4>
1515
}
1616
@wikis.drop((page - 1) * RepositorySearchService.CodeLimit).take(RepositorySearchService.CodeLimit).map { file =>
1717
<div>
18-
<h5><a href="@helpers.url(repository)/wiki/@file.path">@file.path</a></h5>
18+
<h5><a href="@helpers.url(repository)/wiki/@helpers.urlEncode(file.path)">@file.path</a></h5>
1919
<div class="small muted">Last committed @gitbucket.core.helper.html.datetimeago(file.lastModified)</div>
2020
<pre class="prettyprint linenums:@file.highlightLineNumber" style="padding-left: 25px;">@Html(file.highlightText)</pre>
2121
</div>
Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
package gitbucket.core.controller
2+
3+
import gitbucket.core.TestingGitBucketServer
4+
import gitbucket.core.util.StringUtil
5+
import org.apache.http.client.config.RequestConfig
6+
import org.apache.http.client.entity.UrlEncodedFormEntity
7+
import org.apache.http.client.methods.{HttpGet, HttpPost}
8+
import org.apache.http.impl.client.{BasicCookieStore, CloseableHttpClient, HttpClients}
9+
import org.apache.http.message.BasicNameValuePair
10+
import org.apache.http.util.EntityUtils
11+
import org.scalatest.funsuite.AnyFunSuite
12+
13+
import java.util.{Arrays => JArrays}
14+
import scala.util.Using
15+
16+
/**
17+
* Need to run `sbt package` before running this test.
18+
*/
19+
class WikiControllerSpec extends AnyFunSuite {
20+
21+
private val pageNames = Seq("title+", "100%", "x%41y", "a b", "日本語")
22+
23+
test("wiki pages whose names contain URL-special characters can be viewed, edited and deleted") {
24+
withWiki { (server, httpClient) =>
25+
val base = s"http://localhost:${server.port}/root/wiki_test/wiki"
26+
27+
pageNames.foreach { pageName =>
28+
val encoded = StringUtil.urlEncode(pageName)
29+
val (viewStatus, viewBody) = get(httpClient, s"$base/$encoded")
30+
assert(viewStatus == 200 && viewBody.contains(content(pageName)), s"view $pageName")
31+
32+
val (historyStatus, _) = get(httpClient, s"$base/$encoded/_history")
33+
assert(historyStatus == 200, s"history $pageName")
34+
35+
val (editStatus, editBody) = get(httpClient, s"$base/$encoded/_edit")
36+
assert(editStatus == 200 && editBody.contains(content(pageName)), s"edit $pageName")
37+
}
38+
39+
val (deleteStatus, _) = get(httpClient, s"$base/${StringUtil.urlEncode("title+")}/_delete")
40+
assert(deleteStatus == 302)
41+
val (_, pageList) = get(httpClient, s"$base/_pages")
42+
assert(!pageList.contains(s"/wiki/${StringUtil.urlEncode("title+")}\""), "title+ should be deleted")
43+
assert(
44+
pageList.contains(s"/wiki/${StringUtil.urlEncode("a b")}\""),
45+
"deleting title+ must not delete other pages"
46+
)
47+
val (otherStatus, otherBody) = get(httpClient, s"$base/${StringUtil.urlEncode("a b")}")
48+
assert(otherStatus == 200 && otherBody.contains(content("a b")), "deleting title+ must not affect other pages")
49+
}
50+
}
51+
52+
test("a raw + in a wiki URL still means a space, as in links created before GitBucket 3.8") {
53+
withWiki { (server, httpClient) =>
54+
val (status, body) = get(httpClient, s"http://localhost:${server.port}/root/wiki_test/wiki/a+b")
55+
assert(status == 200 && body.contains(content("a b")))
56+
}
57+
}
58+
59+
test("news feed links to wiki pages are URL-encoded") {
60+
withWiki { (server, httpClient) =>
61+
val (_, dashboard) = get(httpClient, s"http://localhost:${server.port}/")
62+
pageNames.foreach { pageName =>
63+
val link = s"/root/wiki_test/wiki/${StringUtil.urlEncode(pageName)}\""
64+
assert(dashboard.contains(link), s"news feed link for $pageName")
65+
}
66+
}
67+
}
68+
69+
private def content(pageName: String): String = s"content of [$pageName]"
70+
71+
private def withWiki(f: (TestingGitBucketServer, CloseableHttpClient) => Unit): Unit = {
72+
Using.resource(new TestingGitBucketServer(19995)) { server =>
73+
server.client("root", "root").createRepository("wiki_test").autoInit(true).create()
74+
75+
Using.resource(HttpClients.custom().setDefaultCookieStore(new BasicCookieStore()).build()) { httpClient =>
76+
post(httpClient, s"http://localhost:${server.port}/signin", "userName" -> "root", "password" -> "root")
77+
78+
pageNames.foreach { pageName =>
79+
val status = post(
80+
httpClient,
81+
s"http://localhost:${server.port}/root/wiki_test/wiki/_new",
82+
"pageName" -> pageName,
83+
"content" -> content(pageName),
84+
"message" -> s"Create $pageName",
85+
"currentPageName" -> "",
86+
"id" -> ""
87+
)
88+
assert(status == 302, s"create $pageName")
89+
}
90+
f(server, httpClient)
91+
}
92+
}
93+
}
94+
95+
private def get(httpClient: CloseableHttpClient, url: String): (Int, String) = {
96+
val request = new HttpGet(url)
97+
request.setConfig(RequestConfig.custom().setRedirectsEnabled(false).build())
98+
Using.resource(httpClient.execute(request)) { response =>
99+
(response.getStatusLine.getStatusCode, EntityUtils.toString(response.getEntity, "UTF-8"))
100+
}
101+
}
102+
103+
private def post(httpClient: CloseableHttpClient, url: String, params: (String, String)*): Int = {
104+
val request = new HttpPost(url)
105+
request.setEntity(
106+
new UrlEncodedFormEntity(JArrays.asList(params.map { case (k, v) => new BasicNameValuePair(k, v) }*), "UTF-8")
107+
)
108+
Using.resource(httpClient.execute(request)) { response =>
109+
EntityUtils.consume(response.getEntity)
110+
response.getStatusLine.getStatusCode
111+
}
112+
}
113+
}

0 commit comments

Comments
 (0)