dsh-switchman 1.5.0 → 1.7.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.
@@ -1,22 +1,22 @@
1
1
  'use strict';
2
2
 
3
3
  /**
4
- * MySQL 只读校验:有限状态词法扫描器 + 语句分类 + LIMIT 注入
5
- * 安全核心:任何歧义都拒绝(fail-closed),宁可误杀不可漏过
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
- * - 状态机正确处理引号内分号/关键字/ doubled quote
9
- * - 拒绝反斜杠转义(NO_BACKSLASH_ESCAPES 歧义)
10
- * - 拒绝所有注释(堵住关键字拆分绕过)
11
- * - 只允许单条语句,最多末尾一个分号
12
- * - 拒绝 NUL 字符、未闭合引号、括号不平衡、超长 SQL
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,防超大 payload
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
- * 对 SQL 做有限状态词法扫描,返回 token 数组
29
- * 每个_token: { type, value, depth }
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: 括号嵌套深度(引号内也算外层的 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 超长(${sql.length} > ${MAX_SQL_LENGTH})`);
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 字节可能导致底层 C 层截断,一律拒绝
42
- if (sql.includes('\0')) throw new ReadonlyError('SQL 含 NUL 字符');
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(`不允许 /*${nextCh} ${nextCh === '!' ? 'version comment' : 'optimizer hint'}(可能含可执行代码)`);
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 含无法识别的字符: ${JSON.stringify(ch)} (位置 ${i})`);
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
- // 反斜杠转义在 NO_BACKSLASH_ESCAPES 模式下语义不同,
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
- // 引号内内容不逐字符记录,只保留引号边界 token
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 ? '单引号' : state === STATE.DOUBLE_QUOTE ? '双引号' : '反引号';
247
- throw new ReadonlyError(`未闭合的${stateName}`);
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(`括号不匹配:缺少 ${parenDepth} 个 )`);
251
+ throw new ReadonlyError(`Unbalanced parentheses: missing ${parenDepth} )`);
251
252
  }
252
253
 
253
254
  return tokens;
254
255
  }
255
256
 
256
- /** 判断是否为 SQL 标点符号字符(非字母、非数字、非空白、非引号、非分号、非括号) */
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
- * 根据 token 判断根语句类型
265
- * 只允许 SELECT / WITH...SELECT / SHOW / DESCRIBE / EXPLAIN
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('无有效 token');
271
+ if (tokens.length === 0) throw new ReadonlyError('No valid tokens');
271
272
 
272
- // 找到 depth=0 的首个 word token(跳过前导空白/注释已被拒绝)
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:WITH 后的根查询必须是 SELECT(拒绝 WITH ... DELETE/UPDATE)
291
- // 策略:找 depth=0 上出现过的所有「语句类关键字」,第一个必须是 SELECT
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 后的根语句必须是 SELECT,发现: ${w}`);
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('WITH 后未找到根查询 SELECT 语句');
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
- // 只允许 EXPLAIN [FORMAT=...] SELECT/WITH
324
- // 拒绝 EXPLAIN ANALYZE(会实际执行语句并产生写入/锁)、EXPLAIN DML
325
- // 修复:旧实现只看 EXPLAIN 后第一个 word,遇到 FORMAT 就误判为非法,
326
- // 导致合法的 EXPLAIN FORMAT=JSON SELECT 被拒。这里收集后续所有 word 再判定。
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
- // EXPLAIN 后没有 SELECT/WITH(如旧式 EXPLAIN tbl_name),不支持
335
- throw new ReadonlyError('EXPLAIN 后必须跟 SELECT 或 WITH');
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('不允许 EXPLAIN ANALYZE(会实际执行语句)');
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
- // (= 是非 word token,已被跳过;wordsAfterExplain 形如 [FORMAT, JSON, SELECT, ...])
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= 需要格式名(JSON/TRADITIONAL/TREE/WIDTH)');
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= 不支持的格式名: ${fmtName}`);
353
+ throw new ReadonlyError(`EXPLAIN FORMAT= unsupported format name: ${fmtName}`);
351
354
  }
352
- // 格式名之后必须紧跟 SELECT/WITH
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=... 后必须是 SELECT 或 WITH');
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(`不允许 EXPLAIN ${explainFirst}(只允许 EXPLAIN [FORMAT=...] SELECT/WITH)`);
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(`不允许的语句类型: ${firstWord}`);
375
+ throw new ReadonlyError(`Statement type not allowed: ${firstWord}`);
373
376
  }
374
377
  }
375
378
 
376
- // ── 危险关键字/模式检测 ──
379
+ // ── Dangerous keyword/pattern detection ──
377
380
 
378
- /** 非引用 word token 中遇到即拒绝的关键字 */
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
- * 检查 token 流中是否包含禁止的关键字/模式
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(`不允许危险函数: ${upper}`);
415
+ throw new ReadonlyError(`Dangerous function not allowed: ${upper}`);
413
416
  }
414
417
 
415
- // INTO 可以出现在各种合法位置(SELECT INTO 不常见但存在),
416
- // 但 SELECT INTO OUTFILE/DUMPFILE 是写文件操作,风险太高一律拒绝
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
- // 检查后续是否紧跟 OUTFILE/DUMPFILE 或变量赋值模式
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(`不允许 INTO ${nextUpper}(数据写出风险)`);
427
+ throw new ReadonlyError(`INTO ${nextUpper} is not allowed (data exfiltration risk)`);
425
428
  }
