Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
106 changes: 106 additions & 0 deletions backend/src/drep/__tests__/drep.service.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
import { DataSource } from 'typeorm';
import { DrepService } from '../drep.service';
import { HttpService } from '@nestjs/axios';
import { ConfigService } from '@nestjs/config';
import { AttachmentService } from 'src/attachment/attachment.service';
import { ReactionsService } from 'src/reactions/reactions.service';
import { CommentsService } from 'src/comments/comments.service';
import { AuthService } from 'src/auth/auth.service';

describe('DrepService - SQL Injection Prevention', () => {
let service: DrepService;
let mockCexplorerQuery: jest.Mock;
let mockVoltaireRepo: any;

beforeEach(() => {
mockCexplorerQuery = jest.fn().mockResolvedValue([]);
mockVoltaireRepo = {
createQueryBuilder: jest.fn().mockReturnValue({
leftJoinAndSelect: jest.fn().mockReturnThis(),
where: jest.fn().mockReturnThis(),
andWhere: jest.fn().mockReturnThis(),
getRawMany: jest.fn().mockResolvedValue([]),
getOne: jest.fn().mockResolvedValue(null),
}),
insert: jest.fn(),
update: jest.fn(),
getRepository: jest.fn().mockReturnValue({
createQueryBuilder: jest.fn().mockReturnValue({
leftJoinAndSelect: jest.fn().mockReturnThis(),
where: jest.fn().mockReturnThis(),
andWhere: jest.fn().mockReturnThis(),
getRawMany: jest.fn().mockResolvedValue([]),
getOne: jest.fn().mockResolvedValue(null),
}),
}),
};

const mockVoltaireDataSource = {
getRepository: jest.fn().mockReturnValue(mockVoltaireRepo),
};

service = new DrepService(
mockVoltaireDataSource as any,
{ manager: { query: mockCexplorerQuery } } as any,
{} as any,
{} as any,
{} as any,
{} as any,
{} as any,
{} as any,
);
});

describe('getAllDRepsCexplorer - Search parameterization', () => {
it('should use parameterized queries for search input instead of string interpolation', async () => {
const searchQuery = "test'; DROP TABLE drep_hash; --";

// Mock the method to capture the SQL
mockCexplorerQuery.mockResolvedValue([]);

// Call with malicious search - should not throw, but should parameterize
// The key test is that the SQL string should NOT contain the raw search term
await service.getAllDRepsCexplorer(
searchQuery,
1,
24,
undefined,
undefined,
undefined,
undefined,
undefined,
undefined,
);

// Check that queries were called with params array
expect(mockCexplorerQuery).toHaveBeenCalled();

// Get the first call's arguments
const [sql, params] = mockCexplorerQuery.mock.calls[0];

// The SQL should use $1, $2 placeholders instead of the raw search string
expect(sql).not.toContain(searchQuery);
// The params array should contain the search values
expect(params).toBeDefined();
expect(params).toContain(`%${searchQuery}%`);
});

it('should properly escape single quotes in nameFilteredDRepViews', async () => {
const maliciousView = "drep_test'; DROP TABLE drep_hash; --";

mockCexplorerQuery.mockResolvedValue([]);

await service.getAllDRepsCexplorer(
undefined,
1,
24,
[maliciousView],
);

const [sql] = mockCexplorerQuery.mock.calls[0];
// Single quotes in view values should be escaped
// The malicious content should be escaped with doubled quotes
expect(sql).toContain("drep_test''; DROP TABLE drep_hash; --");
});
});
});
2 changes: 2 additions & 0 deletions backend/src/drep/drep.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
Post,
Query,
Res,
BadRequestException,
} from '@nestjs/common';

