Skip to content

Commit d3c54bc

Browse files
committed
fix: add CSRF token to category restrictions save endpoint
The POST endpoint lacked CSRF protection. Added token generation in the admin GroupController template vars, a data attribute on the template, passing the token through the TypeScript API call, and server-side verification via Token::verifyToken in the API controller.
1 parent 5014e52 commit d3c54bc

6 files changed

Lines changed: 17 additions & 6 deletions

File tree

phpmyfaq/admin/assets/src/api/group.test.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -215,7 +215,7 @@ describe('saveGroupCategoryRestrictions', () => {
215215
const mockResponse = { ok: true, status: 200 } as Response;
216216
vi.spyOn(fetchWrapperModule, 'fetchWrapper').mockResolvedValue(mockResponse);
217217

218-
const result = await saveGroupCategoryRestrictions('5', '1', [10, 20]);
218+
const result = await saveGroupCategoryRestrictions('5', '1', [10, 20], 'test-csrf-token');
219219

220220
expect(result).toEqual(mockResponse);
221221
expect(fetchWrapperModule.fetchWrapper).toHaveBeenCalledWith('./api/group/category-restrictions', {
@@ -224,7 +224,7 @@ describe('saveGroupCategoryRestrictions', () => {
224224
headers: {
225225
'Content-Type': 'application/json',
226226
},
227-
body: JSON.stringify({ groupId: 5, rightId: 1, categoryIds: [10, 20] }),
227+
body: JSON.stringify({ groupId: 5, rightId: 1, categoryIds: [10, 20], csrfToken: 'test-csrf-token' }),
228228
redirect: 'follow',
229229
referrerPolicy: 'no-referrer',
230230
});

phpmyfaq/admin/assets/src/api/group.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -91,15 +91,16 @@ export const fetchGroupCategoryRestrictions = async (groupId: string): Promise<C
9191
export const saveGroupCategoryRestrictions = async (
9292
groupId: string,
9393
rightId: string,
94-
categoryIds: number[]
94+
categoryIds: number[],
95+
csrfToken: string
9596
): Promise<Response> => {
9697
return await fetchWrapper('./api/group/category-restrictions', {
9798
method: 'POST',
9899
cache: 'no-cache',
99100
headers: {
100101
'Content-Type': 'application/json',
101102
},
102-
body: JSON.stringify({ groupId: parseInt(groupId), rightId: parseInt(rightId), categoryIds }),
103+
body: JSON.stringify({ groupId: parseInt(groupId), rightId: parseInt(rightId), categoryIds, csrfToken }),
103104
redirect: 'follow',
104105
referrerPolicy: 'no-referrer',
105106
});

phpmyfaq/admin/assets/src/group/groups.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -399,6 +399,7 @@ export const handleCategoryRestrictionsSave = async (): Promise<void> => {
399399
return;
400400
}
401401

402+
const csrfToken = container.dataset.csrfToken || '';
402403
const selects = container.querySelectorAll<HTMLSelectElement>('select[data-right-id]');
403404

404405
for (const select of selects) {
@@ -411,6 +412,6 @@ export const handleCategoryRestrictionsSave = async (): Promise<void> => {
411412
.filter((option: HTMLOptionElement): boolean => option.selected)
412413
.map((option: HTMLOptionElement): number => parseInt(option.value));
413414

414-
await saveGroupCategoryRestrictions(groupId, rightId, selectedCategoryIds);
415+
await saveGroupCategoryRestrictions(groupId, rightId, selectedCategoryIds, csrfToken);
415416
}
416417
};

phpmyfaq/assets/templates/admin/user/group.twig

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -211,7 +211,8 @@
211211
</h5>
212212
<div class="card-body" id="categoryRestrictionsBody"
213213
data-msg-empty="{{ 'ad_group_no_permissions' | translate }}"
214-
data-msg-help="{{ 'ad_group_category_restrictions_select' | translate }}">
214+
data-msg-help="{{ 'ad_group_category_restrictions_select' | translate }}"
215+
data-csrf-token="{{ csrfTokenCategoryRestrictions }}">
215216
<p class="text-muted">{{ 'ad_group_category_restrictions_help' | translate }}</p>
216217
</div>
217218
<div class="card-footer">

phpmyfaq/src/phpMyFAQ/Controller/Administration/Api/GroupController.php

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
use phpMyFAQ\Administration\Category;
2323
use phpMyFAQ\Core\Exception;
2424
use phpMyFAQ\Permission\MediumPermission;
25+
use phpMyFAQ\Session\Token;
2526
use phpMyFAQ\User\CurrentUser;
2627
use Symfony\Component\HttpFoundation\JsonResponse;
2728
use Symfony\Component\HttpFoundation\Request;
@@ -173,6 +174,10 @@ public function saveCategoryRestrictions(Request $request): JsonResponse
173174
return $this->json(['error' => 'Invalid JSON payload.'], Response::HTTP_BAD_REQUEST);
174175
}
175176

177+
if (!Token::getInstance($this->session)->verifyToken('save-category-restrictions', $data['csrfToken'] ?? '')) {
178+
return $this->json(['error' => 'Invalid CSRF token.'], Response::HTTP_FORBIDDEN);
179+
}
180+
176181
$groupId = (int) ($data['groupId'] ?? 0);
177182
$rightId = (int) ($data['rightId'] ?? 0);
178183

phpmyfaq/src/phpMyFAQ/Controller/Administration/GroupController.php

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -340,6 +340,9 @@ private function getBaseTemplateVars(): array
340340
{
341341
return [
342342
'rightData' => $this->user->perm->getAllRightsData(),
343+
'csrfTokenCategoryRestrictions' => Token::getInstance($this->session)->getTokenString(
344+
'save-category-restrictions',
345+
),
343346
];
344347
}
345348
}

0 commit comments

Comments
 (0)