Repository: javalin/javalin
Pull request #2408 (2025-05-19 07:27:52)

[workflow]: Bump codecov/codecov-action from 5.4.2 to 5.4.3 in the dependencies group
Bumps the dependencies group with 1 update: [codecov/codecov-action](https://github.com/codecov/codecov-action).

Updates `codecov/codecov-action` from 5.4.2 to 5.4.3
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a href="https://github.com/codecov/codecov-action/releases">codecov/codecov-action's releases</a>.</em></p>
<blockquote>
<h2>v5.4.3</h2>
<h2>What's Changed</h2>
<ul>
<li>fix: OIDC on forks by <a href="https://github.com/joseph-sentry"><code>@​joseph-sentry</code></a> in <a href="https://redirect.github.com/codecov/codecov-action/pull/1823">codecov/codecov-action#1823</a></li>
<li>build(deps): bump github/codeql-action from 3.28.13 to 3.28.17 by <a href="https://github.com/dependabot"><code>@​dependabot</code></a> in <a href="https://redirect.github.com/codecov/codecov-action/pull/1822">codecov/codecov-action#1822</a></li>
<li>chore(release): 5.4.3 by <a href="https://github.com/thomasrockhu-codecov"><code>@​thomasrockhu-codecov</code></a> in <a href="https://redirect.github.com/codecov/codecov-action/pull/1827">codecov/codecov-action#1827</a></li>
</ul>
<p><strong>Full Changelog</strong>: <a href="https://github.com/codecov/codecov-action/compare/v5.4.2...v5.4.3">https://github.com/codecov/codecov-action/compare/v5.4.2...v5.4.3</a></p>
</blockquote>
</details>
<details>
<summary>Changelog</summary>
<p><em>Sourced from <a href="https://github.com/codecov/codecov-action/blob/main/CHANGELOG.md">codecov/codecov-action's changelog</a>.</em></p>
<blockquote>
<h2>v5.4.3</h2>
<h3>What's Changed</h3>
<ul>
<li>build(deps): bump github/codeql-action from 3.28.13 to 3.28.17 by <code>@​app/dependabot</code> in <a href="https://redirect.github.com/codecov/codecov-action/pull/1822">codecov/codecov-action#1822</a></li>
<li>fix: OIDC on forks by <a href="https://github.com/joseph-sentry"><code>@​joseph-sentry</code></a> in <a href="https://redirect.github.com/codecov/codecov-action/pull/1823">codecov/codecov-action#1823</a></li>
</ul>
<p><strong>Full Changelog</strong>: <a href="https://github.com/codecov/codecov-action/compare/v5.4.2..v5.4.3">https://github.com/codecov/codecov-action/compare/v5.4.2..v5.4.3</a></p>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a href="https://github.com/codecov/codecov-action/commit/18283e04ce6e62d37312384ff67231eb8fd56d24"><code>18283e0</code></a> chore(release): 5.4.3 (<a href="https://redirect.github.com/codecov/codecov-action/issues/1827">#1827</a>)</li>
<li><a href="https://github.com/codecov/codecov-action/commit/525fcbf8a05e5933b1ef3c7885da8fd1d7231920"><code>525fcbf</code></a> build(deps): bump github/codeql-action from 3.28.13 to 3.28.17 (<a href="https://redirect.github.com/codecov/codecov-action/issues/1822">#1822</a>)</li>
<li><a href="https://github.com/codecov/codecov-action/commit/b203f00e21e8678aa5d26b967170503dbb855a18"><code>b203f00</code></a> fix: OIDC on forks (<a href="https://redirect.github.com/codecov/codecov-action/issues/1823">#1823</a>)</li>
<li>See full diff in <a href="https://github.com/codecov/codecov-action/compare/v5.4.2...v5.4.3">compare view</a></li>
</ul>
</details>
<br />


[![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=codecov/codecov-action&package-manager=github_actions&previous-version=5.4.2&new-version=5.4.3)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores)

Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`.

[//]: # (dependabot-automerge-start)
[//]: # (dependabot-automerge-end)

---

<details>
<summary>Dependabot commands and options</summary>
<br />

You can trigger Dependabot actions by commenting on this PR:
- `@dependabot rebase` will rebase this PR
- `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it
- `@dependabot merge` will merge this PR after your CI passes on it
- `@dependabot squash and merge` will squash and merge this PR after your CI passes on it
- `@dependabot cancel merge` will cancel a previously requested merge and block automerging
- `@dependabot reopen` will reopen this PR if it is closed
- `@dependabot close` will close this PR and stop Dependabot recreating it. You can achieve the same result by closing it manually
- `@dependabot show <dependency name> ignore conditions` will show all of the ignore conditions of the specified dependency
- `@dependabot ignore <dependency name> major version` will close this group update PR and stop Dependabot creating any more for the specific dependency's major version (unless you unignore this specific dependency's major version or upgrade to it yourself)
- `@dependabot ignore <dependency name> minor version` will close this group update PR and stop Dependabot creating any more for the specific dependency's minor version (unless you unignore this specific dependency's minor version or upgrade to it yourself)
- `@dependabot ignore <dependency name>` will close this group update PR and stop Dependabot creating any more for the specific dependency (unless you unignore this specific dependency or upgrade to it yourself)
- `@dependabot unignore <dependency name>` will remove all of the ignore conditions of the specified dependency
- `@dependabot unignore <dependency name> <ignore condition>` will remove the ignore condition of the specified dependency and ignore conditions


</details>

codecov[bot]: ## [Codecov](https://app.codecov.io/gh/javalin/javalin/pull/2408?dropdown=coverage&src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=javalin) Report
All modified and coverable lines are covered by tests :white_check_mark:
> Project coverage is 87.20%. Comparing base [(`93aafbc`)](https://app.codecov.io/gh/javalin/javalin/commit/93aafbc084cffe67bb265d58439bb6b44fc43ff0?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=javalin) to head [(`e8f92b7`)](https://app.codecov.io/gh/javalin/javalin/commit/e8f92b71bdcb1b3250451f85bad57cfd1a38f2b4?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=javalin).

<details><summary>Additional details and impacted files</summary>


```diff
@@            Coverage Diff            @@
##             master    #2408   +/-   ##
=========================================
  Coverage     87.20%   87.20%           
  Complexity     1334     1334           
=========================================
  Files           148      148           
  Lines          4493     4493           
  Branches        513      513           
=========================================
  Hits           3918     3918           
  Misses          363      363           
  Partials        212      212           
```

</details>

[:umbrella: View full report in Codecov by Sentry](https://app.codecov.io/gh/javalin/javalin/pull/2408?dropdown=coverage&src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=javalin).   
:loudspeaker: Have feedback on the report? [Share it here](https://about.codecov.io/codecov-pr-comment-feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=javalin).

<details><summary> :rocket: New features to boost your workflow: </summary>

- :snowflake: [Test Analytics](https://docs.codecov.com/docs/test-analytics): Detect flaky tests, report on failures, and find test suite problems.
- :package: [JS Bundle Analysis](https://docs.codecov.com/docs/javascript-bundle-analysis): Save yourself from yourself by tracking and limiting bundle sizes in JS merges.
</details>

Patch:
diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml
index bf95d138..c8ac5e88 100644
--- a/.github/workflows/main.yml
+++ b/.github/workflows/main.yml
@@ -60,7 +60,7 @@ jobs:
         env:
           MAVEN_OPTS: -Dorg.slf4j.simpleLogger.log.org.apache.maven.cli.transfer.Slf4jMavenTransferListener=warn
       - name: Upload coverage to Codecov
-        uses: codecov/codecov-action@v5.4.2
+        uses: codecov/codecov-action@v5.4.3
         with:
           files: "${{ github.workspace }}/javalin-utils/coverage/target/site/jacoco-aggregate/jacoco.xml"
           verbose: true
diff --git a/javalin/src/main/java/io/javalin/config/StaticFilesConfig.kt b/javalin/src/main/java/io/javalin/config/StaticFilesConfig.kt
index 4324f987..08a63c64 100644
--- a/javalin/src/main/java/io/javalin/config/StaticFilesConfig.kt
+++ b/javalin/src/main/java/io/javalin/config/StaticFilesConfig.kt
@@ -4,6 +4,7 @@ import io.javalin.http.Header
 import io.javalin.http.staticfiles.Location
 import io.javalin.http.staticfiles.StaticFileConfig
 import io.javalin.jetty.JettyResourceHandler
+import io.javalin.security.RouteRole
 import java.util.function.Consumer
 
 /**
@@ -27,11 +28,13 @@ class StaticFilesConfig(private val cfg: JavalinConfig) {
      * Adds the given directory as a static file containers.
      * @param directory the directory where your files are located
      * @param location the location of the static directory (default: CLASSPATH)
+     * @param roles the roles which can access the the static directory (default: emptySet())
      */
     @JvmOverloads
-    fun add(directory: String, location: Location = Location.CLASSPATH) = add { staticFiles ->
+    fun add(directory: String, location: Location = Location.CLASSPATH, roles: Set<RouteRole> = emptySet()) = add { staticFiles ->
         staticFiles.directory = directory
         staticFiles.location = location
+        staticFiles.roles = roles
     }
 
     /**
diff --git a/javalin/src/main/java/io/javalin/http/servlet/DefaultTasks.kt b/javalin/src/main/java/io/javalin/http/servlet/DefaultTasks.kt
index 2b948509..ce0d8efe 100644
--- a/javalin/src/main/java/io/javalin/http/servlet/DefaultTasks.kt
+++ b/javalin/src/main/java/io/javalin/http/servlet/DefaultTasks.kt
@@ -22,13 +22,16 @@ object DefaultTasks {
     }
 
     val BEFORE_MATCHED = TaskInitializer<JavalinServletContext> { submitTask, servlet, ctx, requestUri ->
-        val willMatch by javalinLazy {
-            ctx.setRouteRoles(servlet.matchedRoles(ctx, requestUri)) // set roles for the matched handler
-            servlet.willMatch(ctx, requestUri)
-        }
         val httpHandlerOrNull by javalinLazy {
             servlet.router.findHttpHandlerEntries(ctx.method(), requestUri).firstOrNull()
         }
+        val isResourceHandler = httpHandlerOrNull == null && ctx.method() in setOf(HEAD, GET)
+        val matchedRouteRoles by javalinLazy { servlet.matchedRoles(ctx, requestUri) }
+        val resourceRouteRoles by javalinLazy { servlet.cfg.pvt.resourceHandler?.getResourceRouteRoles(ctx) ?: emptySet() }
+        val willMatch by javalinLazy {
+            ctx.setRouteRoles(if (isResourceHandler) resourceRouteRoles else matchedRouteRoles)
+            servlet.willMatch(ctx, requestUri)
+        }
         servlet.router.findHttpHandlerEntries(HandlerType.BEFORE_MATCHED, requestUri).forEach { entry ->
             if (willMatch) {
                 submitTask(LAST, Task(skipIfExceptionOccurred = true) {
diff --git a/javalin/src/main/java/io/javalin/http/staticfiles/ResourceHandler.java b/javalin/src/main/java/io/javalin/http/staticfiles/ResourceHandler.java
index 4f7f2b1e..309f4e07 100644
--- a/javalin/src/main/java/io/javalin/http/staticfiles/ResourceHandler.java
+++ b/javalin/src/main/java/io/javalin/http/staticfiles/ResourceHandler.java
@@ -1,6 +1,8 @@
 package io.javalin.http.staticfiles;
 
 import io.javalin.http.Context;
+import io.javalin.security.RouteRole;
+import java.util.Set;
 
 public interface ResourceHandler {
     boolean canHandle(Context context);
@@ -8,4 +10,6 @@ public interface ResourceHandler {
     boolean handle(Context context);
 
     boolean addStaticFileConfig(StaticFileConfig config);
+
+    Set<RouteRole> getResourceRouteRoles(Context ctx);
 }
diff --git a/javalin/src/main/java/io/javalin/http/staticfiles/StaticFileConfig.kt b/javalin/src/main/java/io/javalin/http/staticfiles/StaticFileConfig.kt
index 4e314b93..4494d0a5 100644
--- a/javalin/src/main/java/io/javalin/http/staticfiles/StaticFileConfig.kt
+++ b/javalin/src/main/java/io/javalin/http/staticfiles/StaticFileConfig.kt
@@ -3,6 +3,7 @@ package io.javalin.http.staticfiles
 import io.javalin.config.StaticFilesConfig
 import io.javalin.http.ContentType
 import io.javalin.http.Header
+import io.javalin.security.RouteRole
 import jakarta.servlet.http.HttpServletRequest
 import org.eclipse.jetty.server.handler.ContextHandler.AliasCheck
 
@@ -37,7 +38,8 @@ data class StaticFileConfig(
     @JvmField var aliasCheck: AliasCheck? = null,
     @JvmField var headers: Map<String, String> = mutableMapOf(Header.CACHE_CONTROL to "max-age=0"),
     @JvmField var skipFileFunction: (HttpServletRequest) -> Boolean = { false },
-    @JvmField val mimeTypes: MimeTypesConfig = MimeTypesConfig()
+    @JvmField val mimeTypes: MimeTypesConfig = MimeTypesConfig(),
+    @JvmField var roles: Set<RouteRole> = emptySet()
 ) {
     internal fun refinedToString(): String {
         return this.toString().replace(", skipFileFunction=(jakarta.servlet.http.HttpServletRequest) -> kotlin.Boolean", "")
diff --git a/javalin/src/main/java/io/javalin/jetty/JettyResourceHandler.kt b/javalin/src/main/java/io/javalin/jetty/JettyResourceHandler.kt
index 384845af..a1ca64d8 100644
--- a/javalin/src/main/java/io/javalin/jetty/JettyResourceHandler.kt
+++ b/javalin/src/main/java/io/javalin/jetty/JettyResourceHandler.kt
@@ -10,6 +10,7 @@ import io.javalin.config.PrivateConfig
 import io.javalin.http.Context
 import io.javalin.http.staticfiles.Location
 import io.javalin.http.staticfiles.StaticFileConfig
+import io.javalin.security.RouteRole
 import io.javalin.util.JavalinException
 import io.javalin.util.JavalinLogger
 import io.javalin.util.javalinLazy
@@ -44,7 +45,8 @@ class JettyResourceHandler(val pvt: PrivateConfig) : JavalinResourceHandler {
 
     override fun canHandle(ctx: Context) = nonSkippedHandlers(ctx.jettyReq()).any { handler ->
         try {
-            fileOrWelcomeFile(handler, ctx.target) != null
+            val target = URLDecoder.decode(ctx.target, "UTF-8")
+            fileOrWelcomeFile(handler, target) != null
         } catch (e: Exception) {
             e.message?.contains("Rejected alias reference") == true ||  // we want to say these are un-handleable (404)
                 e.message?.contains("Failed alias check") == true // we want to say these are un-handleable (404)
@@ -86,6 +88,17 @@ class JettyResourceHandler(val pvt: PrivateConfig) : JavalinResourceHandler {
 
     private val Context.target get() = this.req().requestURI.removePrefix(this.req().contextPath)
 
+    override fun getResourceRouteRoles(ctx: Context): Set<RouteRole> {
+        nonSkippedHandlers(ctx.jettyReq()).forEach { handler ->
+            val target = URLDecoder.decode(ctx.target, "UTF-8")
+            val fileOrWelcomeFile = fileOrWelcomeFile(handler, target)
+            if (fileOrWelcomeFile != null) {
+                return handler.config.roles;
+            }
+        }
+        return emptySet();
+    }
+
 }
 
 open class ConfigurableHandler(val config: StaticFileConfig, jettyServer: Server) : ResourceHandler() {


Test patch:
diff --git a/javalin/src/test/java/io/javalin/staticfiles/TestStaticFileAccess.kt b/javalin/src/test/java/io/javalin/staticfiles/TestStaticFileAccess.kt
new file mode 100644
index 00000000..d09c07ac
--- /dev/null
+++ b/javalin/src/test/java/io/javalin/staticfiles/TestStaticFileAccess.kt
@@ -0,0 +1,116 @@
+package io.javalin.staticfiles
+
+import io.javalin.Javalin
+import io.javalin.config.JavalinConfig
+import io.javalin.http.ContentType
+import io.javalin.http.Header
+import io.javalin.http.HttpStatus
+import io.javalin.http.HttpStatus.UNAUTHORIZED
+import io.javalin.http.UnauthorizedResponse
+import io.javalin.http.staticfiles.Location
+import io.javalin.security.RouteRole
+import io.javalin.testing.TestUtil
+import io.javalin.testing.httpCode
+import kong.unirest.Unirest
+import org.assertj.core.api.Assertions.assertThat
+import org.junit.jupiter.api.Test
+import java.io.File
+
+
+class TestStaticFileAccess {
+
+    enum class MyRole : RouteRole { ROLE_ONE, ROLE_TWO}
+
+    private fun addAuthentication(config: JavalinConfig) {
+        config.router.mount {
+            it.beforeMatched { ctx ->
+                val role: RouteRole? = ctx.queryParam("role")?.let { role -> MyRole.valueOf(role) }
+                val routeRoles = ctx.routeRoles()
+                if (routeRoles != emptySet<RouteRole>() && role !in routeRoles) {
+                    throw UnauthorizedResponse()
+                }
+            }
+        }
+    }
+
+    private val defaultStaticResourceApp: Javalin by lazy {
+        Javalin.create {
+            it.staticFiles.add("/public", Location.CLASSPATH, roles = setOf(MyRole.ROLE_ONE))
+            addAuthentication(it)
+        }
+    }
+
+    private val passingStaticFileConfigResourceApp: Javalin by lazy {
+        Javalin.create {
+            it.staticFiles.add { staticFileConfig ->
+                staticFileConfig.hostedPath = "/"
+                staticFileConfig.directory = "/public"
+                staticFileConfig.location = Location.CLASSPATH
+                staticFileConfig.roles = setOf(MyRole.ROLE_ONE)
+            }
+            addAuthentication(it)
+        }
+    }
+
+    private val externalStaticResourceApp: Javalin by lazy {
+        Javalin.create {
+            it.staticFiles.add("src/test/external/", Location.EXTERNAL, roles = setOf(MyRole.ROLE_ONE))
+            addAuthentication(it)
+        }
+    }
+    private val multiLocationStaticResourceApp: Javalin by lazy {
+        Javalin.create {
+            it.staticFiles.add("src/test/external/", Location.EXTERNAL, roles = setOf(MyRole.ROLE_ONE))
+            it.staticFiles.add("/public/immutable", Location.CLASSPATH, roles = setOf(MyRole.ROLE_TWO))
+            addAuthentication(it)
+        }
+    }
+
+    @Test
+    fun `Authentication works for overlapping route and file name`() = TestUtil.test(defaultStaticResourceApp) { app, http ->
+        app.get("/file") { it.result("Test Route") }
+        assertThat(callWithRole(http.origin, "/file", role = "ROLE_ONE")).isEqualTo("Test Route")
+    }
+
+    @Test
+    fun `Authentication works for passing static config`() = TestUtil.test(passingStaticFileConfigResourceApp) { _, http ->
+        assertThat(callWithRole(http.origin, "/html.html", "ROLE_TWO")).isEqualTo(UNAUTHORIZED.message)
+        assertThat(http.get("/html.html?role=ROLE_ONE").httpCode()).isEqualTo(HttpStatus.OK)
+        assertThat(http.get("/html.html?role=ROLE_ONE").headers.getFirst(Header.CONTENT_TYPE)).contains(ContentType.HTML)
+        assertThat(http.getBody("/html.html?role=ROLE_ONE")).contains("HTML works")
+    }
+
+    @Test
+    fun `Authentication works for classPath location`() = TestUtil.test(defaultStaticResourceApp) { _, http ->
+        assertThat(callWithRole(http.origin, "/html.html", "ROLE_TWO")).isEqualTo(UNAUTHORIZED.message)
+        assertThat(http.get("/html.html?role=ROLE_ONE").httpCode()).isEqualTo(HttpStatus.OK)
+        assertThat(http.get("/html.html?role=ROLE_ONE").headers.getFirst(Header.CONTENT_TYPE)).contains(ContentType.HTML)
+        assertThat(http.getBody("/html.html?role=ROLE_ONE")).contains("HTML works")
+    }
+
+    @Test
+    fun `Authentication works for external location`() = TestUtil.test(externalStaticResourceApp) { _, http ->
+        assertThat(callWithRole(http.origin, "/html.html", "ROLE_TWO")).isEqualTo(UNAUTHORIZED.message)
+        assertThat(http.get("/html.html?role=ROLE_ONE").httpCode()).isEqualTo(HttpStatus.OK)
+        assertThat(http.get("/html.html?role=ROLE_ONE").headers.getFirst(Header.CONTENT_TYPE)).contains(ContentType.HTML)
+        assertThat(http.getBody("/html.html?role=ROLE_ONE")).contains("HTML works")
+    }
+
+    @Test
+    fun `Authentication works for multiple location`() = TestUtil.test(multiLocationStaticResourceApp) { _, http ->
+        assertThat(callWithRole(http.origin, "/txt.txt", "ROLE_TWO")).isEqualTo(UNAUTHORIZED.message)
+        assertThat(callWithRole(http.origin, "/txt.txt", "ROLE_ONE")).isEqualTo(
+            File("src/test/external/txt.txt").takeIf { it.exists() }?.readText())
+        assertThat(http.get("/txt.txt?role=ROLE_ONE").httpCode()).isEqualTo(HttpStatus.OK)
+
+        val content = javaClass.classLoader.getResourceAsStream("public/immutable/library-1.0.0.min.js")
+            ?.bufferedReader()
+            ?.use { it.readText() }
+        assertThat(callWithRole(http.origin, "/library-1.0.0.min.js", "ROLE_ONE")).isEqualTo(UNAUTHORIZED.message)
+        assertThat(callWithRole(http.origin, "/library-1.0.0.min.js", "ROLE_TWO")).isEqualTo(content)
+        assertThat(http.get("/library-1.0.0.min.js?role=ROLE_TWO").httpCode()).isEqualTo(HttpStatus.OK)
+    }
+
+    private fun callWithRole(origin: String, path: String, role: String) =
+        Unirest.get(origin + path).queryString("role", role).asString().body
+}