security: enforce CSRF protection on POST/DELETE export endpoints (Closes #171)
This commit was merged in pull request #187.
This commit is contained in:
@@ -11,7 +11,6 @@ use OCA\Mitgliederverwaltung\Service\ValidationException;
|
|||||||
use OCP\AppFramework\ApiController;
|
use OCP\AppFramework\ApiController;
|
||||||
use OCP\AppFramework\Db\DoesNotExistException;
|
use OCP\AppFramework\Db\DoesNotExistException;
|
||||||
use OCP\AppFramework\Http;
|
use OCP\AppFramework\Http;
|
||||||
use OCP\AppFramework\Http\Attribute\NoCSRFRequired;
|
|
||||||
use OCP\AppFramework\Http\DataDownloadResponse;
|
use OCP\AppFramework\Http\DataDownloadResponse;
|
||||||
use OCP\AppFramework\Http\JSONResponse;
|
use OCP\AppFramework\Http\JSONResponse;
|
||||||
use OCP\IRequest;
|
use OCP\IRequest;
|
||||||
@@ -58,7 +57,6 @@ class DsgvoController extends ApiController {
|
|||||||
* POST /api/v1/members/{id}/dsgvo-export
|
* POST /api/v1/members/{id}/dsgvo-export
|
||||||
* Body: { "password": "..." }
|
* Body: { "password": "..." }
|
||||||
*/
|
*/
|
||||||
#[NoCSRFRequired]
|
|
||||||
public function export(int $id): DataDownloadResponse|JSONResponse {
|
public function export(int $id): DataDownloadResponse|JSONResponse {
|
||||||
try {
|
try {
|
||||||
$this->assertAdmin();
|
$this->assertAdmin();
|
||||||
@@ -119,7 +117,6 @@ class DsgvoController extends ApiController {
|
|||||||
* DELETE /api/v1/members/{id}/dsgvo-delete
|
* DELETE /api/v1/members/{id}/dsgvo-delete
|
||||||
* Body: { "confirm": true }
|
* Body: { "confirm": true }
|
||||||
*/
|
*/
|
||||||
#[NoCSRFRequired]
|
|
||||||
public function hardDelete(int $id): JSONResponse {
|
public function hardDelete(int $id): JSONResponse {
|
||||||
try {
|
try {
|
||||||
$this->assertAdmin();
|
$this->assertAdmin();
|
||||||
|
|||||||
@@ -155,7 +155,6 @@ class ExportController extends ApiController {
|
|||||||
* POST /api/v1/export/members/encrypted
|
* POST /api/v1/export/members/encrypted
|
||||||
* Body: { "password": "...", "status": "aktiv" }
|
* Body: { "password": "...", "status": "aktiv" }
|
||||||
*/
|
*/
|
||||||
#[NoCSRFRequired]
|
|
||||||
public function membersEncrypted(): DataDownloadResponse|JSONResponse {
|
public function membersEncrypted(): DataDownloadResponse|JSONResponse {
|
||||||
try {
|
try {
|
||||||
$password = $this->request->getParam('password');
|
$password = $this->request->getParam('password');
|
||||||
@@ -204,7 +203,6 @@ class ExportController extends ApiController {
|
|||||||
* POST /api/v1/export/fees/encrypted
|
* POST /api/v1/export/fees/encrypted
|
||||||
* Body: { "password": "...", "year": 2026 }
|
* Body: { "password": "...", "year": 2026 }
|
||||||
*/
|
*/
|
||||||
#[NoCSRFRequired]
|
|
||||||
public function feesEncrypted(): DataDownloadResponse|JSONResponse {
|
public function feesEncrypted(): DataDownloadResponse|JSONResponse {
|
||||||
try {
|
try {
|
||||||
$password = $this->request->getParam('password');
|
$password = $this->request->getParam('password');
|
||||||
@@ -260,7 +258,6 @@ class ExportController extends ApiController {
|
|||||||
* POST /api/v1/export/birthdays/encrypted
|
* POST /api/v1/export/birthdays/encrypted
|
||||||
* Body: { "password": "...", "month": 4 }
|
* Body: { "password": "...", "month": 4 }
|
||||||
*/
|
*/
|
||||||
#[NoCSRFRequired]
|
|
||||||
public function birthdaysEncrypted(): DataDownloadResponse|JSONResponse {
|
public function birthdaysEncrypted(): DataDownloadResponse|JSONResponse {
|
||||||
try {
|
try {
|
||||||
$password = $this->request->getParam('password');
|
$password = $this->request->getParam('password');
|
||||||
@@ -354,7 +351,6 @@ class ExportController extends ApiController {
|
|||||||
*
|
*
|
||||||
* Requires explicit confirmation because sensitive data is decrypted.
|
* Requires explicit confirmation because sensitive data is decrypted.
|
||||||
*/
|
*/
|
||||||
#[NoCSRFRequired]
|
|
||||||
public function bundleSensitive(): DataDownloadResponse|JSONResponse {
|
public function bundleSensitive(): DataDownloadResponse|JSONResponse {
|
||||||
try {
|
try {
|
||||||
$confirm = $this->request->getParam('confirm');
|
$confirm = $this->request->getParam('confirm');
|
||||||
@@ -448,7 +444,6 @@ class ExportController extends ApiController {
|
|||||||
* POST /api/v1/export/banking/encrypted
|
* POST /api/v1/export/banking/encrypted
|
||||||
* Body: { "password": "..." }
|
* Body: { "password": "..." }
|
||||||
*/
|
*/
|
||||||
#[NoCSRFRequired]
|
|
||||||
public function bankingEncrypted(): DataDownloadResponse|JSONResponse {
|
public function bankingEncrypted(): DataDownloadResponse|JSONResponse {
|
||||||
try {
|
try {
|
||||||
// Permission check: banking visibility required
|
// Permission check: banking visibility required
|
||||||
|
|||||||
@@ -163,7 +163,6 @@ class ReportController extends ApiController {
|
|||||||
* POST /api/v1/reports/{type}/encrypted
|
* POST /api/v1/reports/{type}/encrypted
|
||||||
* Body: { "password": "...", ...params }
|
* Body: { "password": "...", ...params }
|
||||||
*/
|
*/
|
||||||
#[NoCSRFRequired]
|
|
||||||
public function encrypted(string $type): DataDownloadResponse|JSONResponse {
|
public function encrypted(string $type): DataDownloadResponse|JSONResponse {
|
||||||
try {
|
try {
|
||||||
$password = $this->request->getParam('password');
|
$password = $this->request->getParam('password');
|
||||||
|
|||||||
@@ -0,0 +1,132 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace OCA\Mitgliederverwaltung\Tests\Unit;
|
||||||
|
|
||||||
|
use OCP\AppFramework\Http\Attribute\NoCSRFRequired;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
use ReflectionClass;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verify that POST/DELETE endpoints do NOT have #[NoCSRFRequired].
|
||||||
|
*
|
||||||
|
* GET endpoints may legitimately skip CSRF checks (standard Nextcloud practice).
|
||||||
|
* POST/DELETE endpoints must have CSRF protection enabled (no #[NoCSRFRequired]).
|
||||||
|
*
|
||||||
|
* Part of Issue #171.
|
||||||
|
*/
|
||||||
|
class CsrfProtectionTest extends TestCase {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Map of controller class => method names that are POST/DELETE and
|
||||||
|
* MUST NOT have #[NoCSRFRequired].
|
||||||
|
*/
|
||||||
|
private function getPostDeleteEndpoints(): array {
|
||||||
|
return [
|
||||||
|
// ExportController — encrypted exports use POST
|
||||||
|
\OCA\Mitgliederverwaltung\Controller\ExportController::class => [
|
||||||
|
'membersEncrypted',
|
||||||
|
'feesEncrypted',
|
||||||
|
'birthdaysEncrypted',
|
||||||
|
'bundleSensitive',
|
||||||
|
'bankingEncrypted',
|
||||||
|
],
|
||||||
|
// ReportController — encrypted report uses POST
|
||||||
|
\OCA\Mitgliederverwaltung\Controller\ReportController::class => [
|
||||||
|
'encrypted',
|
||||||
|
],
|
||||||
|
// DsgvoController — export (POST) and hardDelete (DELETE)
|
||||||
|
\OCA\Mitgliederverwaltung\Controller\DsgvoController::class => [
|
||||||
|
'export',
|
||||||
|
'hardDelete',
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Map of controller class => method names that are GET and
|
||||||
|
* MAY have #[NoCSRFRequired] (read-only endpoints).
|
||||||
|
*/
|
||||||
|
private function getGetEndpoints(): array {
|
||||||
|
return [
|
||||||
|
\OCA\Mitgliederverwaltung\Controller\ExportController::class => [
|
||||||
|
'members',
|
||||||
|
'fees',
|
||||||
|
'birthdays',
|
||||||
|
'bundle',
|
||||||
|
'entityTypes',
|
||||||
|
'entity',
|
||||||
|
],
|
||||||
|
\OCA\Mitgliederverwaltung\Controller\ReportController::class => [
|
||||||
|
'index',
|
||||||
|
'preview',
|
||||||
|
'pdf',
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @dataProvider postDeleteEndpointsProvider
|
||||||
|
*/
|
||||||
|
public function testPostDeleteEndpointsRequireCsrf(string $controllerClass, string $method): void {
|
||||||
|
$rc = new ReflectionClass($controllerClass);
|
||||||
|
$rm = $rc->getMethod($method);
|
||||||
|
|
||||||
|
$attrs = $rm->getAttributes(NoCSRFRequired::class);
|
||||||
|
|
||||||
|
$this->assertEmpty(
|
||||||
|
$attrs,
|
||||||
|
sprintf(
|
||||||
|
'%s::%s is a POST/DELETE endpoint but has #[NoCSRFRequired] — '
|
||||||
|
. 'this bypasses CSRF protection on a state-changing endpoint.',
|
||||||
|
$controllerClass,
|
||||||
|
$method
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @dataProvider getEndpointsProvider
|
||||||
|
*/
|
||||||
|
public function testGetEndpointsAllowNoCsrf(string $controllerClass, string $method): void {
|
||||||
|
$rc = new ReflectionClass($controllerClass);
|
||||||
|
$rm = $rc->getMethod($method);
|
||||||
|
|
||||||
|
$attrs = $rm->getAttributes(NoCSRFRequired::class);
|
||||||
|
|
||||||
|
$this->assertNotEmpty(
|
||||||
|
$attrs,
|
||||||
|
sprintf(
|
||||||
|
'%s::%s is a GET (read-only) endpoint and should have #[NoCSRFRequired] '
|
||||||
|
. 'for compatibility with Nextcloud API calls.',
|
||||||
|
$controllerClass,
|
||||||
|
$method
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function postDeleteEndpointsProvider(): array {
|
||||||
|
$data = [];
|
||||||
|
$endpoints = (new self('dummy'))->getPostDeleteEndpoints();
|
||||||
|
foreach ($endpoints as $class => $methods) {
|
||||||
|
foreach ($methods as $method) {
|
||||||
|
$shortClass = (new ReflectionClass($class))->getShortName();
|
||||||
|
$data["$shortClass::$method"] = [$class, $method];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return $data;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function getEndpointsProvider(): array {
|
||||||
|
$data = [];
|
||||||
|
$endpoints = (new self('dummy'))->getGetEndpoints();
|
||||||
|
foreach ($endpoints as $class => $methods) {
|
||||||
|
foreach ($methods as $method) {
|
||||||
|
$shortClass = (new ReflectionClass($class))->getShortName();
|
||||||
|
$data["$shortClass::$method"] = [$class, $method];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return $data;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user