Skip to content

Commit 0f0da14

Browse files
authoredJan 27, 2025··
fix: validate query before execute (#3048)
1 parent 6f98de8 commit 0f0da14

File tree

3 files changed

+169
-0
lines changed

3 files changed

+169
-0
lines changed
 

‎src/runtime/api/query.post.ts

+3
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,15 @@ import { eventHandler, getRouterParam, readValidatedBody } from 'h3'
22
import * as z from 'zod'
33
import type { RuntimeConfig } from '@nuxt/content'
44
import loadDatabaseAdapter, { checkAndImportDatabaseIntegrity } from '../internal/database.server'
5+
import { assertSafeQuery } from '../internal/security'
56
import { useRuntimeConfig } from '#imports'
67

78
export default eventHandler(async (event) => {
89
const { sql } = await readValidatedBody(event, z.object({ sql: z.string() }).parse)
910
const collection = getRouterParam(event, 'collection')!
1011

12+
assertSafeQuery(sql, collection)
13+
1114
const conf = useRuntimeConfig().content as RuntimeConfig['content']
1215
await checkAndImportDatabaseIntegrity(event, collection, conf)
1316

‎src/runtime/internal/security.ts

+68
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
const SQL_COMMANDS = /SELECT|INSERT|UPDATE|DELETE|DROP|ALTER/i
2+
3+
/**
4+
* Assert that the query is safe
5+
* A query will consider safe if it matched the output pattern of query builder
6+
* Any mismatch will be considered as unsafe
7+
*
8+
* @param sql - The SQL query to check
9+
* @param collection - The collection to check
10+
* @returns True if the query is safe, false otherwise
11+
*/
12+
export function assertSafeQuery(sql: string, collection: string) {
13+
const match = sql.match(/^SELECT (.*) FROM (\w+)( WHERE .*)? ORDER BY (["\w,\s]+) (ASC|DESC)( LIMIT \d+)?( OFFSET \d+)?$/)
14+
if (!match) {
15+
throw new Error('Invalid query')
16+
}
17+
18+
const [_, select, from, where, orderBy, order, limit, offset] = match
19+
20+
// COLUMNS
21+
const columns = select.trim().split(', ')
22+
if (columns.length === 1) {
23+
if (
24+
columns[0] !== '*'
25+
&& !columns[0].startsWith('COUNT(')
26+
&& !columns[0].match(/^COUNT\((DISTINCT )?[a-z_]\w+\) as count$/)
27+
) {
28+
throw new Error('Invalid query')
29+
}
30+
}
31+
else if (!columns.every(column => column.match(/^"[a-z_]\w+"$/i))) {
32+
throw new Error('Invalid query')
33+
}
34+
35+
// FROM
36+
if (from !== `_content_${collection}`) {
37+
throw new Error('Invalid query')
38+
}
39+
40+
// WHERE
41+
if (where) {
42+
if (!where.startsWith(' WHERE (') || !where.endsWith(')')) {
43+
throw new Error('Invalid query')
44+
}
45+
const noString = where?.replace(/(['"`])(?:\\.|[^\\])*?\1/g, '')
46+
if (noString.match(SQL_COMMANDS)) {
47+
throw new Error('Invalid query')
48+
}
49+
}
50+
51+
// ORDER BY
52+
const _order = (orderBy + ' ' + order).split(', ')
53+
if (!_order.every(column => column.match(/^("[a-z_]+"|[a-z_]+) (ASC|DESC)$/))) {
54+
throw new Error('Invalid query')
55+
}
56+
57+
// LIMIT
58+
if (limit !== undefined && !limit.match(/^ LIMIT \d+$/)) {
59+
throw new Error('Invalid query')
60+
}
61+
62+
// OFFSET
63+
if (offset !== undefined && !offset.match(/^ OFFSET \d+$/)) {
64+
throw new Error('Invalid query')
65+
}
66+
67+
return true
68+
}

‎test/unit/assertSafeQuery.test.ts

+98
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
import { describe, it, expect, vi, beforeEach } from 'vitest'
2+
import { assertSafeQuery } from '../../src/runtime/internal/security'
3+
import { collectionQueryBuilder } from '../../src/runtime/internal/query'
4+
5+
// Mock tables from manifest
6+
vi.mock('#content/manifest', () => ({
7+
tables: {
8+
test: '_content_test',
9+
},
10+
}))
11+
const mockFetch = vi.fn().mockResolvedValue(Promise.resolve([{}]))
12+
const mockCollection = 'test' as never
13+
14+
describe('decompressSQLDump', () => {
15+
beforeEach(() => {
16+
mockFetch.mockClear()
17+
})
18+
19+
const queries = {
20+
'SELECT * FROM sqlite_master': false,
21+
'INSERT INTO _test VALUES (\'abc\')': false,
22+
'CREATE TABLE _test (id TEXT PRIMARY KEY)': false,
23+
'select * from _content_test ORDER BY id DESC': false,
24+
' SELECT * FROM _content_test ORDER BY id DESC': false,
25+
'SELECT * FROM _content_test ORDER BY id DESC ': false,
26+
'SELECT * FROM _content_test ORDER BY id DESC': true,
27+
'SELECT * FROM _content_test ORDER BY id ASC,stem DESC': false,
28+
'SELECT * FROM _content_test ORDER BY id ASC, stem DESC': true,
29+
'SELECT * FROM _content_test ORDER BY id DESC -- comment is not allowed': false,
30+
'SELECT * FROM _content_test ORDER BY id DESC; SELECT * FROM _content_test ORDER BY id DESC': false,
31+
'SELECT * FROM _content_test ORDER BY id DESC LIMIT 10': true,
32+
'SELECT * FROM _content_test ORDER BY id DESC LIMIT 10 OFFSET 10': true,
33+
// Where clause should follow query builder syntax
34+
'SELECT * FROM _content_test WHERE id = 1 ORDER BY id DESC LIMIT 10 OFFSET 10': false,
35+
'SELECT * FROM _content_test WHERE (id = 1) ORDER BY id DESC LIMIT 10 OFFSET 10': true,
36+
'SELECT * FROM _content_test WHERE (id = \'");\'); select * from ((SELECT * FROM sqlite_master where 1 <> "") as t) ORDER BY type DESC': false,
37+
}
38+
39+
Object.entries(queries).forEach(([query, isValid]) => {
40+
it(`${query}`, () => {
41+
if (isValid) {
42+
expect(() => assertSafeQuery(query, 'test')).not.toThrow()
43+
}
44+
else {
45+
expect(() => assertSafeQuery(query, 'test')).toThrow()
46+
}
47+
})
48+
})
49+
50+
it('throws error if query is not valid', async () => {
51+
await collectionQueryBuilder(mockCollection, mockFetch).all()
52+
expect(() => assertSafeQuery(mockFetch.mock.lastCall![1], mockCollection)).not.toThrow()
53+
54+
await collectionQueryBuilder(mockCollection, mockFetch).count('stem')
55+
expect(() => assertSafeQuery(mockFetch.mock.lastCall![1], mockCollection)).not.toThrow()
56+
57+
await collectionQueryBuilder(mockCollection, mockFetch).count('stem', true)
58+
expect(() => assertSafeQuery(mockFetch.mock.lastCall![1], mockCollection)).not.toThrow()
59+
60+
await collectionQueryBuilder(mockCollection, mockFetch).first()
61+
expect(() => assertSafeQuery(mockFetch.mock.lastCall![1], mockCollection)).not.toThrow()
62+
63+
await collectionQueryBuilder(mockCollection, mockFetch).order('stem', 'DESC').first()
64+
expect(() => assertSafeQuery(mockFetch.mock.lastCall![1], mockCollection)).not.toThrow()
65+
66+
await collectionQueryBuilder(mockCollection, mockFetch).order('stem', 'DESC').order('id', 'ASC').first()
67+
expect(() => assertSafeQuery(mockFetch.mock.lastCall![1], mockCollection)).not.toThrow()
68+
69+
await collectionQueryBuilder(mockCollection, mockFetch)
70+
.select('stem', 'id', 'title')
71+
.order('stem', 'DESC').order('id', 'ASC').first()
72+
expect(() => assertSafeQuery(mockFetch.mock.lastCall![1], mockCollection)).not.toThrow()
73+
74+
await collectionQueryBuilder(mockCollection, mockFetch)
75+
.select('stem', 'id', 'title')
76+
.limit(10)
77+
.andWhere(group => group.where('id', '=', 1).where('stem', '=', 'abc'))
78+
.order('stem', 'DESC').order('id', 'ASC').first()
79+
expect(() => assertSafeQuery(mockFetch.mock.lastCall![1], mockCollection)).not.toThrow()
80+
81+
await collectionQueryBuilder(mockCollection, mockFetch)
82+
.select('stem', 'id', 'title')
83+
.limit(10)
84+
.andWhere(group => group.where('id', '=', 1).where('stem', '=', 'abc'))
85+
.orWhere(group => group.where('id', '=', 2).where('stem', '=', 'def'))
86+
.order('stem', 'DESC').order('id', 'ASC').first()
87+
expect(() => assertSafeQuery(mockFetch.mock.lastCall![1], mockCollection)).not.toThrow()
88+
89+
await collectionQueryBuilder(mockCollection, mockFetch)
90+
.select('stem', 'id', 'title')
91+
.limit(10)
92+
.andWhere(group => group.where('id', '=', 1).where('stem', '=', 'abc'))
93+
.orWhere(group => group.where('id', '=', 2).where('stem', '=', 'def'))
94+
.andWhere(group => group.where('id', '=', 3).orWhere(g => g.where('stem', '=', 'ghi')))
95+
.order('stem', 'DESC').order('id', 'ASC').first()
96+
expect(() => assertSafeQuery(mockFetch.mock.lastCall![1], mockCollection)).not.toThrow()
97+
})
98+
})

0 commit comments

Comments
 (0)
Please sign in to comment.