1. Overview

A critical SQL injection vulnerability exists in the dotCMS Core content management system’s Publish Audit API. The /api/auditPublishing/getAll REST endpoint accepts a JSON array of bundle identifiers and passes them unsanitized into a SQL query via string concatenation, allowing an attacker to inject arbitrary SQL statements. The endpoint requires no authentication, enabling an unauthenticated remote attacker to read, modify, or destroy the entire dotCMS PostgreSQL database with a single HTTP request. dotCMS addressed this vulnerability in version 26.04.28-03 by parameterizing the SQL query and adding Push Publish JWT token authentication to the affected endpoints.

2. Vulnerability Type

FieldValue
Primary CWECWE-89: Improper Neutralization of Special Elements used in an SQL Command (‘SQL Injection’)
Related CWECWE-306: Missing Authentication for Critical Function

The primary vulnerability is classic SQL injection via string concatenation into a parameterizable query. The missing authentication is a compounding factor that elevates the severity from post-auth to pre-auth.

3. Severity

CVSS 4.0 (from NVD)

FieldValue
Score10.0 (Critical)
VectorCVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:H/VI:H/VA:H/SC:H/SI:H/SA:H

Our Assessment (CVSS 4.0)

Metric GroupMetricValue
Base — ExploitabilityAttack Vector (AV)Network
Attack Complexity (AC)Low
Attack Requirements (AT)None
Privileges Required (PR)None
User Interaction (UI)None
Base — Vulnerable SystemConfidentiality (VC)High
Integrity (VI)High
Availability (VA)High
Base — Subsequent SystemConfidentiality (SC)High
Integrity (SI)High
Availability (SA)High
ThreatExploit Maturity (E)Proof-of-Concept
FieldValue
CVSS 4.0 Score10.0 (Critical)
CVSS 4.0 VectorCVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:H/VI:H/VA:H/SC:H/SI:H/SA:H/E:P

Assessment rationale: The vulnerability is trivially exploitable with no authentication, no special configuration, and no user interaction. The attacker obtains full read/write/delete access to the database, which contains all CMS content, user credentials, API keys, and configuration. The Subsequent System impact is High across all three metrics because dotCMS manages content served to end users (integrity compromise of published websites), stores credentials for integrated systems (confidentiality compromise of downstream services), and a database destruction attack would take the entire CMS offline (availability compromise of all dependent services). The default dotCMS database user is the database owner, granting full schema-level control.

4. Affected Products

Affected Products

ProductVersion RangeCPE 2.3
dotCMS Core25.11.04-1 through 26.04.28-02cpe:2.3:a:dotcms:dotcms:*:*:*:*:*:*:*:*

Note: LTS releases (24.12.27 LTS, 25.07.10 LTS) are not affected.

Tested Environment (Vulnerable)

FieldValue
ProductdotCMS Core
Version26.04.21-01
Docker Imagedotcms/dotcms:26.04.21-01
RuntimeApache Tomcat 9.0.113 / OpenJDK
DatabasePostgreSQL 16.14 (Alpine)
SearchOpenSearch 1.x
Vulnerable Classcom.dotcms.publisher.business.PublishAuditAPIImpl
Vulnerable Endpointcom.dotcms.rest.AuditPublishingResource

Tested Environment (Patched)

FieldValue
ProductdotCMS Core
Version26.04.28-03+
Fix PRdotCMS/core#35553
Fix Date2026-05-06 (merged)
Fix DescriptionParameterized SQL queries + Push Publish JWT authentication

5. Root Cause Analysis

5a. Detailed Description

dotCMS is an open-source Java CMS that supports “Push Publishing” — replicating content between dotCMS instances. The publishing_queue_audit table tracks bundle delivery status. Two REST endpoints expose audit queries:

EndpointMethodPurpose
/api/auditPublishing/get/{bundleId}GETSingle bundle status
/api/auditPublishing/getAllPOSTMultiple bundle statuses (JSON array body)

The /getAll endpoint is served by AuditPublishingResource.getAll(), which receives a List<String> of bundle IDs from the JSON request body and passes them directly to PublishAuditAPIImpl.getPublishAuditStatuses().

The getPublishAuditStatuses() method constructs a SQL IN() clause by wrapping each bundle ID in single quotes via Java string concatenation:

final List<String> parameter = bundleIds.stream()
    .map(id -> "'" + id + "'")       // wraps in quotes — NOT escaped, NOT parameterized
    .collect(Collectors.toList());

