Skip to content

Dictionary fieldtype endpoint resolves permissions via the default guard, crashing when a non-Statamic user is authenticated #15204

Description

@christophstockinger

Bug description

The dictionary fieldtype endpoint (GET /!/fieldtypes/dictionaries/{dictionary}) resolves the access cp permission through Laravel's default guard instead of the configured CP guard. In an application where auth.defaults.guard and statamic.users.guards.cp differ, this produces two failures:

  1. The endpoint always denies CP users. The CP user is authenticated on the CP guard, but the Gate looks at the default guard and sees a guest, so Gate::denies('access cp') is true. Dictionary dropdowns in the CP silently render with no options.
  2. It fatals when a non-Statamic user is authenticated on the default guard. Gate::after in AuthServiceProvider calls User::fromUser($user), which returns null for anything that isn't a Statamic user, then dereferences it unguarded:
Error: Call to a member function isSuper() on null
vendor/statamic/cms/src/Providers/AuthServiceProvider.php:157

Defect 2 is not limited to this endpoint — it will fire for any Statamic permission check against a non-Statamic authenticatable. This endpoint is just the reliable way to reach it, because it is the only non-CP controller in the codebase that calls Gate.

Environment

Statamic 6.27.2 (Pro)
Laravel 13.25.0
PHP 8.4
statamic.users.repository file
statamic.users.guards.cp statamic
auth.defaults.guard web (Eloquent provider, app's own user model)

This is a Statamic site that also serves a separate authenticated area for application users. CP editors live in users/*.yaml; application users live in the database. That is exactly the split statamic.users.guards.cp exists for.

How to reproduce

  1. Install Statamic with the file user repository.
  2. Add a second guard for the application's own users and make it the default:
// config/auth.php
'defaults' => ['guard' => 'web'],

'guards' => [
    'web'      => ['driver' => 'session', 'provider' => 'users'],    // Eloquent
    'statamic' => ['driver' => 'session', 'provider' => 'statamic'],
],
// config/statamic/users.php
'repository' => 'file',
'guards' => ['cp' => 'statamic', 'web' => 'web'],
  1. Add a dictionary that does not allow public access (see note below on why the built-ins hide this):
namespace App\Dictionaries;

use Statamic\Dictionaries\BasicDictionary;

class Role extends BasicDictionary
{
    protected function getItems(): array
    {
        return [
            ['value' => 'admin', 'label' => 'Administrator'],
            ['value' => 'user',  'label' => 'User'],
        ];
    }
}
  1. Put a dictionary field using it on any blueprint, log into the CP, open the publish form.
    The dropdown is empty. The request returns 403.
  2. In the same browser, additionally log in as an Eloquent user on the web guard, then reload the publish form.
    The request returns 500 with Call to a member function isSuper() on null.

Expected

The dropdown loads its options for an authenticated CP user, regardless of what is authenticated on the default guard. An unauthorized request returns 403, never 500.

Actual

The dropdown never loads. Depending on the default guard's session, the request is either 403 or a fatal 500.

Why this is easy to miss

Every built-in concrete dictionary — Countries, Currencies, Languages, Locales, Timezones — overrides allowsPublicAccess(): true, which short-circuits the permission check in DictionaryFieldtypeController before the Gate is consulted:

if (Gate::denies('access cp') && ! $dictionary->allowsPublicAccess()) {

Only File and user-defined dictionaries inherit the false default from Dictionaries\Dictionary::allowsPublicAccess() and actually reach the Gate. So on a default install the broken path is almost never exercised.

Analysis

Defect 1 — the route never sets the CP guard

routes/web.php registers the endpoint inside the action-prefix group with no guard middleware:

// vendor/statamic/cms/routes/web.php
Route::group(['prefix' => config('statamic.routes.action')], function () {
    ...
    // no guard middleware
    Route::get('fieldtypes/dictionaries/{dictionary}', DictionaryFieldtypeController::class)
        ->middleware('throttle:statamic.dictionaries')
        ->name('dictionary-fieldtype');

    // the neighbouring routes in the very same group do set one
    Route::group(['prefix' => 'auth', 'middleware' => [AuthGuard::class]], function () {

Every CP route runs through Statamic\Http\Middleware\CP\AuthGuard, and the frontend auth routes run through Statamic\Http\Middleware\AuthGuard. Both middlewares exist and do nothing but Auth::shouldUse(...). This route — which asks a CP question, access cp — gets neither, so Gate::resolveUser() falls back to auth.defaults.guard.

Defect 2 — Gate::after does not honour fromUser()'s nullable contract

// vendor/statamic/cms/src/Providers/AuthServiceProvider.php:149-161
Gate::after(function ($user, $ability) {
    if (! Permission::boot()->flattened()->map->value()->contains($ability)) {
        return null;
    }

    $user = User::fromUser($user);   // may be null

    if ($user->isSuper()) {          // fatal

null is a documented outcome, not an edge case:

// src/Contracts/Auth/UserRepository.php:24
public function fromUser($user): ?User;
// src/Stache/Repositories/UserRepository.php:77-84
public function fromUser($user): ?User
{
    if ($user instanceof User) {
        return $user;
    }

    return null;
}

And Statamic itself guards it elsewhere in the same namespace:

// src/Auth/SetLastLoginTimestamp.php:14
if ($user = User::fromUser($event->user)) {
    $user->setLastLogin(now());
}

So the Gate::after callback is inconsistent with both its own contract and the rest of the codebase.

Proposed fixes

Defect 2 — return null so the callback abstains instead of crashing. null rather than false matters: false would override an allow decision made by another policy.

$user = User::fromUser($user);

if (! $user) {
    return null; // not a Statamic user — don't get involved
}

Defect 1 — give the route the CP guard, matching what every other CP-permission-checking route already does:

Route::get('fieldtypes/dictionaries/{dictionary}', DictionaryFieldtypeController::class)
    ->middleware([CPAuthGuard::class, 'throttle:statamic.dictionaries'])
    ->name('dictionary-fieldtype');

Note this must be the CP guard specifically, not the CP middleware group — the group would redirect guests to the CP login and break the public dictionaries that are currently reachable anonymously by design.

Alternatively the controller could resolve the check against the CP guard explicitly:

$user = auth()->guard(config('statamic.users.guards.cp', 'web'))->user();

if (Gate::forUser($user)->denies('access cp') && ! $dictionary->allowsPublicAccess()) {
    throw new ForbiddenHttpException;
}

Happy to open a PR for either or both if you'd like — just let me know which direction you prefer for defect 1.

Workaround

For anyone hitting this before it is fixed upstream, appending a middleware to the web group that swaps the guard for this one route restores the intended behaviour:

class ResolveCpGuardForDictionaryFieldtype
{
    public function handle(Request $request, Closure $next): Response
    {
        if ($request->route()?->getName() === 'statamic.dictionary-fieldtype') {
            Auth::shouldUse(config('statamic.users.guards.cp', 'web'));
        }

        return $next($request);
    }
}

One caveat worth passing on: register it last in the group. Anything running after it that resolves $request->user() — an Inertia share(), for instance — will otherwise receive the CP user instead of the application user.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions