Skip to content

Commit c3e263c

Browse files
authored
fix(list_table): preventing sql injection attack on list_tables (#165)
* preventing sql injection attack on list_tables * refactor: address review comments - use unknown[] and add transaction wrapper - Replace z.any() with z.unknown() for better type safety (aligns with postgres-meta) - Wrap query execution in transaction to prevent race conditions for future parallelization - All 123 tests passing * test: use division by zero payload from HackerOne report - Changed SQL injection test to use actual payload from Linear issue AI-139 - Payload: "public') OR (SELECT 1)=1/0--" which causes division by zero error without parameterization - With parameterized queries, returns empty array [] instead of error - Addresses review feedback about using realistic example from original report * chore: remove debug console.log statements
1 parent dd2d3fa commit c3e263c

File tree

8 files changed

+100
-35
lines changed

8 files changed

+100
-35
lines changed

.vscode/settings.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,4 +5,4 @@
55
"[json]": {
66
"editor.defaultFormatter": "biomejs.biome"
77
}
8-
}
8+
}

packages/mcp-server-supabase/src/pg-meta/index.ts

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -25,14 +25,19 @@ export function listTablesSql(schemas: string[] = []) {
2525
`;
2626

2727
sql += '\n';
28+
let parameters: any[] = [];
2829

2930
if (schemas.length > 0) {
30-
sql += `where schema in (${schemas.map((s) => `'${s}'`).join(',')})`;
31+
const placeholders = schemas.map((_, i) => `$${i + 1}`).join(', ');
32+
sql += `where schema in (${placeholders})`;
33+
parameters = schemas;
3134
} else {
32-
sql += `where schema not in (${SYSTEM_SCHEMAS.map((s) => `'${s}'`).join(',')})`;
35+
const placeholders = SYSTEM_SCHEMAS.map((_, i) => `$${i + 1}`).join(', ');
36+
sql += `where schema not in (${placeholders})`;
37+
parameters = SYSTEM_SCHEMAS;
3338
}
3439

35-
return sql;
40+
return { query: sql, parameters };
3641
}
3742

3843
/**

packages/mcp-server-supabase/src/platform/api-platform.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -167,7 +167,8 @@ export function createSupabaseApiPlatform(
167167

168168
const database: DatabaseOperations = {
169169
async executeSql<T>(projectId: string, options: ExecuteSqlOptions) {
170-
const { query, read_only } = executeSqlOptionsSchema.parse(options);
170+
const { query, parameters, read_only } =
171+
executeSqlOptionsSchema.parse(options);
171172

172173
const response = await managementApiClient.POST(
173174
'/v1/projects/{ref}/database/query',
@@ -179,6 +180,7 @@ export function createSupabaseApiPlatform(
179180
},
180181
body: {
181182
query,
183+
parameters,
182184
read_only,
183185
},
184186
}

packages/mcp-server-supabase/src/platform/types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,7 @@ export const deployEdgeFunctionOptionsSchema = z.object({
110110

111111
export const executeSqlOptionsSchema = z.object({
112112
query: z.string(),
113+
parameters: z.array(z.unknown()).optional(),
113114
read_only: z.boolean().optional(),
114115
});
115116

packages/mcp-server-supabase/src/server.test.ts

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1060,6 +1060,42 @@ describe('tools', () => {
10601060
);
10611061
});
10621062

1063+
test('list_tables is not vulnerable to SQL injection via schemas parameter', async () => {
1064+
const { callTool } = await setup();
1065+
1066+
const org = await createOrganization({
1067+
name: 'SQLi Org',
1068+
plan: 'free',
1069+
allowed_release_channels: ['ga'],
1070+
});
1071+
1072+
const project = await createProject({
1073+
name: 'SQLi Project',
1074+
region: 'us-east-1',
1075+
organization_id: org.id,
1076+
});
1077+
project.status = 'ACTIVE_HEALTHY';
1078+
1079+
// Attempt SQL injection via schemas parameter using payload from HackerOne report
1080+
// This payload attempts to break out of the string and inject a division by zero expression
1081+
// Reference: https://linear.app/supabase/issue/AI-139
1082+
const maliciousSchema = "public') OR (SELECT 1)=1/0--";
1083+
1084+
// With proper parameterization, this should NOT throw "division by zero" error
1085+
// The literal schema name doesn't exist, so it should return empty array
1086+
// WITHOUT parameterization, this would throw: "division by zero" error
1087+
const maliciousResult = await callTool({
1088+
name: 'list_tables',
1089+
arguments: {
1090+
project_id: project.id,
1091+
schemas: [maliciousSchema],
1092+
},
1093+
});
1094+
1095+
// Should return empty array without errors, proving the SQL injection was prevented
1096+
expect(maliciousResult).toEqual([]);
1097+
});
1098+
10631099
test('list extensions', async () => {
10641100
const { callTool } = await setup();
10651101

packages/mcp-server-supabase/src/tools/database-operation-tools.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,9 +40,10 @@ export function getDatabaseTools({
4040
}),
4141
inject: { project_id },
4242
execute: async ({ project_id, schemas }) => {
43-
const query = listTablesSql(schemas);
43+
const { query, parameters } = listTablesSql(schemas);
4444
const data = await database.executeSql(project_id, {
4545
query,
46+
parameters,
4647
read_only: true,
4748
});
4849
const tables = data

packages/mcp-server-supabase/src/util.ts

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -11,18 +11,19 @@ export type ValueOf<T> = T[keyof T];
1111

1212
// UnionToIntersection<A | B> = A & B
1313
export type UnionToIntersection<U> = (
14-
U extends unknown ? (arg: U) => 0 : never
14+
U extends unknown
15+
? (arg: U) => 0
16+
: never
1517
) extends (arg: infer I) => 0
1618
? I
1719
: never;
1820

1921
// LastInUnion<A | B> = B
20-
export type LastInUnion<U> =
21-
UnionToIntersection<U extends unknown ? (x: U) => 0 : never> extends (
22-
x: infer L
23-
) => 0
24-
? L
25-
: never;
22+
export type LastInUnion<U> = UnionToIntersection<
23+
U extends unknown ? (x: U) => 0 : never
24+
> extends (x: infer L) => 0
25+
? L
26+
: never;
2627

2728
// UnionToTuple<A, B> = [A, B]
2829
export type UnionToTuple<T, Last = LastInUnion<T>> = [T] extends [never]

packages/mcp-server-supabase/test/mocks.ts

Lines changed: 41 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -258,7 +258,10 @@ export const mockManagementApi = [
258258
/**
259259
* Execute a SQL query on a project's database
260260
*/
261-
http.post<{ projectId: string }, { query: string; read_only?: boolean }>(
261+
http.post<
262+
{ projectId: string },
263+
{ query: string; parameters?: unknown[]; read_only?: boolean }
264+
>(
262265
`${API_URL}/v1/projects/:projectId/database/query`,
263266
async ({ params, request }) => {
264267
const project = mockProjects.get(params.projectId);
@@ -269,30 +272,46 @@ export const mockManagementApi = [
269272
);
270273
}
271274
const { db } = project;
272-
const { query, read_only } = await request.json();
275+
const { query, parameters, read_only } = await request.json();
273276

274-
// Not secure, but good enough for testing
275-
const wrappedQuery = `
276-
SET ROLE ${read_only ? 'supabase_read_only_role' : 'postgres'};
277-
${query};
278-
RESET ROLE;
279-
`;
280-
281-
const statementResults = await db.exec(wrappedQuery);
282-
283-
// Remove last result, which is for the "RESET ROLE" statement
284-
statementResults.pop();
285-
286-
const lastStatementResults = statementResults.at(-1);
277+
try {
278+
// Use transaction to prevent race conditions if tests are parallelized
279+
const result = await db.transaction(async (tx) => {
280+
// Set role before executing query
281+
await tx.exec(
282+
`SET ROLE ${read_only ? 'supabase_read_only_role' : 'postgres'};`
283+
);
284+
285+
// Use query() method with parameters if provided, otherwise use exec()
286+
const queryResult =
287+
parameters && parameters.length > 0
288+
? await tx.query(query, parameters)
289+
: await tx.exec(query);
290+
291+
// Reset role
292+
await tx.exec('RESET ROLE;');
293+
294+
return queryResult;
295+
});
287296

288-
if (!lastStatementResults) {
289-
return HttpResponse.json(
290-
{ message: 'Failed to execute query' },
291-
{ status: 500 }
292-
);
297+
// Handle different response formats
298+
if (Array.isArray(result)) {
299+
// exec() returns an array of results
300+
const lastStatementResults = result.at(-1);
301+
if (!lastStatementResults) {
302+
return HttpResponse.json(
303+
{ message: 'Failed to execute query' },
304+
{ status: 500 }
305+
);
306+
}
307+
return HttpResponse.json(lastStatementResults.rows);
308+
} else {
309+
// query() returns a single result object
310+
return HttpResponse.json(result.rows);
311+
}
312+
} catch (error) {
313+
throw error;
293314
}
294-
295-
return HttpResponse.json(lastStatementResults.rows);
296315
}
297316
),
298317

0 commit comments

Comments
 (0)