dc.setSQL(String.format(SELECT_ALL_BY_BUNDLES_IDS,
    String.join(",", parameter)));   // interpolated directly into SQL via String.format

The SQL template is:

SELECT * FROM publishing_queue_audit WHERE bundle_id IN (%s)

An attacker-controlled bundle ID of x'); SELECT pg_sleep(5)-- produces:

SELECT * FROM publishing_queue_audit WHERE bundle_id IN ('x'); SELECT pg_sleep(5)--')

The PostgreSQL JDBC driver supports stacked queries (multiple SQL statements in a single execute() call), so the attacker can execute arbitrary SQL including INSERT, UPDATE, DELETE, DROP, and SELECT against any table.

The second vulnerability is the complete absence of authentication on both endpoints. The AuditPublishingResource class in vulnerable versions contains no @Context HttpServletRequest parameter, no token validation, no WebResource.InitBuilder, and no call to any authentication utility. Any anonymous HTTP request reaches the vulnerable SQL code path.

The combination of CWE-89 (SQL Injection) and CWE-306 (Missing Authentication) yields a pre-auth, network-reachable SQL injection with CVSS 10.0.

5b. Vulnerable Source Code and Call Path

The following source code was extracted from dotCMS Core version 26.04.21-01 (pre-fix). Comments added by the analyst are marked with **.

AuditPublishingResource.java — REST endpoint (no authentication):

@Path("/auditPublishing")
@Tag(name = "Publishing")
public class AuditPublishingResource {
    private PublishAuditAPI auditAPI = PublishAuditAPI.getInstance();

    // ** NO @Context HttpServletRequest, NO token check, NO auth of any kind

    @POST
    @Path("/getAll")
    @Produces(MediaType.APPLICATION_JSON)
    public Response getAll(List<String> bundleIds) {      // ** bundleIds from JSON body
        try {
            final List<PublishAuditStatus> statuses =
                auditAPI.getPublishAuditStatuses(bundleIds); // ** passes unsanitized input

            if (statuses != null)
                return Response.ok(
                    statuses.stream()
                        .map(status -> status.getStatusPojo().getSerialized())
                ).build();
        } catch (DotPublisherException e) {
            Logger.warn(this, "error trying to get status for bundle "
                + bundleIds.get(0), e);
        }
        return Response.status(404).build();               // ** SQL errors caught here
    }
}

PublishAuditAPIImpl.java — SQL query construction:

// SQL template
private static final String SELECT_ALL_BY_BUNDLES_IDS =
    "select * from publishing_queue_audit where bundle_id in (%s) ";

@Override
@CloseDBIfOpened
public List<PublishAuditStatus> getPublishAuditStatuses(List<String> bundleIds)
        throws DotPublisherException {
    try {
        final List<PublishAuditStatus> result = new ArrayList<>();

        DotConnect dc = new DotConnect();
        final List<String> parameter = bundleIds.stream()
            .map(id -> "'" + id + "'")         // ** VULN: string concatenation, no escaping
            .collect(Collectors.toList());

        dc.setSQL(String.format(
            SELECT_ALL_BY_BUNDLES_IDS,
            String.join(",", parameter)));      // ** VULN: user input interpolated into SQL

        List<Map<String, Object>> items = dc.loadObjectResults();  // ** executes the query

        for (Map<String, Object> item : items) {
            result.add(turnIntoPublishAuditStatus(NO_LIMIT_ASSETS, item));
        }
        return result;
    } catch (Exception e) {
        Logger.debug(PublisherUtil.class, e.getMessage(), e);  // ** DEBUG level only!
        throw new DotPublisherException(
            "Unable to get list of elements with error:" + e.getMessage(), e);
    }
}

Call path:

HTTP POST /api/auditPublishing/getAll
  -> JAX-RS deserializes JSON body into List<String>
    -> AuditPublishingResource.getAll(bundleIds)      [no auth check]
      -> PublishAuditAPI.getPublishAuditStatuses(bundleIds)
        -> PublishAuditAPIImpl.getPublishAuditStatuses(bundleIds)
          -> "'" + id + "'"  for each bundleId         [string concat]
          -> String.format(SQL_TEMPLATE, joined)        [interpolation]
          -> DotConnect.setSQL(...)
          -> DotConnect.loadObjectResults()             [SQL executed]
            -> PostgreSQL JDBC executeQuery(...)

5c. Fix (Patched Version)