426
429
  }
427
- // 保守策略:即使不是 OUTFILE/DUMPFILE 也拒绝 INTO
428
- // 因为 INTO 还能做变量赋值,可能间接导致写操作
429
- throw new ReadonlyError('不允许 INTO(覆盖 OUTFILE/DUMPFILE/变量赋值)');
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('不允许 FOR UPDATE(排他锁定)');
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('不允许 LOCK IN SHARE MODE(共享锁定)');
443
+ throw new ReadonlyError('LOCK IN SHARE MODE is not allowed (shared lock)');
441
444
  }
442
445
  }
443
446
 
444
- // 禁止关键字(在 SELECT/WITH 内)
447
+ // Forbidden keywords (inside SELECT/WITH)
445
448
  if (FORBIDDEN_WORDS.has(upper)) {
446
- throw new ReadonlyError(`不允许在只读查询中使用: ${upper}`);
449
+ throw new ReadonlyError(`Not allowed in read-only queries: ${upper}`);
447
450
  }
448
451
  }
449
452
  }
450
453
 
451
- /** 从 tokens[i] 之后取连续 depth=0 的 word token(跳过空白/非word) */
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
- * 校验 SQL 只包含一条语句,最多末尾一个分号
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
- // 分号必须在 depth=0(不在括号/子查询内)
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(`只允许单条语句,发现 ${semicolons} 个分号`);
486
+ throw new ReadonlyError(`Only a single statement is allowed; found ${semicolons} semicolons`);
484
487
  }
485
- // 如果有分号,必须在最后一个 token
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('分号只能在 SQL 末尾');
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
- * 分析 depth=0 层的 LIMIT 子句,严格只允许三种形式:
498
- * LIMIT <单个非负整数>
499
- * LIMIT <非负整数> OFFSET <非负整数>
500
- * LIMIT <非负整数>, <非负整数> (MySQL 旧式 offset,count)
501
- * 任何表达式、变量、运算符 → 拒绝(防绕过 maxRows)
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
- // 定位 depth=0 的最后一个 LIMIT 关键字(子查询内的 LIMIT 在 depth>0,自然排除)
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
- // 遇到 SELECT/UNION 等顶层关键字说明有新的子查询段
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
- // 收集 LIMIT 之后的 depth=0 token(到语句结尾或下一个顶层关键字)
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
- // 遇到 UNION 等顶层关键字说明 LIMIT 属于前一个段,停止收集
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
- // 解析 LIMIT 后的 token 序列,只允许三种形式
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
- * 解析 LIMIT 子句 token 序列,严格校验形式和 count ≤ maxRows
541
- * @param {Array} limitTokens - LIMIT 关键字之后、depth=0 的 token
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
- // 过滤空白,只保留有意义的 token
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
- // 必须以 number 开头
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
- // LIMIT 后的任何 symbol token(, 除外)都说明是表达式
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 不允许运算符 ${tok.value}(必须是纯整数)`);
567
+ throw new ReadonlyError(`LIMIT does not allow operator ${tok.value} (plain integers only)`);
562
568
  }
563
569
  }
564
570
 
565
- // ── 形式 1:LIMIT <count>(单个整数)──
571
+ // ── Form 1: LIMIT <count> (single integer) ──
566
572
  if (meaningful.length === 1) {
567
- if (firstVal > maxRows) throw new ReadonlyError(`LIMIT ${firstVal} 超过最大行数 ${maxRows}`);
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
- // ── 形式 2:LIMIT <offset>, <count>(逗号分隔)──
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} 超过最大行数 ${maxRows}`);
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
- // ── 形式 3:LIMIT <count> OFFSET <offset>(关键字分隔)──
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 是 count,offset 不影响返回行数
586
- if (firstVal > maxRows) throw new ReadonlyError(`LIMIT ${firstVal} 超过最大行数 ${maxRows}`);
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 只允许:纯整数 / 整数,整数 / 整数 OFFSET 整数');
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
- * 对 SQL 做完整只读校验,返回处理后的 SQL 和元信息
598
- * 任何歧义都 throw,绝不放过
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 - 原始 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
- // 修复:SHOW/DESCRIBE 是元数据查询,其文本里合法包含 CREATE/PROCEDURE 等词
613
- // (如 SHOW CREATE TABLE),若走 checkForbiddenPatterns 会被禁止词表误杀。
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 处理 LIMIT
630
+ // LIMIT handling for SELECT / WITH_SELECT / EXPLAIN
623
631
  const limitInfo = analyzeTopLevelLimit(tokens, maxRows);
624
632
 
625
633
  if (limitInfo.hasLimit) {
626
- // analyzeTopLevelLimit 已校验 count ≤ maxRows,这里直接返回
634
+ // analyzeTopLevelLimit already verified count ≤ maxRows; return directly
627
635
  return { sql: sql.trimEnd(), kind, limitInjected: false };
628
636
  }
629
637
 
630
- // 无 LIMIT → 注入截断哨兵
631
- // 去掉尾部分号后追加 LIMIT (maxRows+1),哨兵用于判断是否有截断
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) {