~ >
cat> ./themes
cattier@cat13val:~$
2026-09-28 · 6 min read

Background

QueryDSL is a popular type-safe query framework in the Java ecosystem. It now lives in two repositories:

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.

Code injection PoC
RuntimeException surfacing in the HTTP response

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.