Skip to content

Commit 7862964

Browse files
authored
Merge pull request #2 from nemozak1/claude/fix-xss-client-registration-nXuBU
Address PR review feedback: centralize htmlEncode, escape form inputs…
2 parents 70b47ab + cdaeef6 commit 7862964

7 files changed

Lines changed: 138 additions & 22 deletions

File tree

src/main/scala/com/tesobe/oidc/endpoints/AuthEndpoint.scala

Lines changed: 9 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ package com.tesobe.oidc.endpoints
2121

2222
import cats.effect.IO
2323
import com.tesobe.oidc.auth.{AuthService, CodeService}
24+
import com.tesobe.oidc.endpoints.HtmlUtils.htmlEncode
2425
import com.tesobe.oidc.models.{OidcError, User}
2526
import com.tesobe.oidc.ratelimit.RateLimitService
2627
import com.tesobe.oidc.config.OidcConfig
@@ -40,10 +41,6 @@ class AuthEndpoint(
4041

4142
private val logger = LoggerFactory.getLogger(getClass)
4243

43-
private def htmlEncode(s: String): String =
44-
s.replace("&", "&amp;").replace("<", "&lt;")
45-
.replace(">", "&gt;").replace("\"", "&quot;").replace("'", "&#x27;")
46-
4744
// Test logging immediately when class is created
4845
logger.info("AuthEndpoint created - logging is working!")
4946
println("AuthEndpoint created - logging is working!")
@@ -328,7 +325,7 @@ class AuthEndpoint(
328325
s"Error handling login submission: ${error.getMessage}",
329326
error
330327
)
331-
BadRequest(s"Invalid form data: ${error.getMessage}")
328+
BadRequest("Invalid form data. Please try again.")
332329
}
333330

