Skip to content

Commit 4f52bc7

Browse files
DawoudIOclaudeCopilotCopilot
authored
refactor: consolidate Slim middleware to reduce code duplication (#8166)
## Summary - **Extract `BrowserRequestTrait`** — removes 30-line `isBrowserRequest()` method that was copy-pasted identically into both `AuthMiddleware` and `BaseAuthRoleMiddleware` - **Add `RequestParameterValidationMiddleware`** — declarative enum/required-field validation; replaces inline `depositType` validation block in `POST /deposits` - **Add `InputSanitizationMiddleware`** — declarative `InputUtils` sanitization mapped by field name; replaces scattered `sanitizeText()` calls in group create/update and deposit create routes - **Fix `GroupMiddleware` + wire to routes** — corrected route param name (`groupId` → `groupID`); added to 8 group write routes that were previously loading the entity inline (the middleware was defined but never used) ## Net result - 127 lines removed, 315 added (315 includes 3 new reusable middleware classes + Cypress tests) - Route handlers are now lean: no inline entity loading or sanitization boilerplate - Consistent 400/412 error responses enforced at the middleware layer ## Test plan - [x] Added `Middleware Validation Tests` block to `finance-deposits.spec.js`: - Missing `depositType` → 400 with `allowed` list - Invalid `depositType` → 400 with error message containing the bad value - XSS in `depositComment` → `Comment` field in response contains no `<script>` - [x] Added `Middleware Validation Tests` block to `private.people.groups.spec.js`: - Non-existent `groupID` on update/delete/addperson → 412 - XSS in `groupName` on create and update → `Name` field stripped of `<script>` / `onerror` - XSS in `groupRoleName` → sanitized before save - [ ] Full Cypress suite (Cypress 15.11.0 has a macOS Sequoia path bug locally; CI will validate) 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com> Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Co-authored-by: Copilot <198982749+Copilot@users.noreply.github.com> Co-authored-by: DawoudIO <554959+DawoudIO@users.noreply.github.com>
1 parent d9404aa commit 4f52bc7

File tree

10 files changed

+335
-130
lines changed

10 files changed

+335
-130
lines changed

cypress/e2e/api/private/finance/finance-deposits.spec.js

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -222,6 +222,53 @@ describe("API Private Deposit Operations", () => {
222222
});
223223
});
224224

