Skip to content
Closed
Show file tree
Hide file tree
Changes from 3 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
6 changes: 6 additions & 0 deletions apps/api/src/agent/utils/query-executor.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { chQuery } from '@databuddy/db';
import { validateSQL } from './sql-validator';

export interface QueryResult {
data: unknown[];
Expand All @@ -7,6 +8,11 @@ export interface QueryResult {
}

export async function executeQuery(sql: string): Promise<QueryResult> {
// Validate SQL before execution
if (!validateSQL(sql)) {
throw new Error('SQL query validation failed - potential security risk detected');
}

const queryStart = Date.now();
const result = await chQuery(sql);
const queryTime = Date.now() - queryStart;
Expand Down
56 changes: 55 additions & 1 deletion apps/api/src/agent/utils/sql-validator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,19 +14,73 @@ const FORBIDDEN_SQL_KEYWORDS = [
'BULK',
'RESTORE',
'BACKUP',
'GRANT',
'REVOKE',
'SHOW GRANTS',
'SHOW USERS',
'SYSTEM',
'ATTACH',
'DETACH',
'OPTIMIZE',
'CHECK',
'REPAIR',
'ANALYZE',
] as const;

const DANGEROUS_PATTERNS = [
/--/, // SQL comments
/\/\*/, // Multi-line comments start
/\*\//, // Multi-line comments end
/;\s*(DROP|DELETE|UPDATE|INSERT|CREATE|ALTER|TRUNCATE)/i, // Suspicious stacked queries
/\bINTO\s+OUTFILE\b/i, // File operations
/\bLOAD_FILE\b/i, // File reading
/\bINTO\s+DUMPFILE\b/i, // File writing
/\bSHOW\s+PROCESSLIST\b/i, // Process information
/\bINFORMATION_SCHEMA\b/i, // Schema inspection
/\bMYSQL\b/i, // MySQL database access
/\bPG_/i, // PostgreSQL functions
/\bUNION\s+(ALL\s+)?SELECT\b/i, // Union-based injection attempts
/\bOR\s+[\d'"]+=[\d'"]+/i, // Classic SQL injection patterns like OR 1=1
/\bAND\s+[\d'"]+=[\d'"]+/i, // Classic SQL injection patterns like AND 1=1
] as const;

export function validateSQL(sql: string): boolean {
if (!sql || typeof sql !== 'string') {
return false;
}

const upperSQL = sql.toUpperCase();
const trimmed = upperSQL.trim();

// Check length limit to prevent resource exhaustion
if (sql.length > 10000) {
return false;
}

// Check for dangerous keyword patterns
for (const keyword of FORBIDDEN_SQL_KEYWORDS) {
if (upperSQL.includes(keyword)) {
return false;
}
}

Comment on lines 60 to 65

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

Update keyword scanning loop to use regex patterns.

Switch from substring includes to RegExp.test to eliminate false positives and catch EXEC() without trailing spaces.

-// Check for dangerous keyword patterns
-for (const keyword of FORBIDDEN_SQL_KEYWORDS) {
-  if (upperSQL.includes(keyword)) {
-    return false;
-  }
-}
+// Check for dangerous keyword patterns
+for (const re of FORBIDDEN_SQL_KEYWORDS) {
+  if (re.test(sql)) {
+    return false;
+  }
+}
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
// Check for dangerous keyword patterns
for (const keyword of FORBIDDEN_SQL_KEYWORDS) {
if (upperSQL.includes(keyword)) {
return false;
}
}
// Check for dangerous keyword patterns
for (const re of FORBIDDEN_SQL_KEYWORDS) {
if (re.test(sql)) {
return false;
}
}

// Check for dangerous patterns
for (const pattern of DANGEROUS_PATTERNS) {
if (pattern.test(sql)) {
return false;
}
}

// Must start with SELECT or WITH (for CTEs)
return trimmed.startsWith('SELECT') || trimmed.startsWith('WITH');
if (!trimmed.startsWith('SELECT') && !trimmed.startsWith('WITH')) {
return false;
}

// Additional validation: Ensure no stacked queries
const statements = sql.split(';').filter(s => s.trim());
if (statements.length > 1) {
return false;
}

return true;
}
3 changes: 3 additions & 0 deletions apps/api/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,15 @@ import { fetchRequestHandler } from '@trpc/server/adapters/fetch';
import { autumnHandler } from 'autumn-js/elysia';
import { Elysia } from 'elysia';
import { logger } from './lib/logger';
import { cspHeaders, securityMiddleware } from './middleware/security';
import { assistant } from './routes/assistant';
import { exportRoute } from './routes/export';
import { health } from './routes/health';
import { query } from './routes/query';

const app = new Elysia()
.use(securityMiddleware())
.use(cspHeaders())
.use(
cors({
credentials: true,
Expand Down
182 changes: 182 additions & 0 deletions apps/api/src/middleware/security.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,182 @@
import { describe, expect, test } from 'bun:test';
import { validateSQL } from '../agent/utils/sql-validator';

describe('Security Validations', () => {
describe('SQL Injection Prevention', () => {
test('should reject malicious SQL keywords', () => {
const maliciousQueries = [
"SELECT * FROM users; DROP TABLE users; --",
"SELECT * FROM users WHERE id = 1 OR 1=1",
"SELECT * FROM users UNION SELECT password FROM admin",
"SELECT * FROM users; INSERT INTO admin VALUES ('hacker', 'pass')",
"SELECT * FROM users; DELETE FROM logs",
"SELECT * FROM users; UPDATE users SET password = 'hacked'",
"SELECT * FROM users; CREATE TABLE malicious (id INT)",
"SELECT * FROM users; ALTER TABLE users ADD COLUMN hacked VARCHAR(255)",
"SELECT * FROM users; EXEC xp_cmdshell 'net user'",
"SELECT * FROM users; EXECUTE sp_executesql @sql",
"SELECT * FROM users; TRUNCATE TABLE logs",
"SELECT * FROM users; BACKUP DATABASE test TO DISK",
"SELECT * FROM users; RESTORE DATABASE test FROM DISK",
"SELECT * FROM users; GRANT ALL PRIVILEGES ON *.*",
"SELECT * FROM users; REVOKE ALL PRIVILEGES ON *.*",
"SELECT * FROM users; SHOW GRANTS FOR root",
"SELECT * FROM users; SHOW USERS",
"SELECT * FROM users; SYSTEM DROP DATABASE test",
"SELECT * FROM users; ATTACH DATABASE 'test.db' AS test",
"SELECT * FROM users; DETACH DATABASE test",
"SELECT * FROM users; OPTIMIZE TABLE users",
"SELECT * FROM users; CHECK TABLE users",
"SELECT * FROM users; REPAIR TABLE users",
"SELECT * FROM users; ANALYZE TABLE users",
];

for (const query of maliciousQueries) {
expect(validateSQL(query)).toBe(false);
}
});

test('should reject dangerous SQL patterns', () => {
const dangerousPatterns = [
"SELECT * FROM users -- comment",
"SELECT * FROM users /* comment */",
"SELECT * FROM users; SELECT * FROM admin",
"SELECT * FROM users INTO OUTFILE '/tmp/users.txt'",
"SELECT LOAD_FILE('/etc/passwd')",
"SELECT * FROM users INTO DUMPFILE '/tmp/users.txt'",
"SELECT * FROM users; SHOW PROCESSLIST",
"SELECT * FROM INFORMATION_SCHEMA.TABLES",
"SELECT * FROM mysql.user",
"SELECT pg_user FROM pg_stat_activity",
"SELECT * FROM users UNION ALL SELECT * FROM admin",
];

for (const query of dangerousPatterns) {
expect(validateSQL(query)).toBe(false);
}
});

test('should allow safe SELECT queries', () => {
const safeQueries = [
"SELECT id, name FROM users WHERE active = 1",
"SELECT COUNT(*) FROM orders WHERE date > '2023-01-01'",
"SELECT u.name, o.total FROM users u JOIN orders o ON u.id = o.user_id",
"WITH top_users AS (SELECT * FROM users ORDER BY score DESC LIMIT 10) SELECT * FROM top_users",
"SELECT name FROM users WHERE created_at BETWEEN '2023-01-01' AND '2023-12-31'",
];

for (const query of safeQueries) {
expect(validateSQL(query)).toBe(true);
}
});

test('should reject non-SELECT queries', () => {
const nonSelectQueries = [
"INSERT INTO users VALUES (1, 'test')",
"UPDATE users SET name = 'test'",
"DELETE FROM users",
"DROP TABLE users",
"CREATE TABLE test (id INT)",
"ALTER TABLE users ADD COLUMN test VARCHAR(255)",
];

for (const query of nonSelectQueries) {
expect(validateSQL(query)).toBe(false);
}
});

test('should reject overly long queries', () => {
const longQuery = "SELECT * FROM users WHERE " + "a = 1 AND ".repeat(1000) + "b = 2";
expect(validateSQL(longQuery)).toBe(false);
});

test('should handle edge cases', () => {
expect(validateSQL('')).toBe(false);
expect(validateSQL(' ')).toBe(false);
// @ts-ignore - testing invalid input
expect(validateSQL(null)).toBe(false);
// @ts-ignore - testing invalid input
expect(validateSQL(undefined)).toBe(false);
// @ts-ignore - testing invalid input
expect(validateSQL(123)).toBe(false);
});
});

describe('Input Sanitization', () => {
test('should detect XSS patterns', () => {
const xssPatterns = [
"<script>alert('xss')</script>",
"javascript:alert('xss')",
"vbscript:alert('xss')",
"onload=alert('xss')",
"onerror=alert('xss')",
"onclick=alert('xss')",
"onmouseover=alert('xss')",
"eval(alert('xss'))",
"expression(alert('xss'))",
"data:text/html,<script>alert('xss')</script>",
"data:application/javascript,alert('xss')",
"&#x3C;script&#x3E;alert('xss')&#x3C;/script&#x3E;",
"&#60;script&#62;alert('xss')&#60;/script&#62;",
];

// These would be caught by our sanitization functions
// Testing that our patterns detect them correctly
const hasXSSPattern = (input: string): boolean => {
const dangerousPatterns = [
/<script/i,
/javascript:/i,
/vbscript:/i,
/onload=/i,
/onerror=/i,
/onclick=/i,
/onmouse/i,
/eval\(/i,
/expression\(/i,
/data:text\/html/i,
/data:application/i,
/&#x/i,
/&#\d/i,
];
return dangerousPatterns.some(pattern => pattern.test(input));
};

for (const pattern of xssPatterns) {
expect(hasXSSPattern(pattern)).toBe(true);
}
});

test('should allow safe input', () => {
const safeInputs = [
"Hello World",
"user@example.com",
"123-456-7890",
"Safe content with normal characters",
"Product Name - Version 1.0",
];

const hasXSSPattern = (input: string): boolean => {
const dangerousPatterns = [
/<script/i,
/javascript:/i,
/vbscript:/i,
/onload=/i,
/onerror=/i,
/onclick=/i,
/onmouse/i,
/eval\(/i,
/expression\(/i,
/data:text\/html/i,
/data:application/i,
/&#x/i,
/&#\d/i,
];
return dangerousPatterns.some(pattern => pattern.test(input));
};

for (const input of safeInputs) {
expect(hasXSSPattern(input)).toBe(false);
}
});
});
});
Loading