dsh-switchman 1.5.0 → 1.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.de.md +45 -55
- package/README.es.md +21 -31
- package/README.fr.md +23 -33
- package/README.it.md +23 -33
- package/README.ja.md +42 -52
- package/README.ko.md +43 -53
- package/README.md +41 -51
- package/README.pt.md +23 -33
- package/README.ru.md +21 -31
- package/README.zh-TW.md +35 -45
- package/README.zh.md +25 -35
- package/client.js +1464 -43
- package/host/config.js +8 -0
- package/host/context-watch.js +197 -56
- package/host/routes.js +6 -2
- package/host/subagents.js +86 -70
- package/host/teams.js +160 -7
- package/package.json +1 -1
- package/skills/db-query/package.json +1 -1
- package/skills/db-query/scripts/lib/cli.js +23 -23
- package/skills/db-query/scripts/lib/config.js +78 -61
- package/skills/db-query/scripts/lib/mysql-readonly.js +167 -158
- package/skills/db-query/scripts/lib/output.js +56 -55
- package/skills/db-query/scripts/lib/redis-readonly.js +116 -111
- package/skills/db-query/scripts/mysql-query.js +46 -42
- package/skills/db-query/scripts/redis-query.js +32 -29
- package/skills/db-query/scripts/setup.sh +16 -16
|
@@ -1,22 +1,22 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
|
-
* MySQL
|
|
5
|
-
*
|
|
4
|
+
* MySQL read-only validation: finite-state lexer + statement classification + LIMIT injection
|
|
5
|
+
* Security core: reject any ambiguity (fail-closed); prefer false positives over misses
|
|
6
6
|
*
|
|
7
|
-
*
|
|
8
|
-
* -
|
|
9
|
-
* -
|
|
10
|
-
* -
|
|
11
|
-
* -
|
|
12
|
-
* -
|
|
7
|
+
* Design notes:
|
|
8
|
+
* - The state machine handles semicolons/keywords/doubled quotes inside quotes correctly
|
|
9
|
+
* - Reject backslash escapes (NO_BACKSLASH_ESCAPES ambiguity)
|
|
10
|
+
* - Reject all comments (blocks keyword-splitting bypasses)
|
|
11
|
+
* - Allow only a single statement, with at most one trailing semicolon
|
|
12
|
+
* - Reject NUL characters, unclosed quotes, unbalanced parentheses, oversized SQL
|
|
13
13
|
*/
|
|
14
14
|
|
|
15
|
-
const MAX_SQL_LENGTH = 64 * 1024; // 64KiB
|
|
15
|
+
const MAX_SQL_LENGTH = 64 * 1024; // 64KiB, guards against oversized payloads
|
|
16
16
|
|
|
17
|
-
// ──
|
|
17
|
+
// ── Lexer ──
|
|
18
18
|
|
|
19
|
-
/**
|
|
19
|
+
/** Lexer state enum */
|
|
20
20
|
const STATE = {
|
|
21
21
|
NORMAL: 0,
|
|
22
22
|
SINGLE_QUOTE: 1, // '...'
|
|
@@ -25,28 +25,28 @@ const STATE = {
|
|
|
25
25
|
};
|
|
26
26
|
|
|
27
27
|
/**
|
|
28
|
-
*
|
|
29
|
-
*
|
|
28
|
+
* Run a finite-state lexical scan over the SQL and return a token array
|
|
29
|
+
* Each token: { type, value, depth }
|
|
30
30
|
* type: 'word' | 'quoted' | 'number' | 'symbol' | 'semicolon' | 'whitespace'
|
|
31
|
-
* depth:
|
|
31
|
+
* depth: parenthesis nesting depth (content inside quotes keeps the outer depth)
|
|
32
32
|
*
|
|
33
33
|
* @param {string} sql
|
|
34
34
|
* @returns {Array<{type: string, value: string, depth: number}>}
|
|
35
35
|
*/
|
|
36
36
|
function tokenizeMysql(sql) {
|
|
37
|
-
if (typeof sql !== 'string') throw new ReadonlyError('SQL
|
|
38
|
-
if (sql.length === 0) throw new ReadonlyError('SQL
|
|
39
|
-
if (sql.length > MAX_SQL_LENGTH) throw new ReadonlyError(`SQL
|
|
37
|
+
if (typeof sql !== 'string') throw new ReadonlyError('SQL must be a string');
|
|
38
|
+
if (sql.length === 0) throw new ReadonlyError('SQL must not be empty');
|
|
39
|
+
if (sql.length > MAX_SQL_LENGTH) throw new ReadonlyError(`SQL too long (${sql.length} > ${MAX_SQL_LENGTH})`);
|
|
40
40
|
|
|
41
|
-
// NUL
|
|
42
|
-
if (sql.includes('\0')) throw new ReadonlyError('SQL
|
|
41
|
+
// NUL bytes can truncate at the underlying C layer; always reject
|
|
42
|
+
if (sql.includes('\0')) throw new ReadonlyError('SQL contains a NUL character');
|
|
43
43
|
|
|
44
44
|
const tokens = [];
|
|
45
45
|
let state = STATE.NORMAL;
|
|
46
|
-
let parenDepth = 0; //
|
|
46
|
+
let parenDepth = 0; // current parenthesis depth
|
|
47
47
|
let i = 0;
|
|
48
|
-
let wordBuf = ''; //
|
|
49
|
-
let numBuf = ''; //
|
|
48
|
+
let wordBuf = ''; // accumulate unquoted identifier
|
|
49
|
+
let numBuf = ''; // accumulate number
|
|
50
50
|
|
|
51
51
|
const emit = (type, value, d) => {
|
|
52
52
|
tokens.push({ type, value, depth: d });
|
|
@@ -70,23 +70,24 @@ function tokenizeMysql(sql) {
|
|
|
70
70
|
const ch = sql[i];
|
|
71
71
|
|
|
72
72
|
if (state === STATE.NORMAL) {
|
|
73
|
-
// ──
|
|
73
|
+
// ── Comment detection (comments are unnecessary for query verification;
|
|
74
|
+
// conservatively reject to block keyword-splitting bypasses) ──
|
|
74
75
|
if (ch === '#') {
|
|
75
|
-
throw new ReadonlyError('
|
|
76
|
+
throw new ReadonlyError('# comments are not allowed (prevents keyword-splitting bypasses)');
|
|
76
77
|
}
|
|
77
78
|
if (ch === '-' && i + 1 < sql.length && sql[i + 1] === '-') {
|
|
78
|
-
// --
|
|
79
|
-
throw new ReadonlyError('
|
|
79
|
+
// -- comment: the legal form is `-- ` (dashes + space), but reject regardless of the space
|
|
80
|
+
throw new ReadonlyError('-- comments are not allowed (prevents keyword-splitting bypasses)');
|
|
80
81
|
}
|
|
81
82
|
if (ch === '/' && i + 1 < sql.length && sql[i + 1] === '*') {
|
|
82
83
|
const nextCh = i + 2 < sql.length ? sql[i + 2] : '';
|
|
83
84
|
if (nextCh === '!' || nextCh === '+') {
|
|
84
|
-
throw new ReadonlyError(
|
|
85
|
+
throw new ReadonlyError(`/*${nextCh} ${nextCh === '!' ? 'version comment' : 'optimizer hint'} is not allowed (may contain executable code)`);
|
|
85
86
|
}
|
|
86
|
-
throw new ReadonlyError('
|
|
87
|
+
throw new ReadonlyError('/* */ block comments are not allowed (prevents keyword-splitting bypasses)');
|
|
87
88
|
}
|
|
88
89
|
|
|
89
|
-
// ──
|
|
90
|
+
// ── Quote state transitions ──
|
|
90
91
|
if (ch === "'") {
|
|
91
92
|
flushWord(parenDepth);
|
|
92
93
|
flushNum(parenDepth);
|
|
@@ -109,7 +110,7 @@ function tokenizeMysql(sql) {
|
|
|
109
110
|
continue;
|
|
110
111
|
}
|
|
111
112
|
|
|
112
|
-
// ──
|
|
113
|
+
// ── Parenthesis depth tracking ──
|
|
113
114
|
if (ch === '(') {
|
|
114
115
|
flushWord(parenDepth);
|
|
115
116
|
flushNum(parenDepth);
|
|
@@ -121,14 +122,14 @@ function tokenizeMysql(sql) {
|
|
|
121
122
|
if (ch === ')') {
|
|
122
123
|
flushWord(parenDepth);
|
|
123
124
|
flushNum(parenDepth);
|
|
124
|
-
if (parenDepth <= 0) throw new ReadonlyError('
|
|
125
|
+
if (parenDepth <= 0) throw new ReadonlyError('Unbalanced parentheses: extra )');
|
|
125
126
|
emit('symbol', ')', parenDepth);
|
|
126
127
|
parenDepth--;
|
|
127
128
|
i++;
|
|
128
129
|
continue;
|
|
129
130
|
}
|
|
130
131
|
|
|
131
|
-
// ──
|
|
132
|
+
// ── Semicolon ──
|
|
132
133
|
if (ch === ';') {
|
|
133
134
|
flushWord(parenDepth);
|
|
134
135
|
flushNum(parenDepth);
|
|
@@ -137,11 +138,11 @@ function tokenizeMysql(sql) {
|
|
|
137
138
|
continue;
|
|
138
139
|
}
|
|
139
140
|
|
|
140
|
-
// ──
|
|
141
|
+
// ── Punctuation/symbols ──
|
|
141
142
|
if (isSymbolChar(ch)) {
|
|
142
|
-
//
|
|
143
|
+
// Detect the := assignment operator
|
|
143
144
|
if (ch === ':' && i + 1 < sql.length && sql[i + 1] === '=') {
|
|
144
|
-
throw new ReadonlyError('
|
|
145
|
+
throw new ReadonlyError(':= assignment operator is not allowed');
|
|
145
146
|
}
|
|
146
147
|
flushWord(parenDepth);
|
|
147
148
|
flushNum(parenDepth);
|
|
@@ -150,7 +151,7 @@ function tokenizeMysql(sql) {
|
|
|
150
151
|
continue;
|
|
151
152
|
}
|
|
152
153
|
|
|
153
|
-
// ──
|
|
154
|
+
// ── Whitespace ──
|
|
154
155
|
if (/\s/.test(ch)) {
|
|
155
156
|
flushWord(parenDepth);
|
|
156
157
|
flushNum(parenDepth);
|
|
@@ -158,7 +159,7 @@ function tokenizeMysql(sql) {
|
|
|
158
159
|
continue;
|
|
159
160
|
}
|
|
160
161
|
|
|
161
|
-
// ──
|
|
162
|
+
// ── Numbers ──
|
|
162
163
|
if (/\d/.test(ch)) {
|
|
163
164
|
flushWord(parenDepth);
|
|
164
165
|
numBuf += ch;
|
|
@@ -166,7 +167,7 @@ function tokenizeMysql(sql) {
|
|
|
166
167
|
continue;
|
|
167
168
|
}
|
|
168
169
|
|
|
169
|
-
// ──
|
|
170
|
+
// ── Identifiers/keywords (letters, underscore, $) ──
|
|
170
171
|
if (/[a-zA-Z_$]/.test(ch)) {
|
|
171
172
|
flushNum(parenDepth);
|
|
172
173
|
wordBuf += ch;
|
|
@@ -174,38 +175,38 @@ function tokenizeMysql(sql) {
|
|
|
174
175
|
continue;
|
|
175
176
|
}
|
|
176
177
|
|
|
177
|
-
// ──
|
|
178
|
-
throw new ReadonlyError(`SQL
|
|
178
|
+
// ── Unknown character ──
|
|
179
|
+
throw new ReadonlyError(`SQL contains an unrecognized character: ${JSON.stringify(ch)} (position ${i})`);
|
|
179
180
|
}
|
|
180
181
|
|
|
181
|
-
// ──
|
|
182
|
+
// ── Single-quote state ──
|
|
182
183
|
if (state === STATE.SINGLE_QUOTE) {
|
|
183
184
|
if (ch === '\\') {
|
|
184
|
-
//
|
|
185
|
-
//
|
|
186
|
-
throw new ReadonlyError('
|
|
185
|
+
// Backslash escapes mean something different under NO_BACKSLASH_ESCAPES,
|
|
186
|
+
// and can split keywords to bypass detection; always reject
|
|
187
|
+
throw new ReadonlyError("Backslash escapes are not allowed; use standard quote doubling ('') instead");
|
|
187
188
|
}
|
|
188
189
|
if (ch === "'" && i + 1 < sql.length && sql[i + 1] === "'") {
|
|
189
|
-
//
|
|
190
|
+
// Standard doubled-quote escape: '' is a literal single quote
|
|
190
191
|
i += 2;
|
|
191
192
|
continue;
|
|
192
193
|
}
|
|
193
194
|
if (ch === "'") {
|
|
194
|
-
//
|
|
195
|
+
// Quote closes
|
|
195
196
|
emit('quoted', "'", parenDepth);
|
|
196
197
|
state = STATE.NORMAL;
|
|
197
198
|
i++;
|
|
198
199
|
continue;
|
|
199
200
|
}
|
|
200
|
-
//
|
|
201
|
+
// Quoted content is not recorded char by char; only the quote boundary token is kept
|
|
201
202
|
i++;
|
|
202
203
|
continue;
|
|
203
204
|
}
|
|
204
205
|
|
|
205
|
-
// ──
|
|
206
|
+
// ── Double-quote state ──
|
|
206
207
|
if (state === STATE.DOUBLE_QUOTE) {
|
|
207
208
|
if (ch === '\\') {
|
|
208
|
-
throw new ReadonlyError('
|
|
209
|
+
throw new ReadonlyError('Backslash escapes are not allowed; use standard quote doubling ("") instead');
|
|
209
210
|
}
|
|
210
211
|
if (ch === '"' && i + 1 < sql.length && sql[i + 1] === '"') {
|
|
211
212
|
i += 2;
|
|
@@ -221,7 +222,7 @@ function tokenizeMysql(sql) {
|
|
|
221
222
|
continue;
|
|
222
223
|
}
|
|
223
224
|
|
|
224
|
-
// ──
|
|
225
|
+
// ── Backtick state ──
|
|
225
226
|
if (state === STATE.BACKTICK) {
|
|
226
227
|
if (ch === '`' && i + 1 < sql.length && sql[i + 1] === '`') {
|
|
227
228
|
i += 2;
|
|
@@ -238,38 +239,38 @@ function tokenizeMysql(sql) {
|
|
|
238
239
|
}
|
|
239
240
|
}
|
|
240
241
|
|
|
241
|
-
// ──
|
|
242
|
+
// ── End-of-scan checks ──
|
|
242
243
|
flushWord(parenDepth);
|
|
243
244
|
flushNum(parenDepth);
|
|
244
245
|
|
|
245
246
|
if (state !== STATE.NORMAL) {
|
|
246
|
-
const stateName = state === STATE.SINGLE_QUOTE ? '
|
|
247
|
-
throw new ReadonlyError(
|
|
247
|
+
const stateName = state === STATE.SINGLE_QUOTE ? 'single quote' : state === STATE.DOUBLE_QUOTE ? 'double quote' : 'backtick';
|
|
248
|
+
throw new ReadonlyError(`Unclosed ${stateName}`);
|
|
248
249
|
}
|
|
249
250
|
if (parenDepth !== 0) {
|
|
250
|
-
throw new ReadonlyError(
|
|
251
|
+
throw new ReadonlyError(`Unbalanced parentheses: missing ${parenDepth} )`);
|
|
251
252
|
}
|
|
252
253
|
|
|
253
254
|
return tokens;
|
|
254
255
|
}
|
|
255
256
|
|
|
256
|
-
/**
|
|
257
|
+
/** Check whether a character is SQL punctuation (not letter, digit, whitespace, quote, semicolon, or parenthesis) */
|
|
257
258
|
function isSymbolChar(ch) {
|
|
258
259
|
return /[+\-*/%=<>!&|^~,.\:@]/.test(ch);
|
|
259
260
|
}
|
|
260
261
|
|
|
261
|
-
// ──
|
|
262
|
+
// ── Statement classification ──
|
|
262
263
|
|
|
263
264
|
/**
|
|
264
|
-
*
|
|
265
|
-
*
|
|
266
|
-
* @param {Array} tokens - tokenizeMysql
|
|
265
|
+
* Classify the root statement from tokens
|
|
266
|
+
* Only SELECT / WITH...SELECT / SHOW / DESCRIBE / EXPLAIN are allowed
|
|
267
|
+
* @param {Array} tokens - tokenizeMysql return value
|
|
267
268
|
* @returns {string} 'SELECT' | 'WITH_SELECT' | 'SHOW' | 'DESCRIBE' | 'EXPLAIN'
|
|
268
269
|
*/
|
|
269
270
|
function classifyMysqlStatement(tokens) {
|
|
270
|
-
if (tokens.length === 0) throw new ReadonlyError('
|
|
271
|
+
if (tokens.length === 0) throw new ReadonlyError('No valid tokens');
|
|
271
272
|
|
|
272
|
-
//
|
|
273
|
+
// Find the first word token at depth=0 (leading whitespace is skipped; comments were rejected)
|
|
273
274
|
let firstWord = null;
|
|
274
275
|
let firstWordIdx = -1;
|
|
275
276
|
for (let i = 0; i < tokens.length; i++) {
|
|
@@ -280,15 +281,15 @@ function classifyMysqlStatement(tokens) {
|
|
|
280
281
|
}
|
|
281
282
|
}
|
|
282
283
|
|
|
283
|
-
if (!firstWord) throw new ReadonlyError('
|
|
284
|
+
if (!firstWord) throw new ReadonlyError('No root statement keyword found');
|
|
284
285
|
|
|
285
286
|
switch (firstWord) {
|
|
286
287
|
case 'SELECT':
|
|
287
288
|
return 'SELECT';
|
|
288
289
|
|
|
289
290
|
case 'WITH': {
|
|
290
|
-
// CTE
|
|
291
|
-
//
|
|
291
|
+
// CTE: the root query after WITH must be SELECT (reject WITH ... DELETE/UPDATE)
|
|
292
|
+
// Strategy: scan word tokens at depth=0 for statement keywords; the first must be SELECT
|
|
292
293
|
const STATEMENT_KW = new Set([
|
|
293
294
|
'SELECT', 'INSERT', 'UPDATE', 'DELETE', 'REPLACE', 'CALL', 'DO',
|
|
294
295
|
]);
|
|
@@ -301,13 +302,13 @@ function classifyMysqlStatement(tokens) {
|
|
|
301
302
|
if (w === 'SELECT') {
|
|
302
303
|
foundSelect = true;
|
|
303
304
|
} else {
|
|
304
|
-
throw new ReadonlyError(`WITH CTE
|
|
305
|
+
throw new ReadonlyError(`Root statement after a WITH CTE must be SELECT, found: ${w}`);
|
|
305
306
|
}
|
|
306
307
|
break;
|
|
307
308
|
}
|
|
308
309
|
}
|
|
309
310
|
if (!foundSelect) {
|
|
310
|
-
throw new ReadonlyError('
|
|
311
|
+
throw new ReadonlyError('No root SELECT query found after WITH');
|
|
311
312
|
}
|
|
312
313
|
return 'WITH_SELECT';
|
|
313
314
|
}
|
|
@@ -320,10 +321,12 @@ function classifyMysqlStatement(tokens) {
|
|
|
320
321
|
return 'DESCRIBE';
|
|
321
322
|
|
|
322
323
|
case 'EXPLAIN': {
|
|
323
|
-
//
|
|
324
|
-
//
|
|
325
|
-
//
|
|
326
|
-
//
|
|
324
|
+
// Only EXPLAIN [FORMAT=...] SELECT/WITH is allowed
|
|
325
|
+
// Reject EXPLAIN ANALYZE (it actually executes the statement and causes
|
|
326
|
+
// writes/locks) and EXPLAIN DML
|
|
327
|
+
// Fix: the old implementation looked only at the first word after
|
|
328
|
+
// EXPLAIN, misjudged FORMAT as illegal, and rejected valid
|
|
329
|
+
// EXPLAIN FORMAT=JSON SELECT. Collect all following words instead.
|
|
327
330
|
const wordsAfterExplain = [];
|
|
328
331
|
for (let i = firstWordIdx + 1; i < tokens.length; i++) {
|
|
329
332
|
if (tokens[i].type === 'word' && tokens[i].depth === 0) {
|
|
@@ -331,25 +334,25 @@ function classifyMysqlStatement(tokens) {
|
|
|
331
334
|
}
|
|
332
335
|
}
|
|
333
336
|
if (wordsAfterExplain.length === 0) {
|
|
334
|
-
//
|
|
335
|
-
throw new ReadonlyError('EXPLAIN
|
|
337
|
+
// No SELECT/WITH after EXPLAIN (e.g. legacy EXPLAIN tbl_name); unsupported
|
|
338
|
+
throw new ReadonlyError('EXPLAIN must be followed by SELECT or WITH');
|
|
336
339
|
}
|
|
337
340
|
const explainFirst = wordsAfterExplain[0];
|
|
338
341
|
if (explainFirst === 'ANALYZE') {
|
|
339
|
-
throw new ReadonlyError('
|
|
342
|
+
throw new ReadonlyError('EXPLAIN ANALYZE is not allowed (it actually executes the statement)');
|
|
340
343
|
}
|
|
341
344
|
if (explainFirst === 'FORMAT') {
|
|
342
345
|
// EXPLAIN FORMAT=JSON/TRADITIONAL/TREE/WIDTH SELECT ...
|
|
343
|
-
//
|
|
346
|
+
// (= is a non-word token and was skipped; wordsAfterExplain looks like [FORMAT, JSON, SELECT, ...])
|
|
344
347
|
const EXPLAIN_FORMATS = new Set(['JSON', 'TRADITIONAL', 'TREE', 'WIDTH']);
|
|
345
348
|
if (wordsAfterExplain.length < 2) {
|
|
346
|
-
throw new ReadonlyError('EXPLAIN FORMAT=
|
|
349
|
+
throw new ReadonlyError('EXPLAIN FORMAT= requires a format name (JSON/TRADITIONAL/TREE/WIDTH)');
|
|
347
350
|
}
|
|
348
351
|
const fmtName = wordsAfterExplain[1];
|
|
349
352
|
if (!EXPLAIN_FORMATS.has(fmtName)) {
|
|
350
|
-
throw new ReadonlyError(`EXPLAIN FORMAT=
|
|
353
|
+
throw new ReadonlyError(`EXPLAIN FORMAT= unsupported format name: ${fmtName}`);
|
|
351
354
|
}
|
|
352
|
-
//
|
|
355
|
+
// SELECT/WITH must immediately follow the format name
|
|
353
356
|
let stmtWord = null;
|
|
354
357
|
for (let k = 2; k < wordsAfterExplain.length; k++) {
|
|
355
358
|
if (wordsAfterExplain[k] === 'SELECT' || wordsAfterExplain[k] === 'WITH') {
|
|
@@ -358,24 +361,24 @@ function classifyMysqlStatement(tokens) {
|
|
|
358
361
|
}
|
|
359
362
|
}
|
|
360
363
|
if (!stmtWord) {
|
|
361
|
-
throw new ReadonlyError('EXPLAIN FORMAT=...
|
|
364
|
+
throw new ReadonlyError('EXPLAIN FORMAT=... must be followed by SELECT or WITH');
|
|
362
365
|
}
|
|
363
366
|
return 'EXPLAIN';
|
|
364
367
|
}
|
|
365
368
|
if (explainFirst !== 'SELECT' && explainFirst !== 'WITH') {
|
|
366
|
-
throw new ReadonlyError(
|
|
369
|
+
throw new ReadonlyError(`EXPLAIN ${explainFirst} is not allowed (only EXPLAIN [FORMAT=...] SELECT/WITH is allowed)`);
|
|
367
370
|
}
|
|
368
371
|
return 'EXPLAIN';
|
|
369
372
|
}
|
|
370
373
|
|
|
371
374
|
default:
|
|
372
|
-
throw new ReadonlyError(
|
|
375
|
+
throw new ReadonlyError(`Statement type not allowed: ${firstWord}`);
|
|
373
376
|
}
|
|
374
377
|
}
|
|
375
378
|
|
|
376
|
-
// ──
|
|
379
|
+
// ── Dangerous keyword/pattern detection ──
|
|
377
380
|
|
|
378
|
-
/**
|
|
381
|
+
/** Keywords rejected when found in unquoted word tokens */
|
|
379
382
|
const FORBIDDEN_WORDS = new Set([
|
|
380
383
|
'INSERT', 'UPDATE', 'DELETE', 'REPLACE', 'DROP', 'ALTER', 'CREATE',
|
|
381
384
|
'TRUNCATE', 'RENAME', 'GRANT', 'REVOKE', 'CALL', 'DO', 'LOAD',
|
|
@@ -385,20 +388,20 @@ const FORBIDDEN_WORDS = new Set([
|
|
|
385
388
|
'PREPARE', 'EXECUTE', 'DEALLOCATE',
|
|
386
389
|
]);
|
|
387
390
|
|
|
388
|
-
/**
|
|
391
|
+
/** Pattern keywords that need special detection */
|
|
389
392
|
const INTO_FORBIDDEN = 'INTO';
|
|
390
393
|
const FOR_UPDATE_PATTERN = ['FOR', 'UPDATE'];
|
|
391
394
|
const LOCK_SHARE_PATTERN = ['LOCK', 'IN', 'SHARE', 'MODE'];
|
|
392
395
|
|
|
393
|
-
/**
|
|
396
|
+
/** Dangerous function names */
|
|
394
397
|
const DANGEROUS_FUNCTIONS = new Set([
|
|
395
398
|
'SLEEP', 'BENCHMARK', 'GET_LOCK', 'RELEASE_LOCK',
|
|
396
399
|
'IS_FREE_LOCK', 'IS_USED_LOCK', 'LOAD_FILE', 'MASTER_POS_WAIT',
|
|
397
400
|
]);
|
|
398
401
|
|
|
399
402
|
/**
|
|
400
|
-
*
|
|
401
|
-
* SELECT/WITH
|
|
403
|
+
* Check the token stream for forbidden keywords/patterns
|
|
404
|
+
* Reject any write keyword appearing inside SELECT/WITH
|
|
402
405
|
*/
|
|
403
406
|
function checkForbiddenPatterns(tokens) {
|
|
404
407
|
for (let i = 0; i < tokens.length; i++) {
|
|
@@ -407,48 +410,48 @@ function checkForbiddenPatterns(tokens) {
|
|
|
407
410
|
|
|
408
411
|
const upper = tok.value.toUpperCase();
|
|
409
412
|
|
|
410
|
-
//
|
|
413
|
+
// Dangerous function detection (rejected even inside subqueries)
|
|
411
414
|
if (DANGEROUS_FUNCTIONS.has(upper)) {
|
|
412
|
-
throw new ReadonlyError(
|
|
415
|
+
throw new ReadonlyError(`Dangerous function not allowed: ${upper}`);
|
|
413
416
|
}
|
|
414
417
|
|
|
415
|
-
// INTO
|
|
416
|
-
//
|
|
418
|
+
// INTO can appear in various legal positions (SELECT INTO is uncommon but exists),
|
|
419
|
+
// but SELECT INTO OUTFILE/DUMPFILE writes files — too risky, reject unconditionally
|
|
417
420
|
if (upper === INTO_FORBIDDEN) {
|
|
418
|
-
//
|
|
421
|
+
// Check whether OUTFILE/DUMPFILE or a variable-assignment pattern follows
|
|
419
422
|
for (let j = i + 1; j < tokens.length && j < i + 4; j++) {
|
|
420
423
|
const next = tokens[j];
|
|
421
424
|
if (next.type !== 'word') continue;
|
|
422
425
|
const nextUpper = next.value.toUpperCase();
|
|
423
426
|
if (nextUpper === 'OUTFILE' || nextUpper === 'DUMPFILE' || nextUpper === '@') {
|
|
424
|
-
throw new ReadonlyError(
|
|
427
|
+
throw new ReadonlyError(`INTO ${nextUpper} is not allowed (data exfiltration risk)`);
|
|
425
428
|
}
|
|
426
429
|
}
|
|
427
|
-
//
|
|
428
|
-
//
|
|
429
|
-
throw new ReadonlyError('
|
|
430
|
+
// Conservative policy: reject INTO even outside OUTFILE/DUMPFILE
|
|
431
|
+
// because INTO can also assign variables and indirectly cause writes
|
|
432
|
+
throw new ReadonlyError('INTO is not allowed (covers OUTFILE/DUMPFILE/variable assignment)');
|
|
430
433
|
}
|
|
431
434
|
|
|
432
|
-
// FOR UPDATE
|
|
435
|
+
// FOR UPDATE lock detection
|
|
433
436
|
if (upper === 'FOR' && tok.depth === 0) {
|
|
434
437
|
const subsequent = getSubsequentRootWords(tokens, i);
|
|
435
438
|
if (subsequent.length >= 1 && subsequent[0] === 'UPDATE') {
|
|
436
|
-
throw new ReadonlyError('
|
|
439
|
+
throw new ReadonlyError('FOR UPDATE is not allowed (exclusive lock)');
|
|
437
440
|
}
|
|
438
441
|
if (subsequent.length >= 3 &&
|
|
439
442
|
subsequent[0] === 'LOCK' && subsequent[1] === 'IN' && subsequent[2] === 'SHARE' && subsequent[3] === 'MODE') {
|
|
440
|
-
throw new ReadonlyError('
|
|
443
|
+
throw new ReadonlyError('LOCK IN SHARE MODE is not allowed (shared lock)');
|
|
441
444
|
}
|
|
442
445
|
}
|
|
443
446
|
|
|
444
|
-
//
|
|
447
|
+
// Forbidden keywords (inside SELECT/WITH)
|
|
445
448
|
if (FORBIDDEN_WORDS.has(upper)) {
|
|
446
|
-
throw new ReadonlyError(
|
|
449
|
+
throw new ReadonlyError(`Not allowed in read-only queries: ${upper}`);
|
|
447
450
|
}
|
|
448
451
|
}
|
|
449
452
|
}
|
|
450
453
|
|
|
451
|
-
/**
|
|
454
|
+
/** Collect consecutive depth=0 word tokens after tokens[i] (skip whitespace/non-word) */
|
|
452
455
|
function getSubsequentRootWords(tokens, startIdx) {
|
|
453
456
|
const result = [];
|
|
454
457
|
for (let j = startIdx + 1; j < tokens.length; j++) {
|
|
@@ -457,15 +460,15 @@ function getSubsequentRootWords(tokens, startIdx) {
|
|
|
457
460
|
if (tok.type === 'word' && tok.depth === 0) {
|
|
458
461
|
result.push(tok.value.toUpperCase());
|
|
459
462
|
}
|
|
460
|
-
if (result.length >= 4) break; //
|
|
463
|
+
if (result.length >= 4) break; // only the first few are needed
|
|
461
464
|
}
|
|
462
465
|
return result;
|
|
463
466
|
}
|
|
464
467
|
|
|
465
|
-
// ──
|
|
468
|
+
// ── Single-statement check ──
|
|
466
469
|
|
|
467
470
|
/**
|
|
468
|
-
*
|
|
471
|
+
* Verify the SQL contains a single statement with at most one trailing semicolon
|
|
469
472
|
* @param {Array} tokens
|
|
470
473
|
*/
|
|
471
474
|
function checkSingleStatement(tokens) {
|
|
@@ -473,39 +476,40 @@ function checkSingleStatement(tokens) {
|
|
|
473
476
|
for (const tok of tokens) {
|
|
474
477
|
if (tok.type === 'semicolon') {
|
|
475
478
|
semicolons++;
|
|
476
|
-
//
|
|
479
|
+
// A semicolon must sit at depth=0 (not inside parentheses/subqueries)
|
|
477
480
|
if (tok.depth !== 0) {
|
|
478
|
-
throw new ReadonlyError('
|
|
481
|
+
throw new ReadonlyError('Semicolons inside parentheses are not allowed');
|
|
479
482
|
}
|
|
480
483
|
}
|
|
481
484
|
}
|
|
482
485
|
if (semicolons > 1) {
|
|
483
|
-
throw new ReadonlyError(
|
|
486
|
+
throw new ReadonlyError(`Only a single statement is allowed; found ${semicolons} semicolons`);
|
|
484
487
|
}
|
|
485
|
-
//
|
|
488
|
+
// If there is a semicolon, it must be the last token
|
|
486
489
|
if (semicolons === 1) {
|
|
487
490
|
const last = tokens[tokens.length - 1];
|
|
488
491
|
if (last.type !== 'semicolon') {
|
|
489
|
-
throw new ReadonlyError('
|
|
492
|
+
throw new ReadonlyError('A semicolon is allowed only at the end of the SQL');
|
|
490
493
|
}
|
|
491
494
|
}
|
|
492
495
|
}
|
|
493
496
|
|
|
494
|
-
// ── LIMIT
|
|
497
|
+
// ── LIMIT handling ──
|
|
495
498
|
|
|
496
499
|
/**
|
|
497
|
-
*
|
|
498
|
-
* LIMIT
|
|
499
|
-
* LIMIT
|
|
500
|
-
* LIMIT
|
|
501
|
-
*
|
|
500
|
+
* Analyze the depth=0 LIMIT clause, strictly allowing only three forms:
|
|
501
|
+
* LIMIT <single non-negative integer>
|
|
502
|
+
* LIMIT <non-negative integer> OFFSET <non-negative integer>
|
|
503
|
+
* LIMIT <non-negative integer>, <non-negative integer> (MySQL legacy offset,count)
|
|
504
|
+
* Any expression, variable, or operator → reject (prevents bypassing maxRows)
|
|
502
505
|
*
|
|
503
|
-
* @param {Array} tokens - tokenizeMysql
|
|
504
|
-
* @param {number} maxRows -
|
|
506
|
+
* @param {Array} tokens - tokenizeMysql return value
|
|
507
|
+
* @param {number} maxRows - maximum allowed row count
|
|
505
508
|
* @returns {{ hasLimit: boolean, limitCount: number }}
|
|
506
509
|
*/
|
|
507
510
|
function analyzeTopLevelLimit(tokens, maxRows) {
|
|
508
|
-
//
|
|
511
|
+
// Locate the last LIMIT keyword at depth=0 (LIMITs inside subqueries sit at
|
|
512
|
+
// depth>0 and are excluded naturally)
|
|
509
513
|
let lastLimitIdx = -1;
|
|
510
514
|
let foundSelect = false;
|
|
511
515
|
for (let i = 0; i < tokens.length; i++) {
|
|
@@ -513,7 +517,7 @@ function analyzeTopLevelLimit(tokens, maxRows) {
|
|
|
513
517
|
if (tok.depth > 0) continue;
|
|
514
518
|
if (tok.type === 'word') {
|
|
515
519
|
const upper = tok.value.toUpperCase();
|
|
516
|
-
//
|
|
520
|
+
// A top-level SELECT/UNION keyword means a new query segment
|
|
517
521
|
if (upper === 'SELECT' || upper === 'UNION') foundSelect = true;
|
|
518
522
|
}
|
|
519
523
|
if (tok.type === 'word' && tok.value.toUpperCase() === 'LIMIT' && tok.depth === 0 && foundSelect) {
|
|
@@ -522,82 +526,84 @@ function analyzeTopLevelLimit(tokens, maxRows) {
|
|
|
522
526
|
}
|
|
523
527
|
if (lastLimitIdx === -1) return { hasLimit: false };
|
|
524
528
|
|
|
525
|
-
//
|
|
529
|
+
// Collect depth=0 tokens after LIMIT (until end of statement or the next
|
|
530
|
+
// top-level keyword)
|
|
526
531
|
const limitTokens = [];
|
|
527
532
|
for (let i = lastLimitIdx + 1; i < tokens.length; i++) {
|
|
528
533
|
const tok = tokens[i];
|
|
529
534
|
if (tok.depth > 0) continue;
|
|
530
|
-
//
|
|
535
|
+
// A top-level UNION keyword means the LIMIT belongs to the previous
|
|
536
|
+
// segment; stop collecting
|
|
531
537
|
if (tok.type === 'word' && tok.value.toUpperCase() === 'UNION') break;
|
|
532
538
|
limitTokens.push(tok);
|
|
533
539
|
}
|
|
534
540
|
|
|
535
|
-
//
|
|
541
|
+
// Parse the token sequence after LIMIT; only the three forms are allowed
|
|
536
542
|
return parseLimitClause(limitTokens, maxRows);
|
|
537
543
|
}
|
|
538
544
|
|
|
539
545
|
/**
|
|
540
|
-
*
|
|
541
|
-
* @param {Array} limitTokens -
|
|
546
|
+
* Parse the LIMIT clause token sequence, strictly validating the form and count ≤ maxRows
|
|
547
|
+
* @param {Array} limitTokens - depth=0 tokens after the LIMIT keyword
|
|
542
548
|
* @param {number} maxRows
|
|
543
549
|
* @returns {{ hasLimit: boolean, limitCount: number }}
|
|
544
550
|
*/
|
|
545
551
|
function parseLimitClause(limitTokens, maxRows) {
|
|
546
|
-
//
|
|
552
|
+
// Drop whitespace; keep only meaningful tokens
|
|
547
553
|
const meaningful = limitTokens.filter(t => t.type !== 'whitespace');
|
|
548
554
|
|
|
549
|
-
if (meaningful.length === 0) throw new ReadonlyError('LIMIT
|
|
555
|
+
if (meaningful.length === 0) throw new ReadonlyError('LIMIT is missing its arguments');
|
|
550
556
|
|
|
551
|
-
//
|
|
557
|
+
// Must start with a number
|
|
552
558
|
const first = meaningful[0];
|
|
553
|
-
if (first.type !== 'number') throw new ReadonlyError('LIMIT
|
|
559
|
+
if (first.type !== 'number') throw new ReadonlyError('LIMIT arguments must be plain integers (expressions or variables are not accepted)');
|
|
554
560
|
const firstVal = Number(first.value);
|
|
555
|
-
if (!Number.isInteger(firstVal) || firstVal < 0) throw new ReadonlyError('LIMIT
|
|
561
|
+
if (!Number.isInteger(firstVal) || firstVal < 0) throw new ReadonlyError('LIMIT must be a non-negative integer');
|
|
556
562
|
|
|
557
|
-
// ──
|
|
558
|
-
//
|
|
563
|
+
// ── Check the remainder for operators/placeholders (expression bypass) ──
|
|
564
|
+
// Any symbol token after LIMIT (except ,) implies an expression
|
|
559
565
|
for (const tok of meaningful) {
|
|
560
566
|
if (tok.type === 'symbol' && tok.value !== ',') {
|
|
561
|
-
throw new ReadonlyError(`LIMIT
|
|
567
|
+
throw new ReadonlyError(`LIMIT does not allow operator ${tok.value} (plain integers only)`);
|
|
562
568
|
}
|
|
563
569
|
}
|
|
564
570
|
|
|
565
|
-
// ──
|
|
571
|
+
// ── Form 1: LIMIT <count> (single integer) ──
|
|
566
572
|
if (meaningful.length === 1) {
|
|
567
|
-
if (firstVal > maxRows) throw new ReadonlyError(`LIMIT ${firstVal}
|
|
573
|
+
if (firstVal > maxRows) throw new ReadonlyError(`LIMIT ${firstVal} exceeds the maximum row count ${maxRows}`);
|
|
568
574
|
return { hasLimit: true, limitCount: firstVal };
|
|
569
575
|
}
|
|
570
576
|
|
|
571
|
-
// ──
|
|
577
|
+
// ── Form 2: LIMIT <offset>, <count> (comma-separated) ──
|
|
572
578
|
if (meaningful.length === 3 && meaningful[1].type === 'symbol' && meaningful[1].value === ',') {
|
|
573
|
-
if (meaningful[2].type !== 'number') throw new ReadonlyError('LIMIT
|
|
579
|
+
if (meaningful[2].type !== 'number') throw new ReadonlyError('LIMIT offset/count must be plain integers');
|
|
574
580
|
const countVal = Number(meaningful[2].value);
|
|
575
|
-
if (!Number.isInteger(countVal) || countVal < 0) throw new ReadonlyError('LIMIT
|
|
576
|
-
if (countVal > maxRows) throw new ReadonlyError(`LIMIT ${countVal}
|
|
581
|
+
if (!Number.isInteger(countVal) || countVal < 0) throw new ReadonlyError('LIMIT count must be a non-negative integer');
|
|
582
|
+
if (countVal > maxRows) throw new ReadonlyError(`LIMIT ${countVal} exceeds the maximum row count ${maxRows}`);
|
|
577
583
|
return { hasLimit: true, limitCount: countVal };
|
|
578
584
|
}
|
|
579
585
|
|
|
580
|
-
// ──
|
|
586
|
+
// ── Form 3: LIMIT <count> OFFSET <offset> (keyword-separated) ──
|
|
581
587
|
if (meaningful.length === 3 && meaningful[1].type === 'word' && meaningful[1].value.toUpperCase() === 'OFFSET') {
|
|
582
|
-
if (meaningful[2].type !== 'number') throw new ReadonlyError('LIMIT OFFSET
|
|
588
|
+
if (meaningful[2].type !== 'number') throw new ReadonlyError('LIMIT OFFSET value must be a plain integer');
|
|
583
589
|
const offsetVal = Number(meaningful[2].value);
|
|
584
|
-
if (!Number.isInteger(offsetVal) || offsetVal < 0) throw new ReadonlyError('LIMIT OFFSET
|
|
585
|
-
// firstVal
|
|
586
|
-
if (firstVal > maxRows) throw new ReadonlyError(`LIMIT ${firstVal}
|
|
590
|
+
if (!Number.isInteger(offsetVal) || offsetVal < 0) throw new ReadonlyError('LIMIT OFFSET must be a non-negative integer');
|
|
591
|
+
// firstVal is the count; the offset does not affect the returned row count
|
|
592
|
+
if (firstVal > maxRows) throw new ReadonlyError(`LIMIT ${firstVal} exceeds the maximum row count ${maxRows}`);
|
|
587
593
|
return { hasLimit: true, limitCount: firstVal };
|
|
588
594
|
}
|
|
589
595
|
|
|
590
|
-
//
|
|
591
|
-
throw new ReadonlyError('LIMIT
|
|
596
|
+
// None of the allowed forms → reject
|
|
597
|
+
throw new ReadonlyError('LIMIT only allows: plain integer / integer,integer / integer OFFSET integer');
|
|
592
598
|
}
|
|
593
599
|
|
|
594
|
-
// ──
|
|
600
|
+
// ── Main validation entry ──
|
|
595
601
|
|
|
596
602
|
/**
|
|
597
|
-
*
|
|
598
|
-
*
|
|
603
|
+
* Fully validate the SQL as read-only and return the processed SQL plus metadata
|
|
604
|
+
* Throw on any ambiguity; never let it pass
|
|
599
605
|
*
|
|
600
|
-
* @param {string} sql -
|
|
606
|
+
* @param {string} sql - raw SQL
|
|
601
607
|
* @param {object} opts - { maxRows: number }
|
|
602
608
|
* @returns {{ sql: string, kind: string, limitInjected: boolean }}
|
|
603
609
|
*/
|
|
@@ -609,26 +615,29 @@ function validateMysqlSql(sql, { maxRows } = {}) {
|
|
|
609
615
|
|
|
610
616
|
const kind = classifyMysqlStatement(tokens);
|
|
611
617
|
|
|
612
|
-
//
|
|
613
|
-
//
|
|
614
|
-
//
|
|
618
|
+
// Fix: SHOW/DESCRIBE are metadata queries whose text legally contains words
|
|
619
|
+
// like CREATE/PROCEDURE (e.g. SHOW CREATE TABLE); running
|
|
620
|
+
// checkForbiddenPatterns would false-positive on the forbidden-word list.
|
|
621
|
+
// Both statement kinds are read-only metadata queries and safe, so return
|
|
622
|
+
// early and skip the forbidden-word check.
|
|
615
623
|
if (kind === 'SHOW' || kind === 'DESCRIBE') {
|
|
616
624
|
return { sql: sql.trimEnd(), kind, limitInjected: false };
|
|
617
625
|
}
|
|
618
626
|
|
|
619
|
-
// SELECT / WITH_SELECT / EXPLAIN
|
|
627
|
+
// Only SELECT / WITH_SELECT / EXPLAIN get the forbidden-keyword/dangerous-function check
|
|
620
628
|
checkForbiddenPatterns(tokens);
|
|
621
629
|
|
|
622
|
-
// SELECT / WITH_SELECT / EXPLAIN
|
|
630
|
+
// LIMIT handling for SELECT / WITH_SELECT / EXPLAIN
|
|
623
631
|
const limitInfo = analyzeTopLevelLimit(tokens, maxRows);
|
|
624
632
|
|
|
625
633
|
if (limitInfo.hasLimit) {
|
|
626
|
-
// analyzeTopLevelLimit
|
|
634
|
+
// analyzeTopLevelLimit already verified count ≤ maxRows; return directly
|
|
627
635
|
return { sql: sql.trimEnd(), kind, limitInjected: false };
|
|
628
636
|
}
|
|
629
637
|
|
|
630
|
-
//
|
|
631
|
-
//
|
|
638
|
+
// No LIMIT → inject a truncation sentinel
|
|
639
|
+
// Strip the trailing semicolon and append LIMIT (maxRows+1); the sentinel
|
|
640
|
+
// detects truncation
|
|
632
641
|
let boundedSql = sql.trimEnd();
|
|
633
642
|
if (boundedSql.endsWith(';')) {
|
|
634
643
|
boundedSql = boundedSql.slice(0, -1).trimEnd();
|
|
@@ -638,7 +647,7 @@ function validateMysqlSql(sql, { maxRows } = {}) {
|
|
|
638
647
|
return { sql: boundedSql, kind, limitInjected: true };
|
|
639
648
|
}
|
|
640
649
|
|
|
641
|
-
// ──
|
|
650
|
+
// ── Custom error ──
|
|
642
651
|
|
|
643
652
|
class ReadonlyError extends Error {
|
|
644
653
|
constructor(msg) {
|