import { createDrepDto, ValidateMetadataDTO } from 'src/dto';
Expand All @@ -25,6 +26,7 @@ export class DrepController {
@Get('')
getAll(
@Query('s', new DefaultValuePipe('')) s: string,
// Note: search parameter is validated and parameterized in the service layer,
@Query('page', new DefaultValuePipe(1), ParseIntPipe)
page: number,
@Query('perPage', new DefaultValuePipe(24), ParseIntPipe)
Expand Down
20 changes: 14 additions & 6 deletions backend/src/drep/drep.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -156,15 +156,20 @@ export class DrepService {
) {
const offset = (currentPage - 1) * itemsPerPage;

const sanitizedSearch = query ? query.replace(/'/g, "''") : '';
// Use parameterized queries to prevent SQL injection
const searchParams: any[] = [];
let sanitizedSearchCondition = '';
if (sanitizedSearch && sanitizedSearch.length > 0) {
sanitizedSearchCondition = `AND (dh.view ILIKE '%${sanitizedSearch}%' OR off_chain_vote_drep_data.given_name ILIKE '%${sanitizedSearch}%')`;
if (query && query.length > 0) {
const searchPattern = `%${query}%`;
const nextIdx = searchParams.length;
searchParams.push(searchPattern, searchPattern);
sanitizedSearchCondition = `AND (dh.view ILIKE $${nextIdx + 1} OR off_chain_vote_drep_data.given_name ILIKE $${nextIdx + 2})`;
}

let nameFilteredDRepCondition = '';
if (nameFilteredDRepViews && nameFilteredDRepViews.length > 0) {
nameFilteredDRepCondition = `OR dh.view IN (${nameFilteredDRepViews.map((v) => `'${v}'`).join(', ')})`;
const nameParams = nameFilteredDRepViews.map((v) => `'${v.replace(/'/g, "''")}'`);
nameFilteredDRepCondition = `OR dh.view IN (${nameParams.join(', ')})`;
}

let chainStatusCondition = '';
Expand All @@ -178,10 +183,11 @@ export class DrepService {

let campaignStatusCondition = '';
if (dRepViews && dRepViews.length > 0) {
const escapedViews = dRepViews.map((v) => `'${v.replace(/'/g, "''")}'`);
if (campaignStatus === 'claimed') {
campaignStatusCondition = `AND dh.view IN (${dRepViews.map((v) => `'${v}'`).join(', ')})`;
campaignStatusCondition = `AND dh.view IN (${escapedViews.join(', ')})`;
} else if (campaignStatus === 'unclaimed') {
campaignStatusCondition = `AND dh.view NOT IN (${dRepViews.map((v) => `'${v}'`).join(', ')})`;
campaignStatusCondition = `AND dh.view NOT IN (${escapedViews.join(', ')})`;
}
}

Expand Down Expand Up @@ -221,6 +227,7 @@ export class DrepService {
offset,
typeCondition,
),
searchParams,
);
const totalResults = await this.cexplorerService.manager.query(
getTotalResultsQuery(
Expand All @@ -229,6 +236,7 @@ export class DrepService {
chainStatusCondition,
typeCondition,
),
searchParams,
);

return {
Expand Down
1 change: 1 addition & 0 deletions backend/src/queries/getDReps.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ export const getAllDRepsQuery = (
itemsPerPage: number,
offset: number,
typeCondition: string,
queryParams: any[] = [],
) => `
WITH DRepDistr AS (SELECT *,
ROW_NUMBER() OVER (PARTITION BY drep_hash.id ORDER BY drep_distr.epoch_no DESC) AS rn
Expand Down
97 changes: 97 additions & 0 deletions backend/src/voter/__tests__/voter.service.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
import { BadRequestException } from '@nestjs/common';
import { DataSource } from 'typeorm';
import { VoterService } from '../voter.service';

describe('VoterService', () => {
let service: VoterService;
let mockQuery: jest.Mock;
let mockDataSource: Partial<DataSource>;

beforeEach(() => {
mockQuery = jest.fn().mockResolvedValue([]);
mockDataSource = {
manager: {
query: mockQuery,
} as any,
};
service = new VoterService(mockDataSource as DataSource);
});

describe('getAdaHolderCurrentDelegation - SQL Injection Prevention', () => {
it('should reject input containing SQL injection with single quote', async () => {
const maliciousInput = "'; DROP TABLE delegation_vote; --";
await expect(service.getAdaHolderCurrentDelegation(maliciousInput)).rejects.toThrow(
BadRequestException,
);
expect(mockQuery).not.toHaveBeenCalled();
});

it('should reject input containing UNION-based injection', async () => {
const maliciousInput = "00' UNION SELECT username, password, null FROM users --";
await expect(service.getAdaHolderCurrentDelegation(maliciousInput)).rejects.toThrow(
BadRequestException,
);
expect(mockQuery).not.toHaveBeenCalled();
});

it('should reject input containing OR 1=1 injection', async () => {
const maliciousInput = "' OR '1'='1";
await expect(service.getAdaHolderCurrentDelegation(maliciousInput)).rejects.toThrow(
BadRequestException,
);
expect(mockQuery).not.toHaveBeenCalled();
});

it('should reject empty string', async () => {
await expect(service.getAdaHolderCurrentDelegation('')).rejects.toThrow(
BadRequestException,
);
expect(mockQuery).not.toHaveBeenCalled();
});

it('should reject input with spaces', async () => {
const maliciousInput = "abcd ef01";
await expect(service.getAdaHolderCurrentDelegation(maliciousInput)).rejects.toThrow(
BadRequestException,
);
expect(mockQuery).not.toHaveBeenCalled();
});

it('should reject input with special characters', async () => {
const maliciousInput = "abcd;--ef01";
await expect(service.getAdaHolderCurrentDelegation(maliciousInput)).rejects.toThrow(
BadRequestException,
);
expect(mockQuery).not.toHaveBeenCalled();
});

it('should accept valid hex stake key and use parameterized query', async () => {
const validStakeKey = 'e0abcdef1234567890abcdef1234567890abcdef1234567890abcdef12';
mockQuery.mockResolvedValue([{ drep_raw: 'abc', drep_view: 'drep1xyz' }]);

const result = await service.getAdaHolderCurrentDelegation(validStakeKey);

expect(mockQuery).toHaveBeenCalledTimes(1);
const [sql, params] = mockQuery.mock.calls[0];

// Verify parameterized query is used ($1 instead of interpolated value)
expect(sql).toContain('DECODE($1');
expect(sql).not.toContain(`DECODE('${validStakeKey}'`);
expect(params).toEqual([validStakeKey]);
expect(result).toEqual({ drep_raw: 'abc', drep_view: 'drep1xyz' });
});

it('should use parameterized query - injection attempt in param array is safe', async () => {
const validHex = 'abcdef0123456789abcdef01';
mockQuery.mockResolvedValue([]);

await service.getAdaHolderCurrentDelegation(validHex);

const [sql, params] = mockQuery.mock.calls[0];
// Ensure the stakeKey value is NOT in the SQL string itself
expect(sql).not.toContain(validHex);
// Ensure it IS in the params array
expect(params).toContain(validHex);
});
});
});
10 changes: 8 additions & 2 deletions backend/src/voter/voter.service.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { Injectable } from '@nestjs/common';
import { Injectable, BadRequestException } from '@nestjs/common';
import { InjectDataSource } from '@nestjs/typeorm';
import { DataSource } from 'typeorm';

Expand All @@ -9,6 +9,11 @@ export class VoterService {
private cexplorerService: DataSource,
) {}
async getAdaHolderCurrentDelegation(stakeKey: string) {
// Validate stakeKey: must be a valid hex-encoded hash (typically 28-56 hex chars for Cardano stake key hashes)
if (!stakeKey || !/^[0-9a-fA-F]+$/.test(stakeKey)) {
throw new BadRequestException('Invalid stakeKey: must be a hex-encoded string');
}

const delegation = await this.cexplorerService.manager.query(
`SELECT
CASE
Expand All @@ -26,14 +31,15 @@ export class VoterService {
JOIN
stake_address ON stake_address.id = delegation_vote.addr_id
WHERE
stake_address.hash_raw = DECODE('${stakeKey}', 'hex')
stake_address.hash_raw = DECODE($1, '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;`,
[stakeKey],
);
return delegation[0];
}
Expand Down