The fix was applied in PR #35553, merged 2026-05-06 and released in version 26.04.28-03. The fix addresses both vulnerabilities:

Fix 1: Parameterized SQL queries

VulnerablePatched
bundleIds.stream().map(id -> "'" + id + "'")bundleIds.stream().map(id -> "?")
String.join(",", parameter) into String.format(SQL, ...)Collectors.joining(",") into String.format(SQL, ...)
(no parameter binding)bundleIds.forEach(dc::addParam)

Patched code:

if (bundleIds == null || bundleIds.isEmpty()) {       // ** Added null/empty guard
    return Collections.emptyList();
}
// ...
final String placeholders = bundleIds.stream()
        .map(id -> "?")                                // ** FIXED: ? placeholder
        .collect(Collectors.joining(","));
dc.setSQL(String.format(SELECT_ALL_BY_BUNDLES_IDS, placeholders));
bundleIds.forEach(dc::addParam);                       // ** FIXED: JDBC parameter binding

Fix 2: Authentication added to endpoints

Both get() and getAll() now validate a Push Publish JWT token via AuthCredentialPushPublishUtil.INSTANCE.processAuthHeader(request). Requests without a valid Authorization: Bearer header receive HTTP 401.

@POST
@Path("/getAll")
@Produces(MediaType.APPLICATION_JSON)
public Response getAll(final List<String> bundleIds,
                       @Context final HttpServletRequest request) {    // ** Added

    final AuthCredentialPushPublishUtil.PushPublishAuthenticationToken ppAuthToken =
            AuthCredentialPushPublishUtil.INSTANCE.processAuthHeader(request);
    final Optional<Response> failResponse =
            PushPublishResourceUtil.getFailResponse(request, ppAuthToken);
    if (failResponse.isPresent()) {
        return failResponse.get();                                     // ** Returns 401
    }
    // ... parameterized query logic follows
}

Fix 3: Logger correction

The catch block logger was corrected from Logger.debug(PublisherUtil.class, ...) (wrong class, debug level) to Logger.error(PublishAuditAPIImpl.class, ...) (correct class, error level), improving visibility of exploitation attempts.

5d. Impact

The confirmed impact is full read/write/delete access to the dotCMS PostgreSQL database without authentication. During testing, we successfully extracted the complete PostgreSQL version string, user accounts with credentials, and the database schema using stacked INSERT ... SELECT queries injected through a single HTTP POST request. The attack requires one HTTP request for injection and one for retrieval, with no special tools, credentials, or prior knowledge of the target.

The theoretical maximum impact extends to remote code execution on the database server. If the dotCMS database user has PostgreSQL superuser privileges (not the default in Docker deployments but possible in manual installations), the COPY TO PROGRAM command enables arbitrary OS command execution. Even without superuser, the attacker can modify all CMS content (defacement, malware injection into served pages), create admin accounts for persistent access, extract API keys and integration credentials stored in the database, and delete the entire database to cause a permanent denial of service.

6. Proof-of-Concept

6a. PoC Code

Download poc_cve_2026_8054.py (enterprise email verification required)

FileDescription
poc_cve_2026_8054.pyPython 3 PoC — time-based blind, error-based, and stacked query data exfiltration

6b. Reproduce Instructions

Prerequisites:

  • Container runtime: podman or Docker
  • Python 3.6+ (for the PoC script)
  • Network access to the target dotCMS instance

Environment Setup:

# Create pod with port mappings
podman pod create --name dotcms-vuln -p 8082:8082

# Start PostgreSQL
podman run -d --pod dotcms-vuln --name dotcms-db \
  -e POSTGRES_USER=dotcmsdbuser \
  -e POSTGRES_PASSWORD=password \
  -e POSTGRES_DB=dotcms \
  docker.io/library/postgres:16-alpine

# Start OpenSearch (required by dotCMS)
podman run -d --pod dotcms-vuln --name dotcms-opensearch \
  -e "discovery.type=single-node" \
  -e "OPENSEARCH_JAVA_OPTS=-Xmx512m -Xms512m" \
  -e "DISABLE_SECURITY_PLUGIN=true" \
  docker.io/opensearchproject/opensearch:1

