Security: protect and validate the editor image upload route

Closes #34 (closed)

Vulnerability

BladeComponentsServiceProvider::register() registered POST /bbs-image-upload with no middleware at all, and EditorImageUploadController did no authorization and no validation. As a result:

  • Any anonymous visitor could write arbitrary files of any size and type (including HTML/SVG) to the public images disk and get a public URL back. This allows free hosting of phishing or spam content on client domains.
  • The route was outside the web group, so CSRF was never checked, even though the CK editor already sends ?_token=.
  • The route was registered on every site, including sites that never use the editor. There was no rate limit.

Fix

  • Route registration moved from register() to boot(). It now respects routesAreCached() and can be disabled with editor_image_upload_enabled.
  • Configurable middleware, default ['web', 'auth']. web provides the session (so auth works for logged-in admins) and turns on CSRF checks. The CK editor already sends _token, so the component does not need to change.
  • Optional ability check in the new Http\Middleware\AuthorizeEditorImageUpload: if the configured ability (default edit-text) is defined via Gate::define(), users without it get 403. If the project never defines it, auth alone applies, so nobody is locked out.
  • Rate limit: named limiter bbs-editor-image-upload, 30 uploads per minute per user (falls back to IP). Can be configured or turned off.
  • Controller validation: required|file|image|mimes:jpg,jpeg,png,gif,webp|max:8192. SVG is excluded explicitly through mimes, so it is also rejected on Laravel versions where image still allows SVG. On failure the controller returns 422 with {uploaded: 0, error: {message}}, the error format CKEditor's upload adapter expects, instead of a redirect. The filename is still generated by store() (a random hash, with the extension taken from the file content). The storage path can now be set with editor_image_upload_path.
  • README: new "Editor Image Upload" section.
  • Tests: new tests/EditorImageUploadTest.php.

New config keys (all flat, next to the existing editor_image_* keys)

Key Default
editor_image_upload_enabled true
editor_image_upload_middleware ['web', 'auth']
editor_image_upload_ability 'edit-text'
editor_image_upload_rate_limit 30 (per minute, null = off)
editor_image_upload_max_size 8192 (KB)
editor_image_upload_mimes ['jpg', 'jpeg', 'png', 'gif', 'webp']
editor_image_upload_path null

The new keys are top-level, so mergeConfigFrom() supplies their defaults even in projects that published an older config file. Existing keys are unchanged.

Acceptance criteria

  • Anonymous request rejected (redirect to login, or 401 for JSON), tested
  • Authenticated user without a defined ability gets 403, tested
  • User with the ability can upload, same response format as before, tested
  • Project without the ability keeps working with plain auth, tested
  • Non-image, SVG and oversized uploads rejected, tested
  • Rate limiter added, tested
  • Tests have not been run yet, see below

Upgrade notes for consumers (behaviour change)

This changes the default behaviour. It ships as a patch release because it is a security fix, but it does change what consumers see:

  1. Uploads now require a logged-in user (web + auth). Projects where guests are meant to upload editor images (unlikely) must set editor_image_upload_middleware to something like ['web'].
  2. If a project defines a Gate ability edit-text, only users with that ability can upload.
  3. Projects using spatie/laravel-permission or other Gate::before()-based permissions: Gate::has() does not see these, so only auth is enforced. To require the permission, set 'editor_image_upload_middleware' => ['web', 'auth', 'can:edit-text'].
  4. CSRF is now checked. The shipped CK component already sends the token. Custom upload clients must send _token or X-CSRF-TOKEN.
  5. Only jpg/jpeg/png/gif/webp up to 8 MB are accepted. SVG uploads are rejected.
  6. Projects that already register their own protected route for the same URI (e.g. magnus-kleine-tebbe) are unaffected, because their later registration still wins.
  7. If you set editor_image_upload_enabled to false, the CK component still calls route(editor_image_upload_route_name), so you must register your own route with that name.
  8. Rollout: live sites stay exposed until each project runs composer update berlin-bird-studios/blade-components and deploys (see #34 (closed), and keutzer-boos-website#4).

Not verified

The test suite was not run for this MR. The changes were written through the GitLab API without a local checkout. Please run vendor/bin/phpunit (or CI) before merging. Points to watch:

  • test_anonymous_request_is_redirected_to_login relies on Laravel 11+ falling back to /login when no login route exists.
  • The upload tests use UploadedFile::fake()->create() with explicit MIME types, so GD is not needed.

🤖 Generated with Claude Code


🤖 Posted via the MCP server.

Merge request reports

Loading
Loading