@hoanghainh/komagu 2.1.2 → 2.2.0
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 +7 -0
- package/assess/lsa/knowledge/bfla-patterns.md +8 -0
- package/assess/lsa/knowledge/mass-assignment-patterns.md +7 -0
- 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/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 +5 -0
- 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/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,13 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to `@hoanghainh/komagu` (published as `komainu-guard` up to v1.7.0) are documented here. From v1.2.2 on, entries are generated by [release-please](https://github.com/googleapis/release-please) from [Conventional Commits](https://www.conventionalcommits.org/). Earlier entries are copied from the [GitHub Releases](https://github.com/hoanghainh1188/komainu/releases).
|
|
4
4
|
|
|
5
|
+
## [2.2.0](https://github.com/hoanghainh1188/komagu/compare/v2.1.2...v2.2.0) (2026-11-01)
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
### Features
|
|
9
|
+
|
|
10
|
+
* **remediate:** add role guards and DTO allow-list helpers for BFLA and mass assignment ([#337](https://github.com/hoanghainh1188/komagu/issues/337)) ([e8c0463](https://github.com/hoanghainh1188/komagu/commit/e8c046322fa10c3c29a5d896cdbb41de65282c34))
|
|
11
|
+
|
|
5
12
|
## [2.1.2](https://github.com/hoanghainh1188/komagu/compare/v2.1.1...v2.1.2) (2026-11-01)
|
|
6
13
|
|
|
7
14
|
|
|
@@ -89,3 +89,11 @@ route named `/orders/close-month` can still be an admin function.
|
|
|
89
89
|
|
|
90
90
|
Adjust with `severity-and-cwe.md` (multi-tenant impact +1, compensating
|
|
91
91
|
control outside code −1).
|
|
92
|
+
|
|
93
|
+
## 7. Recommended fix
|
|
94
|
+
|
|
95
|
+
Check the role on the server, from the authenticated principal only, before the privileged action.
|
|
96
|
+
Komagu ships a drop-in guard per stack (`[RequireRole]` for .NET, `@RequireRole` for Spring Boot,
|
|
97
|
+
`requireRole` / `@RequireRole` for Express and NestJS, `RequireRole` middleware for Go): see
|
|
98
|
+
`remediate/authz/README.md`, section 7. Use it in the finding's `recommendation` when the target stack
|
|
99
|
+
matches; never suggest reading the role from the request.
|
|
@@ -75,3 +75,10 @@ Bindings to DTO or form classes that are not entities are not reported.
|
|
|
75
75
|
| Only harmless fields reachable | low (or no finding) |
|
|
76
76
|
|
|
77
77
|
Adjust with `severity-and-cwe.md`.
|
|
78
|
+
|
|
79
|
+
## 6. Recommended fix
|
|
80
|
+
|
|
81
|
+
Bind a DTO with only the editable fields and copy them onto the entity explicitly, or filter map-shaped
|
|
82
|
+
input with an allow-list. Komagu has a tested example per stack and an `allowFields` helper for Spring
|
|
83
|
+
Boot, Express and Go: see `remediate/authz/README.md`, section 7. Use it in the finding's `recommendation`
|
|
84
|
+
when the target stack matches.
|
package/operate/cli/__init__.py
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hoanghainh/komagu",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.2.0",
|
|
4
4
|
"description": "Security-First Legacy Modernization Platform CLI (LSA Kit, IAM Perimeter, Cross-User Testing)",
|
|
5
5
|
"main": "remediate/authz/nodejs/src/index.js",
|
|
6
6
|
"types": "remediate/authz/nodejs/src/index.d.ts",
|
|
@@ -276,7 +276,128 @@ func RegisterOrderRoutes(mux *http.ServeMux, authorizer *authz.Authorizer) {
|
|
|
276
276
|
|
|
277
277
|
---
|
|
278
278
|
|
|
279
|
-
## 7.
|
|
279
|
+
## 7. Function-Level Authorization (BFLA) and Mass Assignment
|
|
280
|
+
|
|
281
|
+
These helpers fix the two privilege findings of the audit kit: **BFLA** (CWE-285, see
|
|
282
|
+
[`bfla-patterns.md`](../../assess/lsa/knowledge/bfla-patterns.md)) and **mass assignment** (CWE-915, see
|
|
283
|
+
[`mass-assignment-patterns.md`](../../assess/lsa/knowledge/mass-assignment-patterns.md)).
|
|
284
|
+
|
|
285
|
+
The role guard behaves the same in every module:
|
|
286
|
+
|
|
287
|
+
| Rule | Behaviour |
|
|
288
|
+
|---|---|
|
|
289
|
+
| Role source | The authenticated principal only (claims, granted authorities, `req.user`, request context). Never route values, query, form, body or headers |
|
|
290
|
+
| Not authenticated | 401 |
|
|
291
|
+
| Match | The caller needs at least one of the listed roles (exact, case-sensitive) |
|
|
292
|
+
| Denied | 403 by default; a module setting answers 404 to hide the endpoint |
|
|
293
|
+
| Audit | Each denial emits `Komagu.Authorization.RoleDenied` with actor ids and the required roles |
|
|
294
|
+
| Bad configuration | Empty or blank role lists and unsupported deny statuses fail fast |
|
|
295
|
+
|
|
296
|
+
For mass assignment, bind a DTO that lists only the fields the caller may change and copy them onto the
|
|
297
|
+
entity explicitly. Never bind the entity itself or rely on `[Bind(Include = "...")]`. Where the input is a
|
|
298
|
+
map, `allowFields` keeps only the listed keys and never mutates its input.
|
|
299
|
+
|
|
300
|
+
### .NET 8
|
|
301
|
+
|
|
302
|
+
```csharp
|
|
303
|
+
// Program.cs
|
|
304
|
+
builder.Services.AddKomaguAuthorization();
|
|
305
|
+
builder.Services.ConfigureKomaguRoles(o =>
|
|
306
|
+
{
|
|
307
|
+
o.RoleClaimType = "role"; // default: ClaimTypes.Role
|
|
308
|
+
o.DenyWith = RoleDenyResponse.NotFound; // default: Forbidden (403)
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
[HttpDelete("admin/users/{id}")]
|
|
312
|
+
[RequireRole("admin", "support")] // any one role; several attributes must all pass
|
|
313
|
+
public IActionResult DeleteUser(int id) => NoContent();
|
|
314
|
+
|
|
315
|
+
public sealed record ProfileUpdateDto(string Name, string Email)
|
|
316
|
+
{
|
|
317
|
+
public void ApplyTo(Customer customer)
|
|
318
|
+
{
|
|
319
|
+
customer.Name = Name;
|
|
320
|
+
customer.Email = Email; // Role / IsAdmin / Balance cannot be set from the request
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
```
|
|
324
|
+
|
|
325
|
+
`[RequireRole]` is an authorization filter, so it runs before model binding.
|
|
326
|
+
|
|
327
|
+
### Spring Boot 3
|
|
328
|
+
|
|
329
|
+
```kotlin
|
|
330
|
+
@RestController
|
|
331
|
+
@RequestMapping("/admin")
|
|
332
|
+
@RequireRole("admin") // class-level default
|
|
333
|
+
class AdminController(private val users: UserService) {
|
|
334
|
+
@DeleteMapping("/users/{id}")
|
|
335
|
+
fun delete(@PathVariable id: String) = users.delete(id) // ROLE_admin or admin passes
|
|
336
|
+
|
|
337
|
+
@GetMapping("/audit")
|
|
338
|
+
@RequireRole("admin", "auditor", hideAsNotFound = true) // method-level overrides class-level
|
|
339
|
+
fun audit() = users.auditTrail()
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
data class ProfileUpdate(val name: String, val email: String) {
|
|
343
|
+
fun applyTo(account: UserAccount) = account.copy(name = name, email = email)
|
|
344
|
+
}
|
|
345
|
+
val safe = allowFields(body, "name", "email") // io.komagu.security.data
|
|
346
|
+
```
|
|
347
|
+
|
|
348
|
+
Roles are the granted authorities of the authentication in `SecurityContextHolder`; one leading `ROLE_` is
|
|
349
|
+
ignored on both sides. Map JWT scopes or role claims to authorities in your `JwtAuthenticationConverter`.
|
|
350
|
+
Set `komagu.security.role.hide-denied-as-not-found=true` to answer every denial with 404. Spring Boot's
|
|
351
|
+
default `ObjectMapper` silently drops unknown fields such as `role`; the explicit copy is what keeps them out.
|
|
352
|
+
|
|
353
|
+
### Node.js (Express & NestJS)
|
|
354
|
+
|
|
355
|
+
```js
|
|
356
|
+
const { requireRole, allowFields } = require('@hoanghainh/komagu-authz');
|
|
357
|
+
|
|
358
|
+
app.delete('/admin/users/:id', requireRole('admin'), deleteUser);
|
|
359
|
+
app.post('/ops/rotate-keys', requireRole(['ops', 'admin'], { denyStatus: 404 }), rotateKeys);
|
|
360
|
+
|
|
361
|
+
app.patch('/me', async (req, res) => {
|
|
362
|
+
const changes = allowFields(req.body, ['name', 'email']); // role / isAdmin are dropped
|
|
363
|
+
res.json(await users.update(req.user.userId, changes));
|
|
364
|
+
});
|
|
365
|
+
```
|
|
366
|
+
|
|
367
|
+
```ts
|
|
368
|
+
@Controller('admin')
|
|
369
|
+
@UseGuards(KomaguRoleGuard)
|
|
370
|
+
export class AdminController {
|
|
371
|
+
@Delete('users/:id')
|
|
372
|
+
@RequireRole('admin')
|
|
373
|
+
deleteUser(@Param('id') id: string) { /* ... */ }
|
|
374
|
+
}
|
|
375
|
+
```
|
|
376
|
+
|
|
377
|
+
Roles come from `req.user.roles` and `req.user.role`. `allowFields` ignores `__proto__`, `constructor` and
|
|
378
|
+
`prototype`, and copies values by reference.
|
|
379
|
+
|
|
380
|
+
### Go
|
|
381
|
+
|
|
382
|
+
```go
|
|
383
|
+
// After verifying the token, attach the identity in your authentication middleware.
|
|
384
|
+
ctx := authz.WithIdentity(r.Context(), authz.Identity{UserID: claims.Sub, TenantID: claims.TenantID, Roles: claims.Roles})
|
|
385
|
+
|
|
386
|
+
auth := authz.NewAuthorizer(nil)
|
|
387
|
+
mux.Handle("/admin/users", authenticate(auth.RequireRole([]string{"admin"})(adminUsers)))
|
|
388
|
+
mux.Handle("/internal/reindex", authenticate(auth.RequireRole(
|
|
389
|
+
[]string{"admin", "ops"}, authz.WithDenyStatus(http.StatusNotFound))(reindex)))
|
|
390
|
+
|
|
391
|
+
patch := authz.AllowFields(body, "name", "email") // or decode into a narrow struct:
|
|
392
|
+
dec := json.NewDecoder(r.Body)
|
|
393
|
+
dec.DisallowUnknownFields() // {"role":"admin"} -> 400
|
|
394
|
+
```
|
|
395
|
+
|
|
396
|
+
`RequireRole` panics at route registration on an empty role list or a deny status other than 403 / 404.
|
|
397
|
+
|
|
398
|
+
---
|
|
399
|
+
|
|
400
|
+
## 8. Test Verification & D5 Runner
|
|
280
401
|
|
|
281
402
|
All multi-language adapters verify identical BOLA defense invariants:
|
|
282
403
|
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
namespace Komagu.Security.AspNetCore;
|
|
2
|
+
|
|
3
|
+
using System.Security.Claims;
|
|
4
|
+
|
|
5
|
+
/// <summary>
|
|
6
|
+
/// Options for <see cref="RequireRoleAttribute"/> / <see cref="RoleAuthorizationFilter"/>.
|
|
7
|
+
/// </summary>
|
|
8
|
+
public sealed class KomaguRoleOptions
|
|
9
|
+
{
|
|
10
|
+
/// <summary>
|
|
11
|
+
/// Claim type read from the authenticated <see cref="ClaimsPrincipal"/> to obtain the caller's roles.
|
|
12
|
+
/// Defaults to <see cref="ClaimTypes.Role"/>; set to <c>"role"</c> or <c>"roles"</c> for raw JWT claims.
|
|
13
|
+
/// </summary>
|
|
14
|
+
public string RoleClaimType { get; set; } = ClaimTypes.Role;
|
|
15
|
+
|
|
16
|
+
/// <summary>
|
|
17
|
+
/// Response returned when an authenticated caller lacks every required role. Defaults to 403 Forbidden.
|
|
18
|
+
/// </summary>
|
|
19
|
+
public RoleDenyResponse DenyWith { get; set; } = RoleDenyResponse.Forbidden;
|
|
20
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
namespace Komagu.Security.AspNetCore;
|
|
2
|
+
|
|
3
|
+
using Microsoft.AspNetCore.Mvc;
|
|
4
|
+
|
|
5
|
+
/// <summary>
|
|
6
|
+
/// Declarative function-level authorization (BFLA defense): the authenticated caller must hold at least one of the
|
|
7
|
+
/// given roles. Roles are read only from verified <c>HttpContext.User</c> claims, never from the request.
|
|
8
|
+
/// Returns 401 when unauthenticated and 403 (or 404, see <see cref="KomaguRoleOptions.DenyWith"/>) when denied.
|
|
9
|
+
/// </summary>
|
|
10
|
+
[AttributeUsage(AttributeTargets.Method | AttributeTargets.Class, AllowMultiple = true, Inherited = true)]
|
|
11
|
+
public sealed class RequireRoleAttribute : TypeFilterAttribute
|
|
12
|
+
{
|
|
13
|
+
public RequireRoleAttribute(params string[] roles)
|
|
14
|
+
: base(typeof(RoleAuthorizationFilter))
|
|
15
|
+
{
|
|
16
|
+
if (roles == null || roles.Length == 0)
|
|
17
|
+
{
|
|
18
|
+
throw new ArgumentException("At least one required role must be specified.", nameof(roles));
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
if (roles.Any(string.IsNullOrWhiteSpace))
|
|
22
|
+
{
|
|
23
|
+
throw new ArgumentException("Required roles must not be null, empty or whitespace.", nameof(roles));
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
Roles = roles.ToArray();
|
|
27
|
+
Arguments = new object[] { Roles.ToArray() };
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/// <summary>
|
|
31
|
+
/// Roles accepted by this guard; holding any one of them (exact, ordinal match) grants access.
|
|
32
|
+
/// </summary>
|
|
33
|
+
public IReadOnlyList<string> Roles { get; }
|
|
34
|
+
}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
namespace Komagu.Security.AspNetCore;
|
|
2
|
+
|
|
3
|
+
using System.Diagnostics;
|
|
4
|
+
using Komagu.Security.Authorization;
|
|
5
|
+
using Microsoft.AspNetCore.Http;
|
|
6
|
+
using Microsoft.AspNetCore.Mvc;
|
|
7
|
+
using Microsoft.AspNetCore.Mvc.Filters;
|
|
8
|
+
using Microsoft.Extensions.Options;
|
|
9
|
+
|
|
10
|
+
/// <summary>
|
|
11
|
+
/// Authorization filter enforcing that the authenticated caller holds at least one required role.
|
|
12
|
+
/// Runs before model binding, so request-supplied values (route, query, form, body, headers) can never grant a role.
|
|
13
|
+
/// </summary>
|
|
14
|
+
public sealed class RoleAuthorizationFilter : IAsyncAuthorizationFilter
|
|
15
|
+
{
|
|
16
|
+
/// <summary>
|
|
17
|
+
/// Audit event name emitted on a role denial.
|
|
18
|
+
/// </summary>
|
|
19
|
+
public const string RoleDeniedEventName = "Komagu.Authorization.RoleDenied";
|
|
20
|
+
|
|
21
|
+
private readonly string[] _requiredRoles;
|
|
22
|
+
private readonly ISecurityAuditSink _auditSink;
|
|
23
|
+
private readonly IKomaguAuthorizationService _authorizationService;
|
|
24
|
+
private readonly KomaguRoleOptions _options;
|
|
25
|
+
|
|
26
|
+
public RoleAuthorizationFilter(
|
|
27
|
+
string[] requiredRoles,
|
|
28
|
+
ISecurityAuditSink auditSink,
|
|
29
|
+
IKomaguAuthorizationService authorizationService,
|
|
30
|
+
IOptions<KomaguRoleOptions> options)
|
|
31
|
+
{
|
|
32
|
+
ArgumentNullException.ThrowIfNull(requiredRoles);
|
|
33
|
+
if (requiredRoles.Length == 0 || requiredRoles.Any(string.IsNullOrWhiteSpace))
|
|
34
|
+
{
|
|
35
|
+
throw new ArgumentException("At least one non-empty required role must be specified.", nameof(requiredRoles));
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
_requiredRoles = requiredRoles.ToArray();
|
|
39
|
+
_auditSink = auditSink ?? throw new ArgumentNullException(nameof(auditSink));
|
|
40
|
+
_authorizationService = authorizationService ?? throw new ArgumentNullException(nameof(authorizationService));
|
|
41
|
+
_options = options?.Value ?? throw new ArgumentNullException(nameof(options));
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
public async Task OnAuthorizationAsync(AuthorizationFilterContext context)
|
|
45
|
+
{
|
|
46
|
+
ArgumentNullException.ThrowIfNull(context);
|
|
47
|
+
|
|
48
|
+
var user = context.HttpContext.User;
|
|
49
|
+
|
|
50
|
+
// Invariant: Unauthenticated callers get 401 before any role evaluation
|
|
51
|
+
if (user?.Identity?.IsAuthenticated != true)
|
|
52
|
+
{
|
|
53
|
+
context.Result = new UnauthorizedResult();
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Roles come only from the verified principal; exact, ordinal match against any required role
|
|
58
|
+
var roleClaimType = string.IsNullOrWhiteSpace(_options.RoleClaimType)
|
|
59
|
+
? System.Security.Claims.ClaimTypes.Role
|
|
60
|
+
: _options.RoleClaimType;
|
|
61
|
+
|
|
62
|
+
var hasRole = user.FindAll(roleClaimType)
|
|
63
|
+
.Any(c => _requiredRoles.Contains(c.Value, StringComparer.Ordinal));
|
|
64
|
+
|
|
65
|
+
if (hasRole)
|
|
66
|
+
{
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
context.Result = _options.DenyWith == RoleDenyResponse.NotFound
|
|
71
|
+
? new NotFoundResult()
|
|
72
|
+
: new StatusCodeResult(StatusCodes.Status403Forbidden);
|
|
73
|
+
|
|
74
|
+
await EmitDenialAsync(context).ConfigureAwait(false);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
private async ValueTask EmitDenialAsync(AuthorizationFilterContext context)
|
|
78
|
+
{
|
|
79
|
+
var httpContext = context.HttpContext;
|
|
80
|
+
try
|
|
81
|
+
{
|
|
82
|
+
var auditEvent = new SecurityAuditEvent
|
|
83
|
+
{
|
|
84
|
+
Timestamp = DateTimeOffset.UtcNow,
|
|
85
|
+
EventName = RoleDeniedEventName,
|
|
86
|
+
TraceId = Activity.Current?.Id ?? httpContext.TraceIdentifier,
|
|
87
|
+
ActorUserId = TryRead(() => _authorizationService.GetCurrentUserId(httpContext.User)),
|
|
88
|
+
ActorTenantId = TryRead(() => _authorizationService.GetCurrentTenantId(httpContext.User)),
|
|
89
|
+
ResourceType = "Endpoint",
|
|
90
|
+
ResourceId = context.ActionDescriptor?.DisplayName ?? httpContext.Request.Path.Value,
|
|
91
|
+
ClientIp = httpContext.Connection.RemoteIpAddress?.ToString(),
|
|
92
|
+
Reason = $"Missing required role: caller holds none of [{string.Join(", ", _requiredRoles)}]"
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
await _auditSink.EmitAsync(auditEvent, httpContext.RequestAborted).ConfigureAwait(false);
|
|
96
|
+
}
|
|
97
|
+
catch
|
|
98
|
+
{
|
|
99
|
+
// Auditing failures must not mask or alter the denial outcome
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
private static string? TryRead(Func<string> read)
|
|
104
|
+
{
|
|
105
|
+
try
|
|
106
|
+
{
|
|
107
|
+
return read();
|
|
108
|
+
}
|
|
109
|
+
catch (UnauthorizedAccessException)
|
|
110
|
+
{
|
|
111
|
+
return null;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
namespace Komagu.Security.AspNetCore;
|
|
2
|
+
|
|
3
|
+
/// <summary>
|
|
4
|
+
/// HTTP response returned by <see cref="RoleAuthorizationFilter"/> when an authenticated caller lacks every required role.
|
|
5
|
+
/// </summary>
|
|
6
|
+
public enum RoleDenyResponse
|
|
7
|
+
{
|
|
8
|
+
/// <summary>
|
|
9
|
+
/// HTTP 403 Forbidden (default).
|
|
10
|
+
/// </summary>
|
|
11
|
+
Forbidden,
|
|
12
|
+
|
|
13
|
+
/// <summary>
|
|
14
|
+
/// HTTP 404 Not Found, hiding the existence of the privileged endpoint.
|
|
15
|
+
/// </summary>
|
|
16
|
+
NotFound
|
|
17
|
+
}
|
package/remediate/authz/dotnet/src/Komagu.Security.AspNetCore/ServiceCollectionExtensions.cs
CHANGED
|
@@ -16,6 +16,7 @@ public static class ServiceCollectionExtensions
|
|
|
16
16
|
{
|
|
17
17
|
services.TryAddSingleton<ISecurityAuditSink, LoggingSecurityAuditSink>();
|
|
18
18
|
services.TryAddScoped<IKomaguAuthorizationService, DefaultKomaguAuthorizationService>();
|
|
19
|
+
services.AddOptions<KomaguRoleOptions>();
|
|
19
20
|
return services;
|
|
20
21
|
}
|
|
21
22
|
|
|
@@ -27,6 +28,21 @@ public static class ServiceCollectionExtensions
|
|
|
27
28
|
{
|
|
28
29
|
services.AddSingleton<ISecurityAuditSink, TAuditSink>();
|
|
29
30
|
services.TryAddScoped<IKomaguAuthorizationService, DefaultKomaguAuthorizationService>();
|
|
31
|
+
services.AddOptions<KomaguRoleOptions>();
|
|
32
|
+
return services;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/// <summary>
|
|
36
|
+
/// Configures the <see cref="RequireRoleAttribute"/> guard (role claim type, 403 vs 404 on denial).
|
|
37
|
+
/// Call after <c>AddKomaguAuthorization</c>.
|
|
38
|
+
/// </summary>
|
|
39
|
+
public static IServiceCollection ConfigureKomaguRoles(
|
|
40
|
+
this IServiceCollection services,
|
|
41
|
+
Action<KomaguRoleOptions> configure)
|
|
42
|
+
{
|
|
43
|
+
ArgumentNullException.ThrowIfNull(services);
|
|
44
|
+
ArgumentNullException.ThrowIfNull(configure);
|
|
45
|
+
services.AddOptions<KomaguRoleOptions>().Configure(configure);
|
|
30
46
|
return services;
|
|
31
47
|
}
|
|
32
48
|
}
|
package/remediate/authz/dotnet/tests/Komagu.Security.Authorization.Tests/MassAssignmentDtoTests.cs
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
namespace Komagu.Security.Authorization.Tests;
|
|
2
|
+
|
|
3
|
+
using System.Text.Json;
|
|
4
|
+
using Xunit;
|
|
5
|
+
|
|
6
|
+
/// <summary>
|
|
7
|
+
/// Mass-assignment remediation example: replace whole-entity binding (or <c>[Bind(Include = "...")]</c>) with a
|
|
8
|
+
/// request DTO that declares only the client-editable fields, then copy them onto the tracked entity explicitly.
|
|
9
|
+
/// </summary>
|
|
10
|
+
public class MassAssignmentDtoTests
|
|
11
|
+
{
|
|
12
|
+
/// <summary>Persisted entity: Role, IsAdmin and Balance must never be client-writable.</summary>
|
|
13
|
+
public sealed class Customer
|
|
14
|
+
{
|
|
15
|
+
public int Id { get; set; }
|
|
16
|
+
public string Name { get; set; } = string.Empty;
|
|
17
|
+
public string Email { get; set; } = string.Empty;
|
|
18
|
+
public string Role { get; set; } = "customer";
|
|
19
|
+
public bool IsAdmin { get; set; }
|
|
20
|
+
public decimal Balance { get; set; }
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/// <summary>Allow-list DTO: only the fields a customer may edit on their own profile.</summary>
|
|
24
|
+
public sealed record ProfileUpdateDto(string Name, string Email)
|
|
25
|
+
{
|
|
26
|
+
/// <summary>Explicit copy: privileged fields are not reachable from the request.</summary>
|
|
27
|
+
public void ApplyTo(Customer customer)
|
|
28
|
+
{
|
|
29
|
+
ArgumentNullException.ThrowIfNull(customer);
|
|
30
|
+
customer.Name = Name;
|
|
31
|
+
customer.Email = Email;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
private static readonly JsonSerializerOptions WebJson = new(JsonSerializerDefaults.Web);
|
|
36
|
+
|
|
37
|
+
[Fact]
|
|
38
|
+
public void ProfileUpdate_WithPrivilegedFieldsInJson_ChangesOnlyAllowListedFields()
|
|
39
|
+
{
|
|
40
|
+
var customer = new Customer { Id = 7, Name = "Old", Email = "old@example.com", Role = "customer", IsAdmin = false, Balance = 10m };
|
|
41
|
+
const string attackerJson = """
|
|
42
|
+
{"id": 1, "name": "New Name", "email": "new@example.com", "role": "admin", "isAdmin": true, "balance": 999999}
|
|
43
|
+
""";
|
|
44
|
+
|
|
45
|
+
var dto = JsonSerializer.Deserialize<ProfileUpdateDto>(attackerJson, WebJson);
|
|
46
|
+
Assert.NotNull(dto);
|
|
47
|
+
dto!.ApplyTo(customer);
|
|
48
|
+
|
|
49
|
+
Assert.Equal("New Name", customer.Name);
|
|
50
|
+
Assert.Equal("new@example.com", customer.Email);
|
|
51
|
+
Assert.Equal(7, customer.Id);
|
|
52
|
+
Assert.Equal("customer", customer.Role);
|
|
53
|
+
Assert.False(customer.IsAdmin);
|
|
54
|
+
Assert.Equal(10m, customer.Balance);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
[Fact]
|
|
58
|
+
public void ProfileUpdateDto_ExposesOnlyNameAndEmail()
|
|
59
|
+
{
|
|
60
|
+
var writable = typeof(ProfileUpdateDto).GetProperties().Select(p => p.Name).OrderBy(n => n, StringComparer.Ordinal);
|
|
61
|
+
Assert.Equal(new[] { "Email", "Name" }, writable);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
[Fact]
|
|
65
|
+
public void WholeEntityBinding_IsTheVulnerablePattern()
|
|
66
|
+
{
|
|
67
|
+
// Contrast: deserializing straight into the entity lets the client set Role/IsAdmin/Balance.
|
|
68
|
+
const string attackerJson = """{"name": "x", "email": "x@example.com", "role": "admin", "isAdmin": true, "balance": 999999}""";
|
|
69
|
+
|
|
70
|
+
var bound = JsonSerializer.Deserialize<Customer>(attackerJson, WebJson)!;
|
|
71
|
+
|
|
72
|
+
Assert.Equal("admin", bound.Role);
|
|
73
|
+
Assert.True(bound.IsAdmin);
|
|
74
|
+
Assert.Equal(999999m, bound.Balance);
|
|
75
|
+
}
|
|
76
|
+
}
|