# Start vulnerable dotCMS (pre-fix version)
podman run -d --pod dotcms-vuln --name dotcms-app \
  -e "CMS_JAVA_OPTS=-Xmx1g" \
  -e "DB_BASE_URL=jdbc:postgresql://localhost/dotcms" \
  -e "DB_USERNAME=dotcmsdbuser" -e "DB_PASSWORD=password" \
  -e "DOT_ES_ENDPOINTS=http://localhost:9200" \
  -e "DOT_INITIAL_ADMIN_PASSWORD=admin" \
  docker.io/dotcms/dotcms:26.04.21-01

Wait 2-3 minutes for first-boot schema creation. Verify readiness:

curl -s -o /dev/null -w "%{http_code}" http://localhost:8082/api/v1/appconfiguration
# Expected: 200

Reproduction Steps:

  1. Run the PoC vulnerability test:
python3 poc_cve_2026_8054.py http://localhost:8082 --test
  1. Observe the output confirming SQL injection:

    • Endpoint accessible without authentication (HTTP 200)
    • Time-based blind confirmed: ~3s delay from pg_sleep(3) vs ~0.01s baseline
    • Error-based confirmed: differential response (200 vs 404) for normal vs broken SQL
  2. Demonstrate data exfiltration:

python3 poc_cve_2026_8054.py http://localhost:8082 --dump-version
python3 poc_cve_2026_8054.py http://localhost:8082 --dump-users
  1. Verify extracted data directly in the database:
podman exec dotcms-db psql -U dotcmsdbuser -d dotcms -c \
  "SELECT bundle_id FROM publishing_queue_audit WHERE bundle_id LIKE 'SQLI-POC%';"

6c. Test Results

Vulnerability Detection:

TestBaselineInjectedResult
Authentication checkPOST without credentialsHTTP 200 (no auth required)
Time-based blind (pg_sleep(3))0.01s response3.01s responseCONFIRMED (+3.00s delta)
Error-based (broken SQL x')HTTP 200, 2 bytesHTTP 404, 0 bytesCONFIRMED (differential)

Data Exfiltration:

TestMethodExtracted Data
Database versionStacked INSERT…SELECT version()PostgreSQL 16.14 on x86_64-pc-linux-musl
User credentialsStacked INSERT…SELECT FROM user_4 accounts extracted (including default admin with factory credentials)
Database schemaStacked INSERT…SELECT FROM information_schema.tables20+ tables: user_, address, company, passwordtracker, permission_reference, tag, etc.

6d. Patched System Verification

On the patched version (dotcms/dotcms:26.04.22-02 and later), all requests to /api/auditPublishing/getAll without a valid Push Publish JWT token return:

HTTP/1.1 401
WWW-Authenticate: Bearer realm="example",error="invalid_token",
                  error_key="__invalid_token__",error_description=""
Content-Type: application/json

The SQL injection payload never reaches the database because the authentication check rejects the request before getPublishAuditStatuses() is called. Additionally, even if authentication were bypassed, the parameterized query binds all bundle IDs as JDBC parameters, preventing SQL injection.

7. Detection

Note: The detection rules below are provided as a starting point. Validate and tune them in your own environment before deploying to production.

7a. Network-Based Detection

Signature-Based Detection

The attack is delivered as an HTTP POST request to /api/auditPublishing/getAll with a JSON array body. Legitimate requests contain UUID-formatted bundle identifiers (e.g., "3fa85f64-5717-4562-b3fc-2c963f66afa6"). Attack traffic contains SQL metacharacters in the JSON string values — specifically single quotes ('), semicolons (;), and SQL comment sequences (--).

Suricata Rules

# CVE-2026-8054: dotCMS Publish Audit API - SQL injection (single quote in JSON body)
alert http any any -> any any (msg:"CVE-2026-8054 dotCMS auditPublishing SQLi - single quote in body"; \
  flow:to_server,established; \
  http.method; content:"POST"; \
  http.uri; content:"/api/auditPublishing/getAll"; \
  http.request_body; content:"'"; \
  reference:cve,2026-8054; \
  classtype:web-application-attack; \
  sid:2026805401; rev:1;)

# CVE-2026-8054: dotCMS Publish Audit API - SQL injection (pg_sleep time-based blind)
alert http any any -> any any (msg:"CVE-2026-8054 dotCMS auditPublishing SQLi - pg_sleep"; \
  flow:to_server,established; \
  http.method; content:"POST"; \
  http.uri; content:"/api/auditPublishing/"; \
  http.request_body; content:"pg_sleep"; nocase; \
  reference:cve,2026-8054; \
  classtype:web-application-attack; \
  sid:2026805402; rev:1;)

# CVE-2026-8054: dotCMS Publish Audit API - SQL injection (stacked query keywords)
alert http any any -> any any (msg:"CVE-2026-8054 dotCMS auditPublishing SQLi - SQL keyword in body"; \
  flow:to_server,established; \
  http.method; content:"POST"; \
  http.uri; content:"/api/auditPublishing/getAll"; \
  http.request_body; pcre:"/(?:INSERT|DELETE|UPDATE|DROP|UNION|COPY)\s/i"; \
  reference:cve,2026-8054; \
  classtype:web-application-attack; \
  sid:2026805403; rev:1;)

# CVE-2026-8054: dotCMS Publish Audit API - unauthenticated access (pre-patch)
alert http any any -> any any (msg:"CVE-2026-8054 dotCMS auditPublishing unauthenticated access"; \
  flow:to_server,established; \
  http.uri; content:"/api/auditPublishing/"; \
  http.header; content:!"Authorization"; \
  reference:cve,2026-8054; \
  classtype:web-application-attack; \
  sid:2026805404; rev:1;)

Byte Offset Reference

The attack payload is embedded in the HTTP request body as a JSON array. The offsets are relative to the start of the HTTP body:

Offset   Content                          Description
-----------------------------------------------------------------
0x00     [                                JSON array open
0x01     "                                First string element open
0x02     x'); <SQL_PAYLOAD>--             Injection payload
N        "                                String element close
N+1      ]                                JSON array close

The critical injection point is the content between the JSON string delimiters ("). Any single quote character (0x27) within these strings can break out of the SQL context.

7b. Host-Based Detection (YARA)

Since dotCMS is a Java application (WAR/JAR deployment), YARA rules target the compiled .class files rather than native PE binaries. The rules detect the vulnerable string concatenation pattern and the patched parameterized query pattern in the PublishAuditAPIImpl class file.

rule CVE_2026_8054_dotCMS_Vulnerable_PublishAuditAPIImpl {
    meta:
        description = "Detects vulnerable dotCMS PublishAuditAPIImpl with SQL string concatenation"
        cve = "CVE-2026-8054"
        component = "dotCMS Core Publish Audit API"
        severity = "Critical"
        type = "vulnerability"

    strings:
        // Vulnerable SQL template — string constant in class file
        $sql_template = "select * from publishing_queue_audit where bundle_id in (%s)" ascii

        // String concatenation pattern: wrapping in single quotes
        $quote_wrap = "'" ascii

        // Class identifier
        $class_name = "PublishAuditAPIImpl" ascii

        // Vulnerable method uses String.format with user input
        $string_format = "String.format" ascii

    condition:
        $class_name and $sql_template and $string_format
}

rule CVE_2026_8054_dotCMS_Patched_PublishAuditAPIImpl {
    meta:
        description = "Detects patched dotCMS PublishAuditAPIImpl with parameterized queries"
        cve = "CVE-2026-8054"
        component = "dotCMS Core Publish Audit API"
        severity = "Critical"
        type = "patch_verification"

    strings:
        $class_name = "PublishAuditAPIImpl" ascii
        $sql_template = "select * from publishing_queue_audit where bundle_id in (%s)" ascii

        // Patched version uses addParam for JDBC parameter binding
        $add_param = "addParam" ascii

        // Auth check class referenced in patched endpoint
        $auth_check = "AuthCredentialPushPublishUtil" ascii

    condition:
        $class_name and $sql_template and $add_param
}

Usage:

# Scan dotCMS installation for vulnerable class
find /opt/dotcms/ -name "*.class" -o -name "*.jar" | xargs yara -s cve_2026_8054.yar

# Scan WAR file directly (extract first)
jar xf dotcms.war && yara -r -s cve_2026_8054.yar WEB-INF/
RuleMatch Means
CVE_2026_8054_dotCMS_Vulnerable_PublishAuditAPIImplVulnerable version detected — string concatenation in SQL query
CVE_2026_8054_dotCMS_Patched_PublishAuditAPIImplPatched version detected — parameterized query with addParam binding

8. References

SourceURL
dotCMS Advisory (SI-75)https://dev.dotcms.com/docs/known-security-issues?issueNumber=SI-75
NVDhttps://nvd.nist.gov/vuln/detail/CVE-2026-8054
MITRE CVEhttps://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2026-8054
Fix PRhttps://github.com/dotCMS/core/pull/35553
dotCMS Docker Hubhttps://hub.docker.com/r/dotcms/dotcms