225+
describe("Middleware Validation Tests", () => {
226+
it("Rejects missing depositType with 400", () => {
227+
const today = new Date().toISOString().split("T")[0];
228+
cy.makePrivateAdminAPICall(
229+
"POST",
230+
`/api/deposits`,
231+
{ depositComment: "No type given", depositDate: today },
232+
400
233+
).then((resp) => {
234+
expect(resp.body).to.have.property("error");
235+
expect(resp.body).to.have.property("allowed");
236+
expect(resp.body.allowed).to.include("Bank");
237+
});
238+
});
239+
240+
it("Rejects invalid depositType with 400", () => {
241+
const today = new Date().toISOString().split("T")[0];
242+
cy.makePrivateAdminAPICall(
243+
"POST",
244+
`/api/deposits`,
245+
{ depositType: "Cash", depositDate: today },
246+
400
247+
).then((resp) => {
248+
expect(resp.body).to.have.property("error");
249+
expect(resp.body.error).to.include("Cash");
250+
expect(resp.body).to.have.property("allowed");
251+
});
252+
});
253+
254+
it("Sanitizes XSS in depositComment before saving", () => {
255+
const today = new Date().toISOString().split("T")[0];
256+
cy.makePrivateAdminAPICall(
257+
"POST",
258+
`/api/deposits`,
259+
{
260+
depositType: "Bank",
261+
depositComment: "<script>alert('xss')</script>Test",
262+
depositDate: today,
263+
},
264+
200
265+
).then((resp) => {
266+
expect(resp.body).to.have.property("Comment");
267+
expect(resp.body.Comment).to.not.include("<script>");
268+
});
269+
});
270+
});
271+
225272
describe("Data Structure Validation", () => {
226273
it("Verify deposit data structure", () => {
227274
// Ensure deposit operations return proper structure

cypress/e2e/api/private/standard/private.people.groups.spec.js

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -202,6 +202,79 @@ describe("API Private Group Operations", () => {
202202
});
203203
});
204204

205+
describe("Middleware Validation Tests", () => {
206+
it("Returns 412 when updating a non-existent group", () => {
207+
cy.makePrivateAdminAPICall(
208+
"POST",
209+
`/api/groups/999999`,
210+
{ groupName: "Ghost Group", groupType: 0, description: "" },
211+
412
212+
);
213+
});
214+
215+
it("Returns 412 when deleting a non-existent group", () => {
216+
cy.makePrivateAdminAPICall(
217+
"DELETE",
218+
`/api/groups/999999`,
219+
null,
220+
412
221+
);
222+
});
223+
224+
it("Returns 412 when adding a person to a non-existent group", () => {
225+
cy.makePrivateAdminAPICall(
226+
"POST",
227+
`/api/groups/999999/addperson/1`,
228+
{ RoleID: 1 },
229+
412
230+
);
231+
});
232+
233+
it("Sanitizes XSS in groupName when creating a group", () => {
234+
cy.makePrivateAdminAPICall(
235+
"POST",
236+
`/api/groups/`,
237+
{
238+
groupName: "<script>alert('xss')</script>TestGroup",
239+
description: "safe description",
240+
},
241+
200
242+
).then((resp) => {
243+
expect(resp.body).to.have.property("Name");
244+
expect(resp.body.Name).to.not.include("<script>");
245+
});
246+
});
247+
248+
it("Sanitizes XSS in groupName when updating a group", () => {
249+
cy.makePrivateAdminAPICall(
250+
"POST",
251+
`/api/groups/${groupID}`,
252+
{
253+
groupName: "<img src=x onerror=alert(1)>CleanName",
254+
groupType: 0,
255+
description: "",
256+
},
257+
200
258+
).then((resp) => {
259+
expect(resp.body).to.have.property("Name");
260+
expect(resp.body.Name).to.not.include("onerror");
261+
});
262+
});
263+
264+
it("Sanitizes XSS in role name when updating group role", () => {
265+
cy.makePrivateAdminAPICall(
266+
"POST",
267+
`/api/groups/${groupID}/roles/1`,
268+
{ groupRoleName: "<b>Bold</b>RoleName" },
269+
200
270+
).then((resp) => {
271+
expect(resp.body).to.have.property("OptionName");
272+
expect(resp.body.OptionName).to.not.include("<b>");
273+
expect(resp.body.OptionName).to.include("RoleName");
274+
});
275+
});
276+
});
277+
205278
describe("Authorization Tests - Non-Admin Users", () => {
206279
it("Non-admin should be denied adding group members", () => {
207280
// Test that a user without bManageGroups permission is denied

src/ChurchCRM/Slim/Middleware/Api/GroupMiddleware.php

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,14 +16,14 @@ class GroupMiddleware implements MiddlewareInterface
1616
public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
1717
{
1818
$response = new Response();
19-
$groupId = SlimUtils::getRouteArgument($request, 'groupId');
19+
$groupId = SlimUtils::getRouteArgument($request, 'groupID');
2020
if (empty(trim($groupId))) {
21-
return $response->withStatus(412, gettext('Missing') . ' GroupId');
21+
return SlimUtils::renderErrorJSON($response, gettext('Missing') . ' GroupId', [], 412);
2222
}
2323

2424
$group = GroupQuery::create()->findPk($groupId);
2525
if (empty($group)) {
26-
return $response->withStatus(412, 'GroupId: ' . $groupId . ' ' . gettext('not found'));
26+
return SlimUtils::renderErrorJSON($response, 'GroupId: ' . $groupId . ' ' . gettext('not found'), [], 412);
2727
}
2828

2929
$request = $request->withAttribute('group', $group);

src/ChurchCRM/Slim/Middleware/AuthMiddleware.php

Lines changed: 2 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@
1414

1515
class AuthMiddleware implements MiddlewareInterface
1616
{
17+
use BrowserRequestTrait;
18+
1719
public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
1820
{
1921
// Construct the full public API path including any subdirectory installation
@@ -86,40 +88,6 @@ private function isPath(ServerRequestInterface $request, string $pathPart): bool
8688
return in_array($pathPart, $pathAry, true);
8789
}
8890

89-
/**
90-
* Check if request is from a browser (expects HTML) vs API client (expects JSON)
91-
*/
92-
private function isBrowserRequest(ServerRequestInterface $request): bool
93-
{
94-
$path = $request->getUri()->getPath();
95-
96-
// API routes should always return JSON
97-
if (str_contains($path, '/api/')) {
98-
return false;
99-
}
100-
101-
// Check Accept header - browsers typically send text/html
102-
$acceptHeader = $request->getHeaderLine('Accept');
103-
if (!empty($acceptHeader)) {
104-
// If client explicitly wants JSON, it's an API request
105-
if (str_contains($acceptHeader, 'application/json') && !str_contains($acceptHeader, 'text/html')) {
106-
return false;
107-
}
108-
// If client accepts HTML, treat as browser
109-
if (str_contains($acceptHeader, 'text/html')) {
110-
return true;
111-
}
112-
}
113-
114-
// Check X-Requested-With header (AJAX requests)
115-
if ($request->getHeaderLine('X-Requested-With') === 'XMLHttpRequest') {
116-
return false;
117-
}
118-
119-
// Default to browser for non-API routes
120-
return true;
121-
}
122-
12391
/**
12492
* Redirect to the login page
12593
*/
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
<?php
2+
3+
namespace ChurchCRM\Slim\Middleware;
4+
5+
use Psr\Http\Message\ServerRequestInterface;
6+
7+
trait BrowserRequestTrait
8+
{
9+
/**
10+
* Check if request is from a browser (expects HTML) vs API client (expects JSON).
11+
*/
12+
protected function isBrowserRequest(ServerRequestInterface $request): bool
13+
{
14+
$path = $request->getUri()->getPath();
15+
16+
// API routes should always return JSON
17+
if (str_contains($path, '/api/')) {
18+
return false;
19+
}
20+
21+
// Check Accept header - browsers typically send text/html
22+
$acceptHeader = $request->getHeaderLine('Accept');
23+
if (!empty($acceptHeader)) {
24+
// If client explicitly wants JSON, it's an API request
25+
if (str_contains($acceptHeader, 'application/json') && !str_contains($acceptHeader, 'text/html')) {
26+
return false;
27+
}
28+
// If client accepts HTML, treat as browser
29+
if (str_contains($acceptHeader, 'text/html')) {
30+
return true;
31+
}
32+
}
33+
34+
// Check X-Requested-With header (AJAX requests)
35+
if ($request->getHeaderLine('X-Requested-With') === 'XMLHttpRequest') {
36+
return false;
37+
}
38+
39+
// Default to browser for non-API routes
40+
return true;
41+
}
42+
}
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
<?php
2+
3+
namespace ChurchCRM\Slim\Middleware;
4+
5+
use ChurchCRM\Utils\InputUtils;
6+
use Psr\Http\Message\ResponseInterface;
7+
use Psr\Http\Message\ServerRequestInterface;
8+
use Psr\Http\Server\MiddlewareInterface;
9+
use Psr\Http\Server\RequestHandlerInterface;
10+
11+
/**
12+
* Sanitizes request body fields before passing to the route handler.
13+
*
14+
* Fields are sanitized in-place; only fields that are present in the body
15+
* are affected. Missing fields are left absent (not set to empty string).
16+
*
17+
* Supported sanitization types:
18+
* - 'text' → InputUtils::sanitizeText() (trims and strips HTML tags)
19+
* - 'html' → InputUtils::sanitizeHTML() (allows safe HTML, strips scripts)
20+
*
21+
* Usage:
22+
* ->add(new InputSanitizationMiddleware([
23+
* 'title' => 'text',
24+
* 'content' => 'html',
25+
* ]))
26+
*/
27+
class InputSanitizationMiddleware implements MiddlewareInterface
28+
{
29+
/**
30+
* @param array<string, 'text'|'html'> $fieldMap Map of field name → sanitization type.
31+
*/
32+
public function __construct(private readonly array $fieldMap) {}
33+
34+
public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
35+
{
36+
$body = $request->getParsedBody();
37+
38+
if (is_array($body)) {
39+
foreach ($this->fieldMap as $field => $type) {
40+
if (isset($body[$field])) {
41+
$body[$field] = match ($type) {
42+
'html' => InputUtils::sanitizeHTML($body[$field]),
43+
default => InputUtils::sanitizeText($body[$field]),
44+
};
45+
}
46+
}
47+
$request = $request->withParsedBody($body);
48+
}
49+
50+
return $handler->handle($request);
51+
}
52+
}

src/ChurchCRM/Slim/Middleware/Request/Auth/BaseAuthRoleMiddleware.php

Lines changed: 3 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
use ChurchCRM\Authentication\AuthenticationManager;
66
use ChurchCRM\dto\SystemURLs;
77
use ChurchCRM\model\ChurchCRM\User;
8+
use ChurchCRM\Slim\Middleware\BrowserRequestTrait;
89
use ChurchCRM\Utils\LoggerUtils;
910
use Laminas\Diactoros\Response;
1011
use Psr\Http\Message\ServerRequestInterface as Request;
@@ -16,6 +17,8 @@
1617

1718
abstract class BaseAuthRoleMiddleware implements MiddlewareInterface
1819
{
20+
use BrowserRequestTrait;
21+
1922
protected User $user;
2023

2124
public function process(Request $request, RequestHandlerInterface $handler): ResponseInterface
@@ -64,40 +67,6 @@ public function process(Request $request, RequestHandlerInterface $handler): Res
6467
return $handler->handle($request);
6568
}
6669

67-
/**
68-
* Check if request is from a browser (expects HTML) vs API client (expects JSON)
69-
*/
70-
protected function isBrowserRequest(Request $request): bool
71-
{
72-
$path = $request->getUri()->getPath();
73-
74-
// API routes should always return JSON
75-
if (str_contains($path, '/api/')) {
76-
return false;
77-
}
78-
79-
// Check Accept header - browsers typically send text/html
80-
$acceptHeader = $request->getHeaderLine('Accept');
81-
if (!empty($acceptHeader)) {
82-
// If client explicitly wants JSON, it's an API request
83-
if (str_contains($acceptHeader, 'application/json') && !str_contains($acceptHeader, 'text/html')) {
84-
return false;
85-
}
86-
// If client accepts HTML, treat as browser
87-
if (str_contains($acceptHeader, 'text/html')) {
88-
return true;
89-
}
90-
}
91-
92-
// Check X-Requested-With header (AJAX requests)
93-
if ($request->getHeaderLine('X-Requested-With') === 'XMLHttpRequest') {
94-
return false;
95-
}
96-
97-
// Default to browser for non-API routes
98-
return true;
99-
}
100-
10170
/**
10271
* Redirect to the access-denied page
10372
*/

0 commit comments

Comments
 (0)