@hoanghainh/komagu 2.1.2 → 2.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +14 -0
- package/README.md +1 -1
- package/assess/lsa/knowledge/bfla-patterns.md +8 -0
- package/assess/lsa/knowledge/mass-assignment-patterns.md +7 -0
- package/examples/member-portal/backend-dotnet/MemberPortal.Backend.csproj +2 -2
- package/operate/cli/__init__.py +1 -1
- package/package.json +1 -1
- package/remediate/authz/README.md +122 -1
- package/remediate/authz/dotnet/src/Komagu.Security.AspNetCore/KomaguRoleOptions.cs +20 -0
- package/remediate/authz/dotnet/src/Komagu.Security.AspNetCore/RequireRoleAttribute.cs +34 -0
- package/remediate/authz/dotnet/src/Komagu.Security.AspNetCore/RoleAuthorizationFilter.cs +114 -0
- package/remediate/authz/dotnet/src/Komagu.Security.AspNetCore/RoleDenyResponse.cs +17 -0
- package/remediate/authz/dotnet/src/Komagu.Security.AspNetCore/ServiceCollectionExtensions.cs +16 -0
- package/remediate/authz/dotnet/src/Komagu.Security.EntityFrameworkCore/Komagu.Security.EntityFrameworkCore.csproj +1 -1
- package/remediate/authz/dotnet/tests/Komagu.Security.Authorization.Tests/Komagu.Security.Authorization.Tests.csproj +1 -1
- package/remediate/authz/dotnet/tests/Komagu.Security.Authorization.Tests/MassAssignmentDtoTests.cs +76 -0
- package/remediate/authz/dotnet/tests/Komagu.Security.Authorization.Tests/RoleAuthorizationFilterTests.cs +233 -0
- package/remediate/authz/golang/massassign.go +16 -0
- package/remediate/authz/golang/massassign_test.go +135 -0
- package/remediate/authz/golang/roles.go +168 -0
- package/remediate/authz/golang/roles_test.go +158 -0
- package/remediate/authz/nodejs/src/express/role-middleware.d.ts +14 -0
- package/remediate/authz/nodejs/src/express/role-middleware.js +52 -0
- package/remediate/authz/nodejs/src/index.d.ts +2 -0
- package/remediate/authz/nodejs/src/index.js +17 -2
- package/remediate/authz/nodejs/src/interfaces.d.ts +19 -4
- package/remediate/authz/nodejs/src/interfaces.js +10 -0
- package/remediate/authz/nodejs/src/mass-assignment.d.ts +8 -0
- package/remediate/authz/nodejs/src/mass-assignment.js +40 -0
- package/remediate/authz/nodejs/src/nestjs/decorators.d.ts +16 -0
- package/remediate/authz/nodejs/src/nestjs/decorators.js +27 -0
- package/remediate/authz/nodejs/src/nestjs/guards.d.ts +5 -0
- package/remediate/authz/nodejs/src/nestjs/guards.js +69 -2
- package/remediate/authz/nodejs/src/roles.js +90 -0
- package/remediate/authz/nodejs/src/service.d.ts +6 -0
- package/remediate/authz/nodejs/src/service.js +30 -0
- package/remediate/authz/nodejs/tests/test-authz.js +4 -0
- package/remediate/authz/nodejs/tests/test-roles.js +278 -0
- package/remediate/authz/spring/build.gradle.kts +7 -2
- package/remediate/authz/spring/src/main/kotlin/io/komagu/security/authorization/SecurityAuditEvent.kt +10 -2
- package/remediate/authz/spring/src/main/kotlin/io/komagu/security/data/MassAssignment.kt +18 -0
- package/remediate/authz/spring/src/main/kotlin/io/komagu/security/spring/AuthenticationRequiredException.kt +14 -0
- package/remediate/authz/spring/src/main/kotlin/io/komagu/security/spring/KomaguSecurityAutoConfiguration.kt +20 -0
- package/remediate/authz/spring/src/main/kotlin/io/komagu/security/spring/RequireRole.kt +34 -0
- package/remediate/authz/spring/src/main/kotlin/io/komagu/security/spring/RoleAspect.kt +97 -0
- package/remediate/authz/spring/src/main/kotlin/io/komagu/security/spring/RoleDeniedException.kt +14 -0
- package/remediate/authz/spring/src/test/kotlin/io/komagu/security/KomaguSecurityAutoConfigurationTest.kt +34 -0
- package/remediate/authz/spring/src/test/kotlin/io/komagu/security/MassAssignmentTest.kt +81 -0
- package/remediate/authz/spring/src/test/kotlin/io/komagu/security/RoleAspectTest.kt +196 -0
- package/remediate/identity/dotnet/src/Komagu.Security.Identity/Komagu.Security.Identity.csproj +1 -1
- package/remediate/identity/dotnet/tests/Komagu.Security.Identity.Tests/Komagu.Security.Identity.Tests.csproj +1 -1
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
package io.komagu.security
|
|
2
|
+
|
|
3
|
+
import com.fasterxml.jackson.databind.DeserializationFeature
|
|
4
|
+
import com.fasterxml.jackson.databind.exc.UnrecognizedPropertyException
|
|
5
|
+
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
|
|
6
|
+
import com.fasterxml.jackson.module.kotlin.readValue
|
|
7
|
+
import io.komagu.security.data.allowFields
|
|
8
|
+
import org.junit.jupiter.api.Test
|
|
9
|
+
import org.junit.jupiter.api.assertThrows
|
|
10
|
+
import java.math.BigDecimal
|
|
11
|
+
import kotlin.test.assertEquals
|
|
12
|
+
|
|
13
|
+
class MassAssignmentTest {
|
|
14
|
+
|
|
15
|
+
/** Persistent entity: role, isAdmin and balance are privileged and server-controlled. */
|
|
16
|
+
data class UserAccount(
|
|
17
|
+
val id: String,
|
|
18
|
+
val name: String,
|
|
19
|
+
val email: String,
|
|
20
|
+
val role: String,
|
|
21
|
+
val isAdmin: Boolean,
|
|
22
|
+
val balance: BigDecimal
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Request DTO: declares ONLY the fields a user may edit on their own profile.
|
|
27
|
+
*
|
|
28
|
+
* Jackson's own default is FAIL_ON_UNKNOWN_PROPERTIES = true, so `role` / `isAdmin` / `balance` in the body
|
|
29
|
+
* make deserialization fail (map that to 400). Spring Boot's auto-configured ObjectMapper turns the feature
|
|
30
|
+
* OFF, so those fields are silently ignored instead. Either way they can never reach the entity, because the
|
|
31
|
+
* DTO has nowhere to put them and [applyTo] copies the allowed fields explicitly.
|
|
32
|
+
*/
|
|
33
|
+
data class ProfileUpdate(val name: String, val email: String) {
|
|
34
|
+
fun applyTo(account: UserAccount): UserAccount = account.copy(name = name, email = email)
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
private val attackBody =
|
|
38
|
+
"""{"name":"Mallory","email":"m@example.test","role":"admin","isAdmin":true,"balance":1000000}"""
|
|
39
|
+
|
|
40
|
+
private val stored = UserAccount("u-1", "Alice", "a@example.test", "user", false, BigDecimal("10.00"))
|
|
41
|
+
|
|
42
|
+
@Test
|
|
43
|
+
fun `allowFields keeps only allowed keys and returns a new map`() {
|
|
44
|
+
val source = mapOf("name" to "Mallory", "email" to "m@example.test", "role" to "admin", "isAdmin" to true)
|
|
45
|
+
val snapshot = source.toMap()
|
|
46
|
+
|
|
47
|
+
val filtered = allowFields(source, "name", "email")
|
|
48
|
+
|
|
49
|
+
assertEquals(mapOf("name" to "Mallory", "email" to "m@example.test"), filtered)
|
|
50
|
+
assertEquals(snapshot, source)
|
|
51
|
+
assertEquals(emptyMap(), allowFields(source))
|
|
52
|
+
assertEquals(emptyMap(), allowFields(source, "Name"))
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
@Test
|
|
56
|
+
fun `allowFields keeps explicit null values of allowed keys`() {
|
|
57
|
+
assertEquals(mapOf("email" to null), allowFields(mapOf("email" to null, "role" to null), "email"))
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
@Test
|
|
61
|
+
fun `strict mapper rejects privileged fields in the body`() {
|
|
62
|
+
val strict = jacksonObjectMapper().enable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES)
|
|
63
|
+
assertThrows<UnrecognizedPropertyException> { strict.readValue<ProfileUpdate>(attackBody) }
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
@Test
|
|
67
|
+
fun `lenient mapper ignores privileged fields and the explicit copy cannot change them`() {
|
|
68
|
+
// Same setting as Spring Boot's default ObjectMapper.
|
|
69
|
+
val lenient = jacksonObjectMapper().disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES)
|
|
70
|
+
val update = lenient.readValue<ProfileUpdate>(attackBody)
|
|
71
|
+
|
|
72
|
+
val saved = update.applyTo(stored)
|
|
73
|
+
|
|
74
|
+
assertEquals("Mallory", saved.name)
|
|
75
|
+
assertEquals("m@example.test", saved.email)
|
|
76
|
+
assertEquals("user", saved.role)
|
|
77
|
+
assertEquals(false, saved.isAdmin)
|
|
78
|
+
assertEquals(BigDecimal("10.00"), saved.balance)
|
|
79
|
+
assertEquals("u-1", saved.id)
|
|
80
|
+
}
|
|
81
|
+
}
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
package io.komagu.security
|
|
2
|
+
|
|
3
|
+
import io.komagu.security.authorization.DefaultKomaguSecurityManager
|
|
4
|
+
import io.komagu.security.authorization.SecurityAuditEvent
|
|
5
|
+
import io.komagu.security.authorization.SecurityAuditSink
|
|
6
|
+
import io.komagu.security.spring.AuthenticationRequiredException
|
|
7
|
+
import io.komagu.security.spring.RequireRole
|
|
8
|
+
import io.komagu.security.spring.ResourceNotFoundException
|
|
9
|
+
import io.komagu.security.spring.RoleAspect
|
|
10
|
+
import io.komagu.security.spring.RoleDeniedException
|
|
11
|
+
import org.junit.jupiter.api.AfterEach
|
|
12
|
+
import org.junit.jupiter.api.Test
|
|
13
|
+
import org.junit.jupiter.api.assertThrows
|
|
14
|
+
import org.springframework.aop.aspectj.annotation.AspectJProxyFactory
|
|
15
|
+
import org.springframework.mock.web.MockHttpServletRequest
|
|
16
|
+
import org.springframework.security.authentication.AnonymousAuthenticationToken
|
|
17
|
+
import org.springframework.security.authentication.TestingAuthenticationToken
|
|
18
|
+
import org.springframework.security.core.authority.AuthorityUtils
|
|
19
|
+
import org.springframework.security.core.context.SecurityContextHolder
|
|
20
|
+
import org.springframework.web.context.request.RequestContextHolder
|
|
21
|
+
import org.springframework.web.context.request.ServletRequestAttributes
|
|
22
|
+
import kotlin.test.assertEquals
|
|
23
|
+
import kotlin.test.assertTrue
|
|
24
|
+
|
|
25
|
+
class RoleAspectTest {
|
|
26
|
+
|
|
27
|
+
open class AdminService {
|
|
28
|
+
@RequireRole("admin")
|
|
29
|
+
open fun deleteUser(userId: String): String = "deleted-$userId"
|
|
30
|
+
|
|
31
|
+
@RequireRole("admin", "auditor")
|
|
32
|
+
open fun exportLogs(): String = "logs"
|
|
33
|
+
|
|
34
|
+
@RequireRole("admin", hideAsNotFound = true)
|
|
35
|
+
open fun hiddenEndpoint(): String = "hidden"
|
|
36
|
+
|
|
37
|
+
@RequireRole("ROLE_admin")
|
|
38
|
+
open fun prefixedRequirement(): String = "prefixed"
|
|
39
|
+
|
|
40
|
+
@RequireRole
|
|
41
|
+
open fun misconfigured(): String = "never"
|
|
42
|
+
|
|
43
|
+
/** Simulates a controller that also receives client-controlled role data. */
|
|
44
|
+
@RequireRole("admin")
|
|
45
|
+
open fun promote(role: String, body: Map<String, Any?>): String = "promoted-$role"
|
|
46
|
+
|
|
47
|
+
open fun unprotected(): String = "open"
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
@RequireRole("admin")
|
|
51
|
+
open class AdminOnlyController {
|
|
52
|
+
open fun dashboard(): String = "dashboard"
|
|
53
|
+
|
|
54
|
+
@RequireRole("auditor")
|
|
55
|
+
open fun auditTrail(): String = "audit"
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
private val events = mutableListOf<SecurityAuditEvent>()
|
|
59
|
+
private val sink = SecurityAuditSink { events.add(it) }
|
|
60
|
+
|
|
61
|
+
private fun aspect(hideDeniedAsNotFound: Boolean = false) =
|
|
62
|
+
RoleAspect(DefaultKomaguSecurityManager(sink), sink, hideDeniedAsNotFound)
|
|
63
|
+
|
|
64
|
+
private inline fun <reified T : Any> proxy(target: T, aspect: RoleAspect = aspect()): T {
|
|
65
|
+
val factory = AspectJProxyFactory(target)
|
|
66
|
+
factory.isProxyTargetClass = true
|
|
67
|
+
factory.addAspect(aspect)
|
|
68
|
+
return factory.getProxy()
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
private fun authenticate(user: String, vararg authorities: String) {
|
|
72
|
+
val auth = TestingAuthenticationToken(user, null, *authorities)
|
|
73
|
+
auth.details = mapOf("tenant_id" to "tenant-1")
|
|
74
|
+
SecurityContextHolder.getContext().authentication = auth
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
@AfterEach
|
|
78
|
+
fun clear() {
|
|
79
|
+
SecurityContextHolder.clearContext()
|
|
80
|
+
RequestContextHolder.resetRequestAttributes()
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
@Test
|
|
84
|
+
fun `caller with the required role is allowed`() {
|
|
85
|
+
authenticate("alice", "ROLE_admin")
|
|
86
|
+
assertEquals("deleted-42", proxy(AdminService()).deleteUser("42"))
|
|
87
|
+
assertTrue(events.isEmpty())
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
@Test
|
|
91
|
+
fun `any one of several required roles is enough`() {
|
|
92
|
+
authenticate("bob", "ROLE_auditor")
|
|
93
|
+
assertEquals("logs", proxy(AdminService()).exportLogs())
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
@Test
|
|
97
|
+
fun `caller without the role is denied with 403`() {
|
|
98
|
+
authenticate("mallory", "ROLE_user")
|
|
99
|
+
assertThrows<RoleDeniedException> { proxy(AdminService()).deleteUser("42") }
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
@Test
|
|
103
|
+
fun `annotation attribute hides the endpoint with 404`() {
|
|
104
|
+
authenticate("mallory", "ROLE_user")
|
|
105
|
+
assertThrows<ResourceNotFoundException> { proxy(AdminService()).hiddenEndpoint() }
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
@Test
|
|
109
|
+
fun `configured default hides every denied endpoint with 404`() {
|
|
110
|
+
authenticate("mallory", "ROLE_user")
|
|
111
|
+
assertThrows<ResourceNotFoundException> {
|
|
112
|
+
proxy(AdminService(), aspect(hideDeniedAsNotFound = true)).deleteUser("42")
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
@Test
|
|
117
|
+
fun `unauthenticated caller gets 401`() {
|
|
118
|
+
assertThrows<AuthenticationRequiredException> { proxy(AdminService()).deleteUser("42") }
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
@Test
|
|
122
|
+
fun `anonymous token is treated as unauthenticated`() {
|
|
123
|
+
SecurityContextHolder.getContext().authentication = AnonymousAuthenticationToken(
|
|
124
|
+
"key", "anonymousUser", AuthorityUtils.createAuthorityList("ROLE_ANONYMOUS", "ROLE_admin")
|
|
125
|
+
)
|
|
126
|
+
assertThrows<AuthenticationRequiredException> { proxy(AdminService()).deleteUser("42") }
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
@Test
|
|
130
|
+
fun `role claimed in request parameter, header, body or argument is ignored`() {
|
|
131
|
+
authenticate("mallory", "ROLE_user")
|
|
132
|
+
val request = MockHttpServletRequest("POST", "/admin/promote")
|
|
133
|
+
request.addParameter("role", "admin")
|
|
134
|
+
request.addHeader("X-Role", "admin")
|
|
135
|
+
request.contentType = "application/json"
|
|
136
|
+
request.setContent("""{"role":"admin","isAdmin":true}""".toByteArray())
|
|
137
|
+
RequestContextHolder.setRequestAttributes(ServletRequestAttributes(request))
|
|
138
|
+
|
|
139
|
+
assertThrows<RoleDeniedException> {
|
|
140
|
+
proxy(AdminService()).promote("admin", mapOf("role" to "admin", "isAdmin" to true))
|
|
141
|
+
}
|
|
142
|
+
assertEquals(1, events.size)
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
@Test
|
|
146
|
+
fun `ROLE_ prefix is normalized on both sides, the name stays case-sensitive`() {
|
|
147
|
+
authenticate("alice", "admin")
|
|
148
|
+
assertEquals("deleted-1", proxy(AdminService()).deleteUser("1"))
|
|
149
|
+
assertEquals("prefixed", proxy(AdminService()).prefixedRequirement())
|
|
150
|
+
|
|
151
|
+
authenticate("carol", "ROLE_Admin", "SCOPE_admin")
|
|
152
|
+
assertThrows<RoleDeniedException> { proxy(AdminService()).deleteUser("1") }
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
@Test
|
|
156
|
+
fun `denial emits a RoleDenied audit event with actor and required roles`() {
|
|
157
|
+
authenticate("mallory", "ROLE_user")
|
|
158
|
+
assertThrows<RoleDeniedException> { proxy(AdminService()).exportLogs() }
|
|
159
|
+
|
|
160
|
+
val event = events.single()
|
|
161
|
+
assertEquals(SecurityAuditEvent.ROLE_DENIED, event.eventName)
|
|
162
|
+
assertEquals("Komagu.Authorization.RoleDenied", event.eventName)
|
|
163
|
+
assertEquals("mallory", event.actorUserId)
|
|
164
|
+
assertEquals("tenant-1", event.actorTenantId)
|
|
165
|
+
assertEquals("AdminService.exportLogs", event.resourceId)
|
|
166
|
+
assertTrue(event.reason.contains("admin") && event.reason.contains("auditor"), event.reason)
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
@Test
|
|
170
|
+
fun `failing audit sink does not change the denial`() {
|
|
171
|
+
authenticate("mallory", "ROLE_user")
|
|
172
|
+
val failing = SecurityAuditSink { error("sink down") }
|
|
173
|
+
val aspect = RoleAspect(DefaultKomaguSecurityManager(failing), failing)
|
|
174
|
+
assertThrows<RoleDeniedException> { proxy(AdminService(), aspect).deleteUser("42") }
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
@Test
|
|
178
|
+
fun `empty role list fails fast before the method runs`() {
|
|
179
|
+
authenticate("alice", "ROLE_admin")
|
|
180
|
+
assertThrows<IllegalStateException> { proxy(AdminService()).misconfigured() }
|
|
181
|
+
assertThrows<IllegalStateException> { aspect().requireAnyRole(listOf(" ")) }
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
@Test
|
|
185
|
+
fun `class-level annotation applies and a method-level one overrides it`() {
|
|
186
|
+
authenticate("bob", "ROLE_auditor")
|
|
187
|
+
val controller = proxy(AdminOnlyController())
|
|
188
|
+
assertThrows<RoleDeniedException> { controller.dashboard() }
|
|
189
|
+
assertEquals("audit", controller.auditTrail())
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
@Test
|
|
193
|
+
fun `unannotated method is not intercepted`() {
|
|
194
|
+
assertEquals("open", proxy(AdminService()).unprotected())
|
|
195
|
+
}
|
|
196
|
+
}
|
package/remediate/identity/dotnet/src/Komagu.Security.Identity/Komagu.Security.Identity.csproj
CHANGED
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
</ItemGroup>
|
|
18
18
|
|
|
19
19
|
<ItemGroup>
|
|
20
|
-
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="8.0.
|
|
20
|
+
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="8.0.33" />
|
|
21
21
|
</ItemGroup>
|
|
22
22
|
|
|
23
23
|
</Project>
|
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
</ItemGroup>
|
|
14
14
|
|
|
15
15
|
<ItemGroup>
|
|
16
|
-
<PackageReference Include="Microsoft.AspNetCore.TestHost" Version="8.0.
|
|
16
|
+
<PackageReference Include="Microsoft.AspNetCore.TestHost" Version="8.0.33" />
|
|
17
17
|
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.11.0" />
|
|
18
18
|
<PackageReference Include="xunit" Version="2.9.3" />
|
|
19
19
|
<PackageReference Include="xunit.runner.visualstudio" Version="4.0.1">
|