Skip to content

Commit 9bc078c

Browse files
authored
Merge pull request #11 from nemozak1/main
Claude Security Review fixes, add claude workflow
2 parents 1a91594 + 4ff4d85 commit 9bc078c

9 files changed

Lines changed: 208 additions & 32 deletions

File tree

.github/workflows/claude.yml

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
name: Claude Code
2+
3+
on:
4+
issue_comment:
5+
types: [created]
6+
pull_request_review_comment:
7+
types: [created]
8+
issues:
9+
types: [opened, assigned]
10+
pull_request_review:
11+
types: [submitted]
12+
13+
jobs:
14+
claude:
15+
if: |
16+
(github.event_name == 'issue_comment' && contains(github.event.comment.body, '@claude')) ||
17+
(github.event_name == 'pull_request_review_comment' && contains(github.event.comment.body, '@claude')) ||
18+
(github.event_name == 'pull_request_review' && contains(github.event.review.body, '@claude')) ||
19+
(github.event_name == 'issues' && (contains(github.event.issue.body, '@claude') || contains(github.event.issue.title, '@claude')))
20+
runs-on: ubuntu-latest
21+
permissions:
22+
contents: read
23+
pull-requests: read
24+
issues: read
25+
id-token: write
26+
actions: read # Required for Claude to read CI results on PRs
27+
steps:
28+
- name: Checkout repository
29+
uses: actions/checkout@v4
30+
with:
31+
fetch-depth: 1
32+
33+
- name: Run Claude Code
34+
id: claude
35+
uses: anthropics/claude-code-action@v1
36+
with:
37+
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
38+
39+
# This is an optional setting that allows Claude to read CI results on PRs
40+
additional_permissions: |
41+
actions: read
42+
43+
# Optional: Give a custom prompt to Claude. If this is not specified, Claude will perform the instructions specified in the comment that tagged it.
44+
# prompt: 'Update the pull request description to include a summary of changes.'
45+
46+
# Optional: Add claude_args to customize behavior and configuration
47+
# See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md
48+
# or https://code.claude.com/docs/en/cli-reference for available options
49+
# claude_args: '--allowed-tools Bash(gh pr:*)'
50+

src/main/scala/com/tesobe/oidc/auth/HybridAuthService.scala

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1007,9 +1007,7 @@ class HybridAuthService(
10071007
Left(
10081008
OidcError(
10091009
"server_error",
1010-
Some(
1011-
s"Database error: ${error.getMessage}. Client may already exist or database constraint violation."
1012-
)
1010+
Some("Failed to create client. Please try again later.")
10131011
)
10141012
)
10151013
)

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

Lines changed: 16 additions & 15 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
@@ -324,7 +325,7 @@ class AuthEndpoint(
324325
s"Error handling login submission: ${error.getMessage}",
325326
error
326327
)
327-
BadRequest(s"Invalid form data: ${error.getMessage}")
328+
BadRequest("Invalid form data. Please try again.")
328329
}
329330