334331
private def generateCodeForUser(
@@ -363,15 +360,15 @@ class AuthEndpoint(
363360
clientOpt <- authService.findClientByClientIdThatIsKey(clientId)
364361

365362
stateParam = state
366-
.map(s => s"""<input type="hidden" name="state" value="$s">""")
363+
.map(s => s"""<input type="hidden" name="state" value="${htmlEncode(s)}">""")
367364
.getOrElse("")
368365
nonceParam = nonce
369-
.map(n => s"""<input type="hidden" name="nonce" value="$n">""")
366+
.map(n => s"""<input type="hidden" name="nonce" value="${htmlEncode(n)}">""")
370367
.getOrElse("")
371368

372369
providerOptions = providers
373370
.map { provider =>
374-
s"""<option value="$provider">$provider</option>"""
371+
s"""<option value="${htmlEncode(provider)}">${htmlEncode(provider)}</option>"""
375372
}
376373
.mkString("\n ")
377374

@@ -473,7 +470,7 @@ class AuthEndpoint(
473470
</div>"""
474471
} else if (providers.length == 1) {
475472
// Single provider in production: use hidden field
476-
s"""<input type="hidden" name="provider" value="${providers.head}">"""
473+
s"""<input type="hidden" name="provider" value="${htmlEncode(providers.head)}">"""
477474
} else {
478475
// No providers - shouldn't happen but handle gracefully
479476
s"""<div class="form-group">
@@ -484,9 +481,9 @@ class AuthEndpoint(
484481
</div>"""
485482
}}
486483

487-
<input type="hidden" name="client_id" value="$clientId">
488-
<input type="hidden" name="redirect_uri" value="$redirectUri">
489-
<input type="hidden" name="scope" value="$scope">
484+
<input type="hidden" name="client_id" value="${htmlEncode(clientId)}">
485+
<input type="hidden" name="redirect_uri" value="${htmlEncode(redirectUri)}">
486+
<input type="hidden" name="scope" value="${htmlEncode(scope)}">
490487
$stateParam
491488
$nonceParam
492489

src/main/scala/com/tesobe/oidc/endpoints/ClientsEndpoint.scala

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -21,16 +21,13 @@ package com.tesobe.oidc.endpoints
2121

2222
import cats.effect.IO
2323
import com.tesobe.oidc.auth.HybridAuthService
24+
import com.tesobe.oidc.endpoints.HtmlUtils.htmlEncode
2425
import com.tesobe.oidc.models.OidcClient
2526
import org.http4s._
2627
import org.http4s.dsl.io._
2728

2829
class ClientsEndpoint(authService: HybridAuthService) {
2930

30-
private def htmlEncode(s: String): String =
31-
s.replace("&", "&amp;").replace("<", "&lt;")
32-
.replace(">", "&gt;").replace("\"", "&quot;").replace("'", "&#x27;")
33-
3431
val routes: HttpRoutes[IO] = HttpRoutes.of[IO] {
3532
case GET -> Root / "clients" =>
3633
handleClientsListRequest()
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
/*
2+
* Copyright (c) 2025 TESOBE
3+
*
4+
* This file is part of OBP-OIDC.
5+
*
6+
* OBP-OIDC is free software: you can redistribute it and/or modify
7+
* it under the terms of the GNU Affero General Public License as published by
8+
* the Free Software Foundation, either version 3 of the License, or
9+
* (at your option) any later version.
10+
*
11+
* OBP-OIDC is distributed in the hope that it will be useful,
12+
* but WITHOUT ANY WARRANTY; without even the implied warranty of
13+
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
14+
* GNU Affero General Public License for more details.
15+
*
16+
* You should have received a copy of the GNU Affero General Public License
17+
* along with OBP-OIDC. If not, see <http://www.gnu.org/licenses/>.
18+
*/
19+
20+
package com.tesobe.oidc.endpoints
21+
22+
object HtmlUtils {
23+
24+
/** Encode a string for safe interpolation into HTML content and attributes.
25+
*
26+
* Replaces the five characters that have special meaning in HTML/XML
27+
* (&, <, >, ", ') with their corresponding character entity references.
28+
*/
29+
def htmlEncode(s: String): String =
30+
s.replace("&", "&amp;")
31+
.replace("<", "&lt;")
32+
.replace(">", "&gt;")
33+
.replace("\"", "&quot;")
34+
.replace("'", "&#x27;")
35+
}

src/main/scala/com/tesobe/oidc/endpoints/RegistrationEndpoint.scala

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -97,7 +97,7 @@ class RegistrationEndpoint(
9797
BadRequest(
9898
ClientRegistrationError(
9999
ClientRegistrationError.INVALID_CLIENT_METADATA,
100-
Some(s"Invalid JSON request body: ${error.getMessage}")
100+
Some("Invalid JSON request body. Please check the request format.")
101101
).asJson
102102
).map(addNoCacheHeaders)
103103
}

src/main/scala/com/tesobe/oidc/endpoints/StaticFilesEndpoint.scala

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,10 +23,13 @@ import cats.effect.IO
2323
import org.http4s._
2424
import org.http4s.dsl.io._
2525
import org.http4s.headers.`Content-Type`
26+
import org.slf4j.LoggerFactory
2627
import scala.io.Source
2728

2829
class StaticFilesEndpoint {
2930

31+
private val logger = LoggerFactory.getLogger(getClass)
32+
3033
val routes: HttpRoutes[IO] = HttpRoutes.of[IO] {
3134
case GET -> Root / "static" / "css" / fileName if fileName.endsWith(".css") =>
3235
serveStaticFile(s"static/css/$fileName", MediaType.text.css)
@@ -50,7 +53,8 @@ class StaticFilesEndpoint {
5053
case None =>
5154
NotFound(s"Static file not found: $resourcePath")
5255
}.handleErrorWith { error =>
53-
InternalServerError(s"Error serving static file: ${error.getMessage}")
56+
logger.error(s"Error serving static file $resourcePath: ${error.getMessage}", error)
57+
InternalServerError("Error serving static file.")
5458
}
5559
}
5660
}

src/main/scala/com/tesobe/oidc/server/OidcServer.scala

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -42,9 +42,7 @@ import scala.concurrent.duration._
4242

4343
object OidcServer extends IOApp {
4444

45-
private def htmlEncode(s: String): String =
46-
s.replace("&", "&amp;").replace("<", "&lt;")
47-
.replace(">", "&gt;").replace("\"", "&quot;").replace("'", "&#x27;")
45+
import HtmlUtils.htmlEncode
4846

4947
private def retryWithBackoff(
5048
action: IO[Either[String, String]],
@@ -544,7 +542,7 @@ object OidcServer extends IOApp {
544542
}
545543
.distinct
546544
.map(url =>
547-
s"""<a href="${htmlEncode(url)}" target="_blank">${htmlEncode(url)}</a>"""
545+
s"""<a href="${htmlEncode(url)}" target="_blank" rel="noopener noreferrer">${htmlEncode(url)}</a>"""
548546
)
549547
.mkString(", ")
550548
s"""<div class="app">${htmlEncode(client.client_name)}: $redirectUrlsList</div>"""
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
/*
2+
* Copyright (c) 2025 TESOBE
3+
*
4+
* This file is part of OBP-OIDC.
5+
*
6+
* OBP-OIDC is free software: you can redistribute it and/or modify
7+
* it under the terms of the GNU Affero General Public License as published by
8+
* the Free Software Foundation, either version 3 of the License, or
9+
* (at your option) any later version.
10+
*
11+
* OBP-OIDC is distributed in the hope that it will be useful,
12+
* but WITHOUT ANY WARRANTY; without even the implied warranty of
13+
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
14+
* GNU Affero General Public License for more details.
15+
*
16+
* You should have received a copy of the GNU Affero General Public License
17+
* along with OBP-OIDC. If not, see <http://www.gnu.org/licenses/>.
18+
*/
19+
20+
package com.tesobe.oidc.endpoints
21+
22+
import org.scalatest.funsuite.AnyFunSuite
23+
import org.scalatest.matchers.should.Matchers
24+
25+
class HtmlUtilsTest extends AnyFunSuite with Matchers {
26+
27+
// Individual character escaping
28+
test("htmlEncode should escape ampersand to &amp;") {
29+
HtmlUtils.htmlEncode("&") shouldBe "&amp;"
30+
}
31+
32+
test("htmlEncode should escape less-than to &lt;") {
33+
HtmlUtils.htmlEncode("<") shouldBe "&lt;"
34+
}
35+
36+
test("htmlEncode should escape greater-than to &gt;") {
37+
HtmlUtils.htmlEncode(">") shouldBe "&gt;"
38+
}
39+
40+
test("htmlEncode should escape double-quote to &quot;") {
41+
HtmlUtils.htmlEncode("\"") shouldBe "&quot;"
42+
}
43+
44+
test("htmlEncode should escape single-quote to &#x27;") {
45+
HtmlUtils.htmlEncode("'") shouldBe "&#x27;"
46+
}
47+
48+
// Edge cases
49+
test("htmlEncode should return empty string unchanged") {
50+
HtmlUtils.htmlEncode("") shouldBe ""
51+
}
52+
53+
test("htmlEncode should return plain text unchanged") {
54+
HtmlUtils.htmlEncode("hello world") shouldBe "hello world"
55+
}
56+
57+
// Mixed strings
58+
test("htmlEncode should escape all five special characters in one string") {
59+
HtmlUtils.htmlEncode("&<>\"'") shouldBe "&amp;&lt;&gt;&quot;&#x27;"
60+
}
61+
62+
test("htmlEncode should escape special characters embedded in plain text") {
63+
HtmlUtils.htmlEncode("hello <world> & \"everyone\"") shouldBe
64+
"hello &lt;world&gt; &amp; &quot;everyone&quot;"
65+
}
66+
67+
test("htmlEncode should escape an HTML tag to prevent injection") {
68+
HtmlUtils.htmlEncode("<script>alert('xss')</script>") shouldBe
69+
"&lt;script&gt;alert(&#x27;xss&#x27;)&lt;/script&gt;"
70+
}
71+
72+
test("htmlEncode should escape HTML attribute injection attempt") {
73+
HtmlUtils.htmlEncode("\" onmouseover=\"alert(1)") shouldBe
74+
"&quot; onmouseover=&quot;alert(1)"
75+
}
76+
77+
// Ampersand-first ordering: ensures existing text is not double-escaped
78+
test("htmlEncode should not double-escape an already-escaped entity") {
79+
HtmlUtils.htmlEncode("&amp;") shouldBe "&amp;amp;"
80+
}
81+
82+
test("htmlEncode should escape multiple ampersands") {
83+
HtmlUtils.htmlEncode("a && b") shouldBe "a &amp;&amp; b"
84+
}
85+
}

0 commit comments

Comments
 (0)