Background
QueryDSL is a popular type-safe query framework in the Java ecosystem. It now lives in two repositories:
- the original
querydsl/querydsl(groupIdcom.querydsl) — no longer actively maintained; - the OpenFeign fork
OpenFeign/querydsl(groupIdio.github.openfeign.querydsl, version 7.x) — which took over and has gained wide adoption.
The analysis below is on the OpenFeign fork.
In October 2024, CVE-2024-49203 was disclosed — an injection vulnerability through PathBuilder when
applications pass user-controlled input as a property name. The fix was merged: reject property names
containing whitespace.
Root Cause: PathBuilder and the CVE-2024-49203 Patch
PathBuilder enables dynamic path construction at runtime — convenient when you need to sort or filter
by a column name provided by the client. Along that path, a user-controlled property name is inserted
verbatim into the query string (JPQL, SQL, or Java source code), with no escaping in between.
The CVE-2024-49203 Patch
PathBuilderValidator.java:
PathBuilderValidator DEFAULT =
new PathBuilderValidator() {
private Pattern SPACES = Pattern.compile("\\s");
@Override
public Class<?> validate(Class<?> parent, String property, Class<?> propertyType) {
if (SPACES.matcher(property).find()) {
throw new IllegalStateException("Unsafe due to CVE-2024-49203");
}
return propertyType;
}
};
It only checks for \s — whitespace characters (space, tab, newline, etc.). If the property name
contains no whitespace, it flows straight into the query engine.
The natural question follows on its own: is there a way to produce token separation without any character
that matches \s?
PathBuilder in DEFAULT Mode: What Can We Do With It?
DEFAULT is the only validator standing between the property name and the query builder, and it rejects
exactly one thing: \s. Everything else is waved through and inserted verbatim into whatever the
back-end is assembling. So the real question is not whether a non-whitespace value reaches the engine —
it does — but what the engine does with it once it arrives.
That depends entirely on the back-end:
- querydsl-jpa assembles a JPQL string and hands it to Hibernate to parse. The property name becomes part of the query grammar.
- querydsl-collections assembles Java source, compiles it, and runs it. The property name becomes part of a program.
Same weak validator, two very different primitives. And there is still that open question from above — can
you separate tokens without any \s? Keep it in mind as we follow each flow, starting with the one closest
to the original CVE: JPQL injection.
Case 1 — JPQL Injection via querydsl-jpa (CWE-89)
Data Flow
With querydsl-jpa, PathBuilder is used and the query runs through JPA:
PathBuilder<Cat> cat = new PathBuilder<>(Cat.class, "cat");
new JPAQuery<>(em).select(cat).from(cat)
.where(cat.getString(field).eq(value))
.fetch();
The serializer generates JPQL (not Java source):
HTTP Request
└─ field = "color"
└─ PathBuilder.getString(field)
└─ PathBuilderValidator.DEFAULT.validate() ← only checks \s
└─ JPQLSerializer → SerializerBase.handleTemplate()
└─ Template: PROPERTY = "{0}.{1s}"
└─ {1s} → AsString → builder.append(rv.toString())
└─ JPQL: "cat.color"
The template {0}.{1s} is defined at Templates.java:237:
add(PathType.PROPERTY, "{0}.{1s}");
The s suffix means AsString — the value is inserted via builder.append(rv.toString())
at SerializerBase.java:172, with no escaping and no quoting. The property name ends up inside the
JPQL fragment that is about to be parsed.
Easy? You can do it!
Case 2 — Code Injection via querydsl-collections (CWE-94)
How querydsl-collections Works
Unlike querydsl-jpa (which generates JPQL) or querydsl-sql (which generates SQL), querydsl-collections
operates on plain Java collections. Its approach is unusual: it generates Java source code at runtime,
compiles it with the JDK compiler (or ECJ), then executes the resulting bytecode.
When you write:
PathBuilder<Cat> cat = new PathBuilder<>(Cat.class, "cat");
CollQueryFactory.from(cat, cats).where(cat.getString(field).eq(value)).fetch();
QueryDSL generates a temporary Java class, compiles it, and invokes it.
Data Flow: From HTTP Request to Generated Source
When the property name exists (has a getter on the bean), the serializer calls the getter directly:
HTTP Request
└─ field = "color"
└─ PathBuilder.getString(field)
└─ PathBuilderValidator.DEFAULT.validate() ← only checks \s
└─ CollQuerySerializer.visit(Path)
└─ getAccessor(Cat.class, "color") ← looks for getter
└─ Cat.getColor() found ✓
└─ append: "cat.getColor()"
But when the property name does not exist:
HTTP Request
└─ field = "noSuchProp"
└─ PathBuilderValidator.DEFAULT.validate() ← pass (no \s)
└─ CollQuerySerializer.visit(Path)
└─ getAccessor(Cat.class, "noSuchProp") ← null
└─ getField(Cat.class, "noSuchProp") ← null
└─ FALLBACK: append raw string
Here is the fallback code in CollQuerySerializer.java:103:
// field access by reflection
append(CollQueryFunctions.class.getName() + ".<");
append((path.getType()).getName()).append(">get(");
handle(parent);
append(", \"" + property + "\")");
// ^^^^^^^^^^^^^^^^
// property inserted RAW into string literal
This produces:
CollQueryFunctions.<java.lang.String>get(cat, "noSuchProp")
The property name ends up inside a Java string literal in source that is about to be compiled and run.

Two back-ends, one value. In querydsl-jpa the property name lands inside a JPQL fragment that is about
to be parsed; in querydsl-collections it lands inside a Java string literal that is about to be compiled.
In both, the only thing standing between the HTTP parameter and the query engine is a check for \s — and
the value arrives otherwise untouched.
Using PathBuilder Correctly
PathBuilder itself is not the problem — DEFAULT mode is. The library ships two stricter validators that
check the property name against the actual bean, so a value that is not a real property is rejected before
any query (JPQL, SQL, or generated Java) is built. Pass one explicitly through the three-argument constructor:
// DEFAULT (implicit): only rejects \s — the mode both cases above rely on
PathBuilder<Cat> cat = new PathBuilder<>(Cat.class, "cat");
// PROPERTIES: the value must resolve to a real getter (via Introspector) — recommended
PathBuilder<Cat> cat = new PathBuilder<>(Cat.class, "cat", PathBuilderValidator.PROPERTIES);
// FIELDS: the value must resolve to a declared field
PathBuilder<Cat> cat = new PathBuilder<>(Cat.class, "cat", PathBuilderValidator.FIELDS);
PROPERTIES mirrors the mechanism querydsl-collections already uses internally (Introspector), so only a
real, allowlisted property ever reaches the serializer.
Better still, don’t hand a raw client string to PathBuilder at all. Keep a fixed map of the sort/filter
columns your API allows, and look the incoming value up in that map — anything not in it never becomes a
property name in the first place.
Analysis performed on querydsl OpenFeign v7.7, Hibernate 6.5 (Spring Boot 3.3.4), PostgreSQL 16.