diff --git a/src/Extend/Csrf.php b/src/Extend/Csrf.php new file mode 100644 index 000000000..7b8bd54b1 --- /dev/null +++ b/src/Extend/Csrf.php @@ -0,0 +1,32 @@ +csrfExemptPaths[] = $path; + + return $this; + } + + public function extend(Container $container, Extension $extension = null) + { + $container->extend('flarum.http.csrfExemptPaths', function ($existingExemptPaths) { + return array_merge($existingExemptPaths, $this->csrfExemptPaths); + }); + } +} diff --git a/src/Foundation/InstalledSite.php b/src/Foundation/InstalledSite.php index ecce78c4f..d61037767 100644 --- a/src/Foundation/InstalledSite.php +++ b/src/Foundation/InstalledSite.php @@ -21,6 +21,7 @@ use Flarum\Formatter\FormatterServiceProvider; use Flarum\Forum\ForumServiceProvider; use Flarum\Frontend\FrontendServiceProvider; use Flarum\Group\GroupServiceProvider; +use Flarum\Http\HttpServiceProvider; use Flarum\Locale\LocaleServiceProvider; use Flarum\Mail\MailServiceProvider; use Flarum\Notification\NotificationServiceProvider; @@ -125,6 +126,7 @@ class InstalledSite implements SiteInterface $laravel->register(FrontendServiceProvider::class); $laravel->register(GroupServiceProvider::class); $laravel->register(HashServiceProvider::class); + $laravel->register(HttpServiceProvider::class); $laravel->register(LocaleServiceProvider::class); $laravel->register(MailServiceProvider::class); $laravel->register(MigrationServiceProvider::class); diff --git a/src/Http/HttpServiceProvider.php b/src/Http/HttpServiceProvider.php new file mode 100644 index 000000000..aac4b3196 --- /dev/null +++ b/src/Http/HttpServiceProvider.php @@ -0,0 +1,29 @@ +app->singleton('flarum.http.csrfExemptPaths', function () { + return ['/api/token']; + }); + + $this->app->bind(Middleware\CheckCsrfToken::class, function ($app) { + return new Middleware\CheckCsrfToken($app->make('flarum.http.csrfExemptPaths')); + }); + } +} diff --git a/src/Http/Middleware/CheckCsrfToken.php b/src/Http/Middleware/CheckCsrfToken.php index 8143701f8..e0a389f4e 100644 --- a/src/Http/Middleware/CheckCsrfToken.php +++ b/src/Http/Middleware/CheckCsrfToken.php @@ -17,8 +17,22 @@ use Psr\Http\Server\RequestHandlerInterface as Handler; class CheckCsrfToken implements Middleware { + protected $exemptRoutes; + + public function __construct(array $exemptRoutes) + { + $this->exemptRoutes = $exemptRoutes; + } + public function process(Request $request, Handler $handler): Response { + $path = $request->getAttribute('originalUri')->getPath(); + foreach ($this->exemptRoutes as $exemptRoute) { + if (fnmatch($exemptRoute, $path)) { + return $handler->handle($request); + } + } + if (in_array($request->getMethod(), ['GET', 'HEAD', 'OPTIONS'])) { return $handler->handle($request); } diff --git a/tests/integration/api/authentication/WithTokenTest.php b/tests/integration/api/authentication/WithTokenTest.php index 7bab5ec4a..4405076ea 100644 --- a/tests/integration/api/authentication/WithTokenTest.php +++ b/tests/integration/api/authentication/WithTokenTest.php @@ -43,7 +43,7 @@ class WithTokenTest extends TestCase 'password' => 'too-obscure' ], ] - )->withAttribute('bypassCsrfToken', true) + ) ); $this->assertEquals(200, $response->getStatusCode()); @@ -75,7 +75,7 @@ class WithTokenTest extends TestCase 'password' => 'too-incorrect' ], ] - )->withAttribute('bypassCsrfToken', true) + ) ); // HTTP 401 signals an authentication problem diff --git a/tests/integration/extenders/CsrfTest.php b/tests/integration/extenders/CsrfTest.php new file mode 100644 index 000000000..8684b5b03 --- /dev/null +++ b/tests/integration/extenders/CsrfTest.php @@ -0,0 +1,114 @@ + 'test', + 'password' => 'too-obscure', + 'email' => 'test@machine.local', + ]; + + protected function prepDb() + { + $this->prepareDatabase([ + 'users' => [], + ]); + } + + /** + * @test + */ + public function create_user_post_needs_csrf_token_by_default() + { + $this->prepDb(); + + $response = $this->send( + $this->request('POST', '/api/users', [ + 'json' => [ + 'data' => [ + 'attributes' => $this->testUser + ] + ], + ]) + ); + + $this->assertEquals(400, $response->getStatusCode()); + } + + /** + * @test + */ + public function create_user_post_doesnt_need_csrf_token_if_whitelisted() + { + $this->extend( + (new Extend\Csrf) + ->exemptPath('/api/users') + ); + + $this->prepDb(); + + $response = $this->send( + $this->request('POST', '/api/users', [ + 'json' => [ + 'data' => [ + 'attributes' => $this->testUser + ] + ], + ]) + ); + + $this->assertEquals(201, $response->getStatusCode()); + + $user = User::where('username', $this->testUser['username'])->firstOrFail(); + + $this->assertEquals(0, $user->is_email_confirmed); + $this->assertEquals($this->testUser['username'], $user->username); + $this->assertEquals($this->testUser['email'], $user->email); + } + + /** + * @test + */ + public function post_to_unknown_route_will_cause_400_error_without_csrf_override() + { + $this->prepDb(); + + $response = $this->send( + $this->request('POST', '/api/fake/route/i/made/up') + ); + + $this->assertEquals(400, $response->getStatusCode()); + } + + /** + * @test + */ + public function csrf_matches_wildcards_properly() + { + $this->extend( + (new Extend\Csrf) + ->exemptPath('/api/fake/*/up') + ); + + $this->prepDb(); + + $response = $this->send( + $this->request('POST', '/api/fake/route/i/made/up') + ); + + $this->assertEquals(404, $response->getStatusCode()); + } +}