# WebVella ERP: authenticated SQL injection through EQL text literals in the query API
 inside a string literal into one literal quote before this interpolation happens, so a value written in EQL as `'x'' OR ''1''=''1'` is decoded to `x' OR '1'='1` and then re-wrapped as `'x' OR '1'='1'`, which closes the SQL string and injects attacker SQL into the WHERE clause. The endpoint is reached by any authenticated principal because the controller is guarded only by a role-less `[Authorize]` attribute. The single entity-level read permission check runs after the SQL has already executed and only inspects the SELECT field entities, so it does not stop the injection. A low-privilege user can read and tamper with data belonging to other users and entities, including the user and role tables, crossing a user/tenant data boundary that the role system is supposed to enforce.
`POST /api/v3/en_US/eql` is handled by `EqlQueryAction`. It takes the request body model and executes `model.Eql` directly.
`WebVella.Erp.Web/Controllers/WebApiController.cs:64-96`
public ActionResult EqlQueryAction([FromBody] EqlQuery model)
ResponseModel response = new ResponseModel();
var eqlResult = new EqlCommand(model.Eql, model.Parameters).Execute();
`model.Eql` is the full EQL query text supplied by the caller. It flows unchanged into `EqlCommand`.
### 2. The auth guard only checks authentication, not role
The controller and its base class carry a role-less `[Authorize]`.
`WebVella.Erp.Web/Controllers/WebApiController.cs:37`
public class WebApiController : ApiControllerBase
`WebVella.Erp.Web/Controllers/ApiControllerBase.cs:9`
public class ApiControllerBase : Controller
`[Authorize]` with no role and no policy admits any authenticated principal. A JWT for any user is obtained from `POST /api/v3/en_US/auth/jwt/token` and attached by `JwtMiddleware`, so the lowest-privilege user reaches `EqlQueryAction`.
### 3. The EQL compiler interpolates text literals into SQL
`EqlCommand.Execute()` builds the SQL with `EqlBuilder` and runs it with Npgsql.
EqlBuilder eqlBuilder = new EqlBuilder(Text, CurrentContext, Settings);
var eqlBuildResult = eqlBuilder.Build(Parameters);
using (var connection = CurrentContext.CreateConnection())
command = connection.CreateCommand(eqlBuildResult.Sql, parameters: npgsParameters);
Arguments bound as `@name` become `NpgsqlParameter` objects and are safe. The WHERE text literal path is not. In `ProcessExpressionOperandNode` the literal is concatenated into the SQL string wrapped in single quotes.
`WebVella.Erp/Eql/EqlBuilder.Sql.cs:572-582`
operandString = $"@{((EqlArgumentValueNode)operandNode).ArgumentName}";
if (!ExpectedParameters.Contains(operandString))
operandString = $"'{((EqlNumberValueNode)operandNode).Number.ToString()}'";
operandString = $"'{((EqlTextValueNode)operandNode).Text}'";
The argument branch produces a bound parameter reference. The text branch produces an inline literal that embeds `Text` with no escaping. That `operandString` is then placed into the comparison expression that becomes the final SQL.
return $" ( {firstOperandString} {expNode.Operator} {secondOperandString} ) ";
The `CONTAINS` and `STARTSWITH` operators embed the same operand string inside `CONCAT`, so they carry the injection too.
`WebVella.Erp/Eql/EqlBuilder.Sql.cs:730` and `:743`
return $" ( {firstOperandString} ILIKE CONCAT ( '%' , {secondOperandString} , '%' ) )";
return $" ( {firstOperandString} ILIKE CONCAT ( {secondOperandString},'%' ) ) ";
### 4. The grammar decodes a doubled quote into a single quote
The decisive detail is what `Text` contains. The EQL string terminal uses doubled-quote escaping.
var STRING = new StringLiteral("STRING", "'", StringOptions.AllowsDoubledQuote);
With `AllowsDoubledQuote`, the Irony lexer treats `''` inside a string literal as one literal quote and stores the decoded value in `Token.ValueString`. The builder copies that decoded value straight into the AST node.
return new EqlTextValueNode { Text = parseTreeNode.ChildNodes[0].Token.ValueString };
So an EQL source literal written as `'a''b'` is decoded to `Text = a'b`. Re-wrapping that at the sink as `'{Text}'` yields the SQL fragment `'a'b'`. The first embedded quote closes the string after `a`, and `b'` becomes SQL code. The escape that is correct for the input is dropped on output, which reintroduces an unescaped quote into the generated SQL.
### 5. The permission check runs too late and in the wrong place
An entity read permission check exists, but it runs during result materialization, after the query has executed, and only for the entities named in the SELECT field meta.
var entity = entMan.ReadEntity(meta.Field.EntityName).Object;
throw new Exception($"Entity '{meta.Field.Name}' not found");
bool hasPermisstion = SecurityContext.HasEntityPermission(EntityPermission.Read, entity);
throw new Exception($"No access to entity '{meta.Field.EntityName}'");
participant A as Low-privilege authenticated user
participant C as WebApiController EqlQueryAction
A->>M: POST api v3 en_US auth jwt token with valid user
A->>C: POST api v3 en_US eql with EQL whose text literal uses doubled quote breakout
B->>B: Decode doubled quote to single quote then interpolate into WHERE
B-->>C: SQL string containing attacker fragment
C->>D: Execute generated SQL under server db account
D-->>C: Rows from other entities or system tables
C-->>A: JSON result reflecting injected query
The request examples below are illustrative and were NOT executed against any deployment. They describe the source-derived behavior.
1. Obtain a session. Any enabled user account works, regardless of ERP role.
- Request (illustrative, unexecuted): `POST /api/v3/en_US/auth/jwt/token` with a JSON body holding the username and password of a low-privilege account.
- State change: none on the server. The caller now holds a bearer token.
2. Send a benign EQL query first to confirm the shape of the response.
- Request (illustrative, unexecuted): `POST /api/v3/en_US/eql` with the bearer token and body `{"eql":"SELECT id FROM user WHERE username = 'nobody'"}`.
- Response: a `ResponseModel` with an empty or small object list.
- State change: none. This establishes the baseline result set for the same query without a breakout.
3. Send the injected query. The WHERE text literal uses a doubled single quote so the lexer decodes it to one quote, and the sink re-emits that quote into SQL unescaped.
- Request (illustrative, unexecuted): `POST /api/v3/en_US/eql` with body `{"eql":"SELECT id FROM user WHERE username = 'x'' OR ''1''=''1'"}`.
- Source-derived behavior: the grammar decodes the literal to `Text = x' OR '1'='1`. The sink at `EqlBuilder.Sql.cs:581` emits `'x' OR '1'='1'` inside the WHERE expression at line 674. The generated SQL therefore contains a tautology, and the query returns rows that the original equality would not have returned.
- Response: a result set larger than the baseline from step 2, reflecting that the injected condition changed query semantics.
- State change: none required to prove the boundary crossing. The altered result set is the evidence that attacker text became SQL.
4. Escalate to cross-entity disclosure. Because the generated SQL runs under the server database account, a breakout can graft a subquery or UNION that reads tables the caller has no ERP permission on, including the user and role tables.
- Request (illustrative, unexecuted): a similar `POST /api/v3/en_US/eql` whose WHERE text literal closes the string and appends a UNION that selects from a sensitive table. Payloads are described in words here and not spelled out to avoid providing a turnkey exploit.
- Source-derived behavior: the injected fragment is compiled into the executed SQL before any permission check, and the permission check only inspects the declared SELECT entities, so the extra read is not blocked at the EQL layer.
- Response: rows sourced from the targeted table, returned inside the JSON result.
- State change: read-only in this step. Data-modifying variants depend on the database account privileges and the Npgsql command behavior, which were not tested.
Static, source-only. The chain was confirmed at pinned commit `10315fe844198b8620494f716ed75410cd54bfe6`:
- Entry and auth: `WebVella.Erp.Web/Controllers/WebApiController.cs:37` (`[Authorize]`), `:64-96` (`EqlQueryAction` runs `model.Eql`), base `ApiControllerBase.cs:9`.
- Sink: `WebVella.Erp/Eql/EqlBuilder.Sql.cs:580-581` (text literal interpolated as `'{Text}'`), used at `:674`, `:730`, `:743`.
- Decode: `WebVella.Erp/Eql/EqlGrammar.cs:15` (`AllowsDoubledQuote`), `WebVella.Erp/Eql/EqlBuilder.cs:421-422` (`Text = Token.ValueString`).
- Late permission check: `WebVella.Erp/Eql/EqlCommand.cs:190-256` (execute then materialize), `:296-305` (`HasEntityPermission` on SELECT entities only).
Runtime validation was NOT performed. No request was sent to any live deployment, no database was queried, and no PoC was executed. The decode behavior of Irony `StringLiteral` with `AllowsDoubledQuote` is taken from the library contract and the surrounding code path, and the exact generated SQL string and database response were not observed at runtime.
Scenario, authenticated low-privilege user: any enabled account can reach `POST /api/v3/en_US/eql` and inject SQL through a WHERE text literal. This supports reading data from entities and system tables the account has no ERP permission on, which crosses the user/tenant data boundary that the role system enforces. Disclosure of the user and role tables is a credential-exposure risk.
Scenario, data tampering: whether UPDATE, DELETE, or stacked statements succeed depends on the PostgreSQL account privileges and on whether the Npgsql command allows multiple statements in this call path. That was not tested, so tampering and stacked execution are plausible but not established here.
Not established: unauthenticated access (authentication is required), remote code execution, and the exact set of statements the connection permits. The number-literal path at `EqlBuilder.Sql.cs:578` is driven by a parsed numeric token and is not an injection vector. The `@argument` path is parameterized and safe. The severity claimed here is authenticated SQL injection, not unauthenticated and not RCE.
**Where exactly:** `WebVella.Erp/Eql/EqlBuilder.Sql.cs:580-581` (and the reuse sites at `:674`, `:730`, `:743`).
- case EqlNodeType.TextValue:
- operandString = $"'{((EqlTextValueNode)operandNode).Text}'";
+ case EqlNodeType.TextValue:
+ // Bind text literals as parameters instead of inlining them,
+ // matching the safe @argument path.
+ var literalParamName = "@__eql_literal_" + ExpectedParameters.Count;
+ Parameters.Add(new EqlParameter(literalParamName, ((EqlTextValueNode)operandNode).Text));
+ ExpectedParameters.Add(literalParamName);
+ operandString = literalParamName;
Short term: bind every text literal as an `NpgsqlParameter`, exactly as the existing `@argument` branch already does, so no caller-controlled string is concatenated into SQL. If inline literals must stay for any reason, escape the embedded single quote by doubling it on output, but parameter binding is the correct fix.
Medium term: audit all string interpolation in `EqlBuilder.Sql.cs` and treat the compiler output as a parameterized command everywhere, with identifiers validated against metadata (already done for field and entity names) and all values bound.
Defense in depth: restrict `POST /api/v3/en_US/eql` to roles that are meant to run arbitrary queries rather than any authenticated user, and move the entity permission check to before query execution so a denied entity never reaches PostgreSQL.
Recommended fix: bind text literals as parameters in the EQL to SQL compiler, gate the raw EQL endpoint to a privileged role, and move the entity permission check ahead of execution.
- No public advisory at time of writing. The GitHub security advisories list for the repository is empty and there is no SECURITY.md.
- Pinned source, entry and auth:
- Pinned source, sink:
- Pinned source, decode:
- Pinned source, AST text node:
- Pinned source, late permission check:
- Released artifacts: and (1.8.8)
Reported by hieuPenguinnn, [github.com/hieuPenguinnn](
The full story
This article is one source in a clustered incident — the cluster page carries the summary, timeline and every other outlet covering it.