330331
private def generateCodeForUser(
@@ -359,31 +360,31 @@ class AuthEndpoint(
359360
clientOpt <- authService.findClientByClientIdThatIsKey(clientId)
360361

361362
stateParam = state
362-
.map(s => s"""<input type="hidden" name="state" value="$s">""")
363+
.map(s => s"""<input type="hidden" name="state" value="${htmlEncode(s)}">""")
363364
.getOrElse("")
364365
nonceParam = nonce
365-
.map(n => s"""<input type="hidden" name="nonce" value="$n">""")
366+
.map(n => s"""<input type="hidden" name="nonce" value="${htmlEncode(n)}">""")
366367
.getOrElse("")
367368

368369
providerOptions = providers
369370
.map { provider =>
370-
s"""<option value="$provider">$provider</option>"""
371+
s"""<option value="${htmlEncode(provider)}">${htmlEncode(provider)}</option>"""
371372
}
372373
.mkString("\n ")
373374

374375
clientName = clientOpt.map(_.client_name).getOrElse("Unknown Client")
375376
consumerId = clientOpt.map(_.consumer_id).getOrElse("Unknown Consumer")
376377

377378
// Format client name for production display: replace dashes with spaces and convert to proper case
378-
formattedClientName = clientName
379+
formattedClientName = htmlEncode(clientName
379380
.replace("-", " ")
380381
.split(" ")
381382
.map(word =>
382383
if (word.isEmpty) ""
383384
else word.charAt(0).toUpper + word.substring(1).toLowerCase
384385
)
385386
.mkString(" ")
386-
.replace("Obp ", "OBP ")
387+
.replace("Obp ", "OBP "))
387388

388389
errorHtml = errorMessage
389390
.map(msg => s"""<div class="error">$msg</div>""")
@@ -434,10 +435,10 @@ class AuthEndpoint(
434435
$errorHtml
435436
${if (config.localDevelopmentMode) {
436437
s"""<div class="info">
437-
<strong>Consumer ID:</strong> $consumerId<br>
438-
<strong>Client Name:</strong> $clientName<br>
439-
<strong>Client ID:</strong> $clientId<br>
440-
<strong>Requested Scopes:</strong> $scope
438+
<strong>Consumer ID:</strong> ${htmlEncode(consumerId)}<br>
439+
<strong>Client Name:</strong> ${htmlEncode(clientName)}<br>
440+
<strong>Client ID:</strong> ${htmlEncode(clientId)}<br>
441+
<strong>Requested Scopes:</strong> ${htmlEncode(scope)}
441442
</div>"""
442443
} else {
443444
""
@@ -469,7 +470,7 @@ class AuthEndpoint(
469470
</div>"""
470471
} else if (providers.length == 1) {
471472
// Single provider in production: use hidden field
472-
s"""<input type="hidden" name="provider" value="${providers.head}">"""
473+
s"""<input type="hidden" name="provider" value="${htmlEncode(providers.head)}">"""
473474
} else {
474475
// No providers - shouldn't happen but handle gracefully
475476
s"""<div class="form-group">
@@ -480,9 +481,9 @@ class AuthEndpoint(
480481
</div>"""
481482
}}
482483

483-
<input type="hidden" name="client_id" value="$clientId">
484-
<input type="hidden" name="redirect_uri" value="$redirectUri">
485-
<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)}">
486487
$stateParam
487488
$nonceParam
488489

@@ -617,7 +618,7 @@ class AuthEndpoint(
617618
redirectUri: String,
618619
error: OidcError
619620
): IO[Response[IO]] = {
620-
val stateParam = error.state.map(s => s"&state=$s").getOrElse("")
621+
val stateParam = error.state.map(s => s"&state=${java.net.URLEncoder.encode(s, "UTF-8")}").getOrElse("")
621622
val descriptionParam = error.error_description
622623
.map(d => s"&error_description=${java.net.URLEncoder.encode(d, "UTF-8")}")
623624
.getOrElse("")

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

Lines changed: 10 additions & 9 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.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._
@@ -205,30 +206,30 @@ class ClientsEndpoint(authService: HybridAuthService) {
205206
"<em>None</em>"
206207
} else {
207208
"<ul>" + client.redirect_uris
208-
.map(uri => s"<li>$uri</li>")
209+
.map(uri => s"<li>${htmlEncode(uri)}</li>")
209210
.mkString("") + "</ul>"
210211
}
211212

212213
val scopesList = client.scopes
213-
.map(scope => s"""<span class="scope">$scope</span>""")
214+
.map(scope => s"""<span class="scope">${htmlEncode(scope)}</span>""")
214215
.mkString(" ")
215216

216217
val clientSecretDisplay = client.client_secret match {
217-
case Some(secret) => s"""<span class="client-secret">$secret</span>"""
218+
case Some(secret) => s"""<span class="client-secret">${htmlEncode(secret)}</span>"""
218219
case None => "<em>Not set</em>"
219220
}
220221

221222
s"""<tr>
222-
| <td><span class="consumer-id">${client.consumer_id}</span></td>
223-
| <td><span class="client-name">${client.client_name}</span></td>
224-
| <td><span class="client-id">${client.client_id}</span></td>
223+
| <td><span class="consumer-id">${htmlEncode(client.consumer_id)}</span></td>
224+
| <td><span class="client-name">${htmlEncode(client.client_name)}</span></td>
225+
| <td><span class="client-id">${htmlEncode(client.client_id)}</span></td>
225226
| <td>$clientSecretDisplay</td>
226227
| <td class="redirect-uris">$redirectUrisList</td>
227228
| <td class="scopes">$scopesList</td>
228-
| <td>${client.token_endpoint_auth_method}</td>
229-
| <td class="created-at">${client.created_at.getOrElse(
229+
| <td>${htmlEncode(client.token_endpoint_auth_method)}</td>
230+
| <td class="created-at">${htmlEncode(client.created_at.getOrElse(
230231
"Unknown"
231-
)}</td>
232+
))}</td>
232233
|</tr>""".stripMargin
233234
}
234235
}
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: 2 additions & 2 deletions
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
}
@@ -175,7 +175,7 @@ class RegistrationEndpoint(
175175
InternalServerError(
176176
ClientRegistrationError(
177177
"server_error",
178-
Some(s"Failed to register client: ${error.error_description.getOrElse("Unknown error")}")
178+
Some("Registration failed. Please try again later.")
179179
).asJson
180180
).map(addNoCacheHeaders)
181181
}

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: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,8 @@ import scala.concurrent.duration._
4242

4343
object OidcServer extends IOApp {
4444

45+
import HtmlUtils.htmlEncode
46+
4547
private def retryWithBackoff(
4648
action: IO[Either[String, String]],
4749
description: String,
@@ -540,10 +542,10 @@ object OidcServer extends IOApp {
540542
}
541543
.distinct
542544
.map(url =>
543-
s"""<a href="$url" target="_blank">$url</a>"""
545+
s"""<a href="${htmlEncode(url)}" target="_blank" rel="noopener noreferrer">${htmlEncode(url)}</a>"""
544546
)
545547
.mkString(", ")
546-
s"""<div class="app">${client.client_name}: $redirectUrlsList</div>"""
548+
s"""<div class="app">${htmlEncode(client.client_name)}: $redirectUrlsList</div>"""
547549
}
548550
.mkString("")
549551
s"""
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)