dsh-switchman 1.3.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/LICENSE +21 -0
- package/README.md +79 -0
- package/README.zh.md +79 -0
- package/client.js +2441 -0
- package/cordis.patch.yml +599 -0
- package/host/config.js +116 -0
- package/host/context-watch.js +620 -0
- package/host/data/capability-default.json +1097 -0
- package/host/dispatch.js +205 -0
- package/host/lang.js +182 -0
- package/host/lib/rank.js +187 -0
- package/host/routes.js +398 -0
- package/icon.svg +7 -0
- package/index.js +154 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +70 -0
- package/skills/db-query/SKILL.md +322 -0
- package/skills/db-query/package-lock.json +251 -0
- package/skills/db-query/package.json +20 -0
- package/skills/db-query/references/mysql.md +137 -0
- package/skills/db-query/references/redis.md +213 -0
- package/skills/db-query/references/security.md +176 -0
- package/skills/db-query/scripts/lib/cli.js +118 -0
- package/skills/db-query/scripts/lib/config.js +215 -0
- package/skills/db-query/scripts/lib/mysql-readonly.js +655 -0
- package/skills/db-query/scripts/lib/output.js +322 -0
- package/skills/db-query/scripts/lib/redis-readonly.js +362 -0
- package/skills/db-query/scripts/mysql-query.js +198 -0
- package/skills/db-query/scripts/redis-query.js +181 -0
- package/skills/db-query/scripts/setup.sh +51 -0
- package/skills/git-commit-message/SKILL.md +49 -0
- package/skills/requirement-docs/SKILL.md +38 -0
- package/skills/requirement-docs/references/templates.md +187 -0
|
@@ -0,0 +1,655 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* MySQL 只读校验:有限状态词法扫描器 + 语句分类 + LIMIT 注入
|
|
5
|
+
* 安全核心:任何歧义都拒绝(fail-closed),宁可误杀不可漏过
|
|
6
|
+
*
|
|
7
|
+
* 设计要点:
|
|
8
|
+
* - 状态机正确处理引号内分号/关键字/ doubled quote
|
|
9
|
+
* - 拒绝反斜杠转义(NO_BACKSLASH_ESCAPES 歧义)
|
|
10
|
+
* - 拒绝所有注释(堵住关键字拆分绕过)
|
|
11
|
+
* - 只允许单条语句,最多末尾一个分号
|
|
12
|
+
* - 拒绝 NUL 字符、未闭合引号、括号不平衡、超长 SQL
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
const MAX_SQL_LENGTH = 64 * 1024; // 64KiB,防超大 payload
|
|
16
|
+
|
|
17
|
+
// ── 词法扫描器 ──
|
|
18
|
+
|
|
19
|
+
/** 词法状态枚举 */
|
|
20
|
+
const STATE = {
|
|
21
|
+
NORMAL: 0,
|
|
22
|
+
SINGLE_QUOTE: 1, // '...'
|
|
23
|
+
DOUBLE_QUOTE: 2, // "..."
|
|
24
|
+
BACKTICK: 3, // `...`
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* 对 SQL 做有限状态词法扫描,返回 token 数组
|
|
29
|
+
* 每个_token: { type, value, depth }
|
|
30
|
+
* type: 'word' | 'quoted' | 'number' | 'symbol' | 'semicolon' | 'whitespace'
|
|
31
|
+
* depth: 括号嵌套深度(引号内也算外层的 depth)
|
|
32
|
+
*
|
|
33
|
+
* @param {string} sql
|
|
34
|
+
* @returns {Array<{type: string, value: string, depth: number}>}
|
|
35
|
+
*/
|
|
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})`);
|
|
40
|
+
|
|
41
|
+
// NUL 字节可能导致底层 C 层截断,一律拒绝
|
|
42
|
+
if (sql.includes('\0')) throw new ReadonlyError('SQL 含 NUL 字符');
|
|
43
|
+
|
|
44
|
+
const tokens = [];
|
|
45
|
+
let state = STATE.NORMAL;
|
|
46
|
+
let parenDepth = 0; // 当前括号深度
|
|
47
|
+
let i = 0;
|
|
48
|
+
let wordBuf = ''; // 累积未引用标识符
|
|
49
|
+
let numBuf = ''; // 累积数字
|
|
50
|
+
|
|
51
|
+
const emit = (type, value, d) => {
|
|
52
|
+
tokens.push({ type, value, depth: d });
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
const flushWord = (d) => {
|
|
56
|
+
if (wordBuf.length > 0) {
|
|
57
|
+
emit('word', wordBuf, d);
|
|
58
|
+
wordBuf = '';
|
|
59
|
+
}
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
const flushNum = (d) => {
|
|
63
|
+
if (numBuf.length > 0) {
|
|
64
|
+
emit('number', numBuf, d);
|
|
65
|
+
numBuf = '';
|
|
66
|
+
}
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
while (i < sql.length) {
|
|
70
|
+
const ch = sql[i];
|
|
71
|
+
|
|
72
|
+
if (state === STATE.NORMAL) {
|
|
73
|
+
// ── 注释检测(注释非查询核验必需,保守拒绝堵住关键字拆分绕过)──
|
|
74
|
+
if (ch === '#') {
|
|
75
|
+
throw new ReadonlyError('不允许 # 注释(防止关键字拆分绕过)');
|
|
76
|
+
}
|
|
77
|
+
if (ch === '-' && i + 1 < sql.length && sql[i + 1] === '-') {
|
|
78
|
+
// -- 注释:合法形式是 `-- `(双横线+空格),但不管空格与否一律拒绝
|
|
79
|
+
throw new ReadonlyError('不允许 -- 注释(防止关键字拆分绕过)');
|
|
80
|
+
}
|
|
81
|
+
if (ch === '/' && i + 1 < sql.length && sql[i + 1] === '*') {
|
|
82
|
+
const nextCh = i + 2 < sql.length ? sql[i + 2] : '';
|
|
83
|
+
if (nextCh === '!' || nextCh === '+') {
|
|
84
|
+
throw new ReadonlyError(`不允许 /*${nextCh} ${nextCh === '!' ? 'version comment' : 'optimizer hint'}(可能含可执行代码)`);
|
|
85
|
+
}
|
|
86
|
+
throw new ReadonlyError('不允许 /* */ 块注释(防止关键字拆分绕过)');
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// ── 引号状态切换 ──
|
|
90
|
+
if (ch === "'") {
|
|
91
|
+
flushWord(parenDepth);
|
|
92
|
+
flushNum(parenDepth);
|
|
93
|
+
state = STATE.SINGLE_QUOTE;
|
|
94
|
+
i++;
|
|
95
|
+
continue;
|
|
96
|
+
}
|
|
97
|
+
if (ch === '"') {
|
|
98
|
+
flushWord(parenDepth);
|
|
99
|
+
flushNum(parenDepth);
|
|
100
|
+
state = STATE.DOUBLE_QUOTE;
|
|
101
|
+
i++;
|
|
102
|
+
continue;
|
|
103
|
+
}
|
|
104
|
+
if (ch === '`') {
|
|
105
|
+
flushWord(parenDepth);
|
|
106
|
+
flushNum(parenDepth);
|
|
107
|
+
state = STATE.BACKTICK;
|
|
108
|
+
i++;
|
|
109
|
+
continue;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// ── 括号深度追踪 ──
|
|
113
|
+
if (ch === '(') {
|
|
114
|
+
flushWord(parenDepth);
|
|
115
|
+
flushNum(parenDepth);
|
|
116
|
+
parenDepth++;
|
|
117
|
+
emit('symbol', '(', parenDepth);
|
|
118
|
+
i++;
|
|
119
|
+
continue;
|
|
120
|
+
}
|
|
121
|
+
if (ch === ')') {
|
|
122
|
+
flushWord(parenDepth);
|
|
123
|
+
flushNum(parenDepth);
|
|
124
|
+
if (parenDepth <= 0) throw new ReadonlyError('括号不匹配:多余的 )');
|
|
125
|
+
emit('symbol', ')', parenDepth);
|
|
126
|
+
parenDepth--;
|
|
127
|
+
i++;
|
|
128
|
+
continue;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// ── 分号 ──
|
|
132
|
+
if (ch === ';') {
|
|
133
|
+
flushWord(parenDepth);
|
|
134
|
+
flushNum(parenDepth);
|
|
135
|
+
emit('semicolon', ';', parenDepth);
|
|
136
|
+
i++;
|
|
137
|
+
continue;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// ── 标点/符号 ──
|
|
141
|
+
if (isSymbolChar(ch)) {
|
|
142
|
+
// 检测 := 赋值运算符
|
|
143
|
+
if (ch === ':' && i + 1 < sql.length && sql[i + 1] === '=') {
|
|
144
|
+
throw new ReadonlyError('不允许 := 赋值运算符');
|
|
145
|
+
}
|
|
146
|
+
flushWord(parenDepth);
|
|
147
|
+
flushNum(parenDepth);
|
|
148
|
+
emit('symbol', ch, parenDepth);
|
|
149
|
+
i++;
|
|
150
|
+
continue;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// ── 空白 ──
|
|
154
|
+
if (/\s/.test(ch)) {
|
|
155
|
+
flushWord(parenDepth);
|
|
156
|
+
flushNum(parenDepth);
|
|
157
|
+
i++;
|
|
158
|
+
continue;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// ── 数字 ──
|
|
162
|
+
if (/\d/.test(ch)) {
|
|
163
|
+
flushWord(parenDepth);
|
|
164
|
+
numBuf += ch;
|
|
165
|
+
i++;
|
|
166
|
+
continue;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// ── 标识符/关键字(字母、下划线、$)──
|
|
170
|
+
if (/[a-zA-Z_$]/.test(ch)) {
|
|
171
|
+
flushNum(parenDepth);
|
|
172
|
+
wordBuf += ch;
|
|
173
|
+
i++;
|
|
174
|
+
continue;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// ── 不明字符 ──
|
|
178
|
+
throw new ReadonlyError(`SQL 含无法识别的字符: ${JSON.stringify(ch)} (位置 ${i})`);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// ── 单引号状态 ──
|
|
182
|
+
if (state === STATE.SINGLE_QUOTE) {
|
|
183
|
+
if (ch === '\\') {
|
|
184
|
+
// 反斜杠转义在 NO_BACKSLASH_ESCAPES 模式下语义不同,
|
|
185
|
+
// 且可能用于拆分关键字绕过检测,一律拒绝
|
|
186
|
+
throw new ReadonlyError('不允许反斜杠转义,请改用标准双引号转义(\'\')');
|
|
187
|
+
}
|
|
188
|
+
if (ch === "'" && i + 1 < sql.length && sql[i + 1] === "'") {
|
|
189
|
+
// 标准双引号转义:'' 转义为单引号
|
|
190
|
+
i += 2;
|
|
191
|
+
continue;
|
|
192
|
+
}
|
|
193
|
+
if (ch === "'") {
|
|
194
|
+
// 引号闭合
|
|
195
|
+
emit('quoted', "'", parenDepth);
|
|
196
|
+
state = STATE.NORMAL;
|
|
197
|
+
i++;
|
|
198
|
+
continue;
|
|
199
|
+
}
|
|
200
|
+
// 引号内内容不逐字符记录,只保留引号边界 token
|
|
201
|
+
i++;
|
|
202
|
+
continue;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// ── 双引号状态 ──
|
|
206
|
+
if (state === STATE.DOUBLE_QUOTE) {
|
|
207
|
+
if (ch === '\\') {
|
|
208
|
+
throw new ReadonlyError('不允许反斜杠转义,请改用标准双引号转义("")');
|
|
209
|
+
}
|
|
210
|
+
if (ch === '"' && i + 1 < sql.length && sql[i + 1] === '"') {
|
|
211
|
+
i += 2;
|
|
212
|
+
continue;
|
|
213
|
+
}
|
|
214
|
+
if (ch === '"') {
|
|
215
|
+
emit('quoted', '"', parenDepth);
|
|
216
|
+
state = STATE.NORMAL;
|
|
217
|
+
i++;
|
|
218
|
+
continue;
|
|
219
|
+
}
|
|
220
|
+
i++;
|
|
221
|
+
continue;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// ── 反引号状态 ──
|
|
225
|
+
if (state === STATE.BACKTICK) {
|
|
226
|
+
if (ch === '`' && i + 1 < sql.length && sql[i + 1] === '`') {
|
|
227
|
+
i += 2;
|
|
228
|
+
continue;
|
|
229
|
+
}
|
|
230
|
+
if (ch === '`') {
|
|
231
|
+
emit('quoted', '`', parenDepth);
|
|
232
|
+
state = STATE.NORMAL;
|
|
233
|
+
i++;
|
|
234
|
+
continue;
|
|
235
|
+
}
|
|
236
|
+
i++;
|
|
237
|
+
continue;
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// ── 扫描结束检查 ──
|
|
242
|
+
flushWord(parenDepth);
|
|
243
|
+
flushNum(parenDepth);
|
|
244
|
+
|
|
245
|
+
if (state !== STATE.NORMAL) {
|
|
246
|
+
const stateName = state === STATE.SINGLE_QUOTE ? '单引号' : state === STATE.DOUBLE_QUOTE ? '双引号' : '反引号';
|
|
247
|
+
throw new ReadonlyError(`未闭合的${stateName}`);
|
|
248
|
+
}
|
|
249
|
+
if (parenDepth !== 0) {
|
|
250
|
+
throw new ReadonlyError(`括号不匹配:缺少 ${parenDepth} 个 )`);
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
return tokens;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/** 判断是否为 SQL 标点符号字符(非字母、非数字、非空白、非引号、非分号、非括号) */
|
|
257
|
+
function isSymbolChar(ch) {
|
|
258
|
+
return /[+\-*/%=<>!&|^~,.\:@]/.test(ch);
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// ── 语句分类 ──
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* 根据 token 判断根语句类型
|
|
265
|
+
* 只允许 SELECT / WITH...SELECT / SHOW / DESCRIBE / EXPLAIN
|
|
266
|
+
* @param {Array} tokens - tokenizeMysql 返回值
|
|
267
|
+
* @returns {string} 'SELECT' | 'WITH_SELECT' | 'SHOW' | 'DESCRIBE' | 'EXPLAIN'
|
|
268
|
+
*/
|
|
269
|
+
function classifyMysqlStatement(tokens) {
|
|
270
|
+
if (tokens.length === 0) throw new ReadonlyError('无有效 token');
|
|
271
|
+
|
|
272
|
+
// 找到 depth=0 的首个 word token(跳过前导空白/注释已被拒绝)
|
|
273
|
+
let firstWord = null;
|
|
274
|
+
let firstWordIdx = -1;
|
|
275
|
+
for (let i = 0; i < tokens.length; i++) {
|
|
276
|
+
if (tokens[i].type === 'word' && tokens[i].depth === 0) {
|
|
277
|
+
firstWord = tokens[i].value.toUpperCase();
|
|
278
|
+
firstWordIdx = i;
|
|
279
|
+
break;
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
if (!firstWord) throw new ReadonlyError('未找到根语句关键字');
|
|
284
|
+
|
|
285
|
+
switch (firstWord) {
|
|
286
|
+
case 'SELECT':
|
|
287
|
+
return 'SELECT';
|
|
288
|
+
|
|
289
|
+
case 'WITH': {
|
|
290
|
+
// CTE:WITH 后的根查询必须是 SELECT(拒绝 WITH ... DELETE/UPDATE)
|
|
291
|
+
// 策略:找 depth=0 上出现过的所有「语句类关键字」,第一个必须是 SELECT
|
|
292
|
+
const STATEMENT_KW = new Set([
|
|
293
|
+
'SELECT', 'INSERT', 'UPDATE', 'DELETE', 'REPLACE', 'CALL', 'DO',
|
|
294
|
+
]);
|
|
295
|
+
let foundSelect = false;
|
|
296
|
+
for (let i = firstWordIdx + 1; i < tokens.length; i++) {
|
|
297
|
+
const tok = tokens[i];
|
|
298
|
+
if (tok.type !== 'word' || tok.depth !== 0) continue;
|
|
299
|
+
const w = tok.value.toUpperCase();
|
|
300
|
+
if (STATEMENT_KW.has(w)) {
|
|
301
|
+
if (w === 'SELECT') {
|
|
302
|
+
foundSelect = true;
|
|
303
|
+
} else {
|
|
304
|
+
throw new ReadonlyError(`WITH CTE 后的根语句必须是 SELECT,发现: ${w}`);
|
|
305
|
+
}
|
|
306
|
+
break;
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
if (!foundSelect) {
|
|
310
|
+
throw new ReadonlyError('WITH 后未找到根查询 SELECT 语句');
|
|
311
|
+
}
|
|
312
|
+
return 'WITH_SELECT';
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
case 'SHOW':
|
|
316
|
+
return 'SHOW';
|
|
317
|
+
|
|
318
|
+
case 'DESCRIBE':
|
|
319
|
+
case 'DESC':
|
|
320
|
+
return 'DESCRIBE';
|
|
321
|
+
|
|
322
|
+
case 'EXPLAIN': {
|
|
323
|
+
// 只允许 EXPLAIN [FORMAT=...] SELECT/WITH
|
|
324
|
+
// 拒绝 EXPLAIN ANALYZE(会实际执行语句并产生写入/锁)、EXPLAIN DML
|
|
325
|
+
// 修复:旧实现只看 EXPLAIN 后第一个 word,遇到 FORMAT 就误判为非法,
|
|
326
|
+
// 导致合法的 EXPLAIN FORMAT=JSON SELECT 被拒。这里收集后续所有 word 再判定。
|
|
327
|
+
const wordsAfterExplain = [];
|
|
328
|
+
for (let i = firstWordIdx + 1; i < tokens.length; i++) {
|
|
329
|
+
if (tokens[i].type === 'word' && tokens[i].depth === 0) {
|
|
330
|
+
wordsAfterExplain.push(tokens[i].value.toUpperCase());
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
if (wordsAfterExplain.length === 0) {
|
|
334
|
+
// EXPLAIN 后没有 SELECT/WITH(如旧式 EXPLAIN tbl_name),不支持
|
|
335
|
+
throw new ReadonlyError('EXPLAIN 后必须跟 SELECT 或 WITH');
|
|
336
|
+
}
|
|
337
|
+
const explainFirst = wordsAfterExplain[0];
|
|
338
|
+
if (explainFirst === 'ANALYZE') {
|
|
339
|
+
throw new ReadonlyError('不允许 EXPLAIN ANALYZE(会实际执行语句)');
|
|
340
|
+
}
|
|
341
|
+
if (explainFirst === 'FORMAT') {
|
|
342
|
+
// EXPLAIN FORMAT=JSON/TRADITIONAL/TREE/WIDTH SELECT ...
|
|
343
|
+
// (= 是非 word token,已被跳过;wordsAfterExplain 形如 [FORMAT, JSON, SELECT, ...])
|
|
344
|
+
const EXPLAIN_FORMATS = new Set(['JSON', 'TRADITIONAL', 'TREE', 'WIDTH']);
|
|
345
|
+
if (wordsAfterExplain.length < 2) {
|
|
346
|
+
throw new ReadonlyError('EXPLAIN FORMAT= 需要格式名(JSON/TRADITIONAL/TREE/WIDTH)');
|
|
347
|
+
}
|
|
348
|
+
const fmtName = wordsAfterExplain[1];
|
|
349
|
+
if (!EXPLAIN_FORMATS.has(fmtName)) {
|
|
350
|
+
throw new ReadonlyError(`EXPLAIN FORMAT= 不支持的格式名: ${fmtName}`);
|
|
351
|
+
}
|
|
352
|
+
// 格式名之后必须紧跟 SELECT/WITH
|
|
353
|
+
let stmtWord = null;
|
|
354
|
+
for (let k = 2; k < wordsAfterExplain.length; k++) {
|
|
355
|
+
if (wordsAfterExplain[k] === 'SELECT' || wordsAfterExplain[k] === 'WITH') {
|
|
356
|
+
stmtWord = wordsAfterExplain[k];
|
|
357
|
+
break;
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
if (!stmtWord) {
|
|
361
|
+
throw new ReadonlyError('EXPLAIN FORMAT=... 后必须是 SELECT 或 WITH');
|
|
362
|
+
}
|
|
363
|
+
return 'EXPLAIN';
|
|
364
|
+
}
|
|
365
|
+
if (explainFirst !== 'SELECT' && explainFirst !== 'WITH') {
|
|
366
|
+
throw new ReadonlyError(`不允许 EXPLAIN ${explainFirst}(只允许 EXPLAIN [FORMAT=...] SELECT/WITH)`);
|
|
367
|
+
}
|
|
368
|
+
return 'EXPLAIN';
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
default:
|
|
372
|
+
throw new ReadonlyError(`不允许的语句类型: ${firstWord}`);
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
// ── 危险关键字/模式检测 ──
|
|
377
|
+
|
|
378
|
+
/** 非引用 word token 中遇到即拒绝的关键字 */
|
|
379
|
+
const FORBIDDEN_WORDS = new Set([
|
|
380
|
+
'INSERT', 'UPDATE', 'DELETE', 'REPLACE', 'DROP', 'ALTER', 'CREATE',
|
|
381
|
+
'TRUNCATE', 'RENAME', 'GRANT', 'REVOKE', 'CALL', 'DO', 'LOAD',
|
|
382
|
+
'INSTALL', 'UNINSTALL', 'LOCK', 'UNLOCK', 'SET', 'RESET', 'PURGE',
|
|
383
|
+
'KILL', 'OPTIMIZE', 'REPAIR', 'ANALYZE', 'CHECK', 'USE',
|
|
384
|
+
'BEGIN', 'START', 'COMMIT', 'ROLLBACK', 'SAVEPOINT', 'XA',
|
|
385
|
+
'PREPARE', 'EXECUTE', 'DEALLOCATE',
|
|
386
|
+
]);
|
|
387
|
+
|
|
388
|
+
/** 需要特殊检测的模式关键字 */
|
|
389
|
+
const INTO_FORBIDDEN = 'INTO';
|
|
390
|
+
const FOR_UPDATE_PATTERN = ['FOR', 'UPDATE'];
|
|
391
|
+
const LOCK_SHARE_PATTERN = ['LOCK', 'IN', 'SHARE', 'MODE'];
|
|
392
|
+
|
|
393
|
+
/** 危险函数名 */
|
|
394
|
+
const DANGEROUS_FUNCTIONS = new Set([
|
|
395
|
+
'SLEEP', 'BENCHMARK', 'GET_LOCK', 'RELEASE_LOCK',
|
|
396
|
+
'IS_FREE_LOCK', 'IS_USED_LOCK', 'LOAD_FILE', 'MASTER_POS_WAIT',
|
|
397
|
+
]);
|
|
398
|
+
|
|
399
|
+
/**
|
|
400
|
+
* 检查 token 流中是否包含禁止的关键字/模式
|
|
401
|
+
* SELECT/WITH 内出现任何写操作关键字都拒绝
|
|
402
|
+
*/
|
|
403
|
+
function checkForbiddenPatterns(tokens) {
|
|
404
|
+
for (let i = 0; i < tokens.length; i++) {
|
|
405
|
+
const tok = tokens[i];
|
|
406
|
+
if (tok.type !== 'word') continue;
|
|
407
|
+
|
|
408
|
+
const upper = tok.value.toUpperCase();
|
|
409
|
+
|
|
410
|
+
// 危险函数检测(即使在子查询中也拒绝)
|
|
411
|
+
if (DANGEROUS_FUNCTIONS.has(upper)) {
|
|
412
|
+
throw new ReadonlyError(`不允许危险函数: ${upper}`);
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
// INTO 可以出现在各种合法位置(SELECT INTO 不常见但存在),
|
|
416
|
+
// 但 SELECT INTO OUTFILE/DUMPFILE 是写文件操作,风险太高一律拒绝
|
|
417
|
+
if (upper === INTO_FORBIDDEN) {
|
|
418
|
+
// 检查后续是否紧跟 OUTFILE/DUMPFILE 或变量赋值模式
|
|
419
|
+
for (let j = i + 1; j < tokens.length && j < i + 4; j++) {
|
|
420
|
+
const next = tokens[j];
|
|
421
|
+
if (next.type !== 'word') continue;
|
|
422
|
+
const nextUpper = next.value.toUpperCase();
|
|
423
|
+
if (nextUpper === 'OUTFILE' || nextUpper === 'DUMPFILE' || nextUpper === '@') {
|
|
424
|
+
throw new ReadonlyError(`不允许 INTO ${nextUpper}(数据写出风险)`);
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
// 保守策略:即使不是 OUTFILE/DUMPFILE 也拒绝 INTO
|
|
428
|
+
// 因为 INTO 还能做变量赋值,可能间接导致写操作
|
|
429
|
+
throw new ReadonlyError('不允许 INTO(覆盖 OUTFILE/DUMPFILE/变量赋值)');
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
// FOR UPDATE 锁定检测
|
|
433
|
+
if (upper === 'FOR' && tok.depth === 0) {
|
|
434
|
+
const subsequent = getSubsequentRootWords(tokens, i);
|
|
435
|
+
if (subsequent.length >= 1 && subsequent[0] === 'UPDATE') {
|
|
436
|
+
throw new ReadonlyError('不允许 FOR UPDATE(排他锁定)');
|
|
437
|
+
}
|
|
438
|
+
if (subsequent.length >= 3 &&
|
|
439
|
+
subsequent[0] === 'LOCK' && subsequent[1] === 'IN' && subsequent[2] === 'SHARE' && subsequent[3] === 'MODE') {
|
|
440
|
+
throw new ReadonlyError('不允许 LOCK IN SHARE MODE(共享锁定)');
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
// 禁止关键字(在 SELECT/WITH 内)
|
|
445
|
+
if (FORBIDDEN_WORDS.has(upper)) {
|
|
446
|
+
throw new ReadonlyError(`不允许在只读查询中使用: ${upper}`);
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
/** 从 tokens[i] 之后取连续 depth=0 的 word token(跳过空白/非word) */
|
|
452
|
+
function getSubsequentRootWords(tokens, startIdx) {
|
|
453
|
+
const result = [];
|
|
454
|
+
for (let j = startIdx + 1; j < tokens.length; j++) {
|
|
455
|
+
const tok = tokens[j];
|
|
456
|
+
if (tok.type === 'whitespace' || tok.type === 'symbol') continue;
|
|
457
|
+
if (tok.type === 'word' && tok.depth === 0) {
|
|
458
|
+
result.push(tok.value.toUpperCase());
|
|
459
|
+
}
|
|
460
|
+
if (result.length >= 4) break; // 只需检查前几个
|
|
461
|
+
}
|
|
462
|
+
return result;
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
// ── 单语句校验 ──
|
|
466
|
+
|
|
467
|
+
/**
|
|
468
|
+
* 校验 SQL 只包含一条语句,最多末尾一个分号
|
|
469
|
+
* @param {Array} tokens
|
|
470
|
+
*/
|
|
471
|
+
function checkSingleStatement(tokens) {
|
|
472
|
+
let semicolons = 0;
|
|
473
|
+
for (const tok of tokens) {
|
|
474
|
+
if (tok.type === 'semicolon') {
|
|
475
|
+
semicolons++;
|
|
476
|
+
// 分号必须在 depth=0(不在括号/子查询内)
|
|
477
|
+
if (tok.depth !== 0) {
|
|
478
|
+
throw new ReadonlyError('语句中括号内不允许分号');
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
if (semicolons > 1) {
|
|
483
|
+
throw new ReadonlyError(`只允许单条语句,发现 ${semicolons} 个分号`);
|
|
484
|
+
}
|
|
485
|
+
// 如果有分号,必须在最后一个 token
|
|
486
|
+
if (semicolons === 1) {
|
|
487
|
+
const last = tokens[tokens.length - 1];
|
|
488
|
+
if (last.type !== 'semicolon') {
|
|
489
|
+
throw new ReadonlyError('分号只能在 SQL 末尾');
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
// ── LIMIT 处理 ──
|
|
495
|
+
|
|
496
|
+
/**
|
|
497
|
+
* 分析 depth=0 层的 LIMIT 子句,严格只允许三种形式:
|
|
498
|
+
* LIMIT <单个非负整数>
|
|
499
|
+
* LIMIT <非负整数> OFFSET <非负整数>
|
|
500
|
+
* LIMIT <非负整数>, <非负整数> (MySQL 旧式 offset,count)
|
|
501
|
+
* 任何表达式、变量、运算符 → 拒绝(防绕过 maxRows)
|
|
502
|
+
*
|
|
503
|
+
* @param {Array} tokens - tokenizeMysql 返回值
|
|
504
|
+
* @param {number} maxRows - 允许的最大行数
|
|
505
|
+
* @returns {{ hasLimit: boolean, limitCount: number }}
|
|
506
|
+
*/
|
|
507
|
+
function analyzeTopLevelLimit(tokens, maxRows) {
|
|
508
|
+
// 定位 depth=0 的最后一个 LIMIT 关键字(子查询内的 LIMIT 在 depth>0,自然排除)
|
|
509
|
+
let lastLimitIdx = -1;
|
|
510
|
+
let foundSelect = false;
|
|
511
|
+
for (let i = 0; i < tokens.length; i++) {
|
|
512
|
+
const tok = tokens[i];
|
|
513
|
+
if (tok.depth > 0) continue;
|
|
514
|
+
if (tok.type === 'word') {
|
|
515
|
+
const upper = tok.value.toUpperCase();
|
|
516
|
+
// 遇到 SELECT/UNION 等顶层关键字说明有新的子查询段
|
|
517
|
+
if (upper === 'SELECT' || upper === 'UNION') foundSelect = true;
|
|
518
|
+
}
|
|
519
|
+
if (tok.type === 'word' && tok.value.toUpperCase() === 'LIMIT' && tok.depth === 0 && foundSelect) {
|
|
520
|
+
lastLimitIdx = i;
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
if (lastLimitIdx === -1) return { hasLimit: false };
|
|
524
|
+
|
|
525
|
+
// 收集 LIMIT 之后的 depth=0 token(到语句结尾或下一个顶层关键字)
|
|
526
|
+
const limitTokens = [];
|
|
527
|
+
for (let i = lastLimitIdx + 1; i < tokens.length; i++) {
|
|
528
|
+
const tok = tokens[i];
|
|
529
|
+
if (tok.depth > 0) continue;
|
|
530
|
+
// 遇到 UNION 等顶层关键字说明 LIMIT 属于前一个段,停止收集
|
|
531
|
+
if (tok.type === 'word' && tok.value.toUpperCase() === 'UNION') break;
|
|
532
|
+
limitTokens.push(tok);
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
// 解析 LIMIT 后的 token 序列,只允许三种形式
|
|
536
|
+
return parseLimitClause(limitTokens, maxRows);
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
/**
|
|
540
|
+
* 解析 LIMIT 子句 token 序列,严格校验形式和 count ≤ maxRows
|
|
541
|
+
* @param {Array} limitTokens - LIMIT 关键字之后、depth=0 的 token
|
|
542
|
+
* @param {number} maxRows
|
|
543
|
+
* @returns {{ hasLimit: boolean, limitCount: number }}
|
|
544
|
+
*/
|
|
545
|
+
function parseLimitClause(limitTokens, maxRows) {
|
|
546
|
+
// 过滤空白,只保留有意义的 token
|
|
547
|
+
const meaningful = limitTokens.filter(t => t.type !== 'whitespace');
|
|
548
|
+
|
|
549
|
+
if (meaningful.length === 0) throw new ReadonlyError('LIMIT 后缺少参数');
|
|
550
|
+
|
|
551
|
+
// 必须以 number 开头
|
|
552
|
+
const first = meaningful[0];
|
|
553
|
+
if (first.type !== 'number') throw new ReadonlyError('LIMIT 参数必须是纯整数(不接受表达式或变量)');
|
|
554
|
+
const firstVal = Number(first.value);
|
|
555
|
+
if (!Number.isInteger(firstVal) || firstVal < 0) throw new ReadonlyError('LIMIT 必须是非负整数');
|
|
556
|
+
|
|
557
|
+
// ── 检查后续是否含运算符/占位符(表达式绕过)──
|
|
558
|
+
// LIMIT 后的任何 symbol token(, 除外)都说明是表达式
|
|
559
|
+
for (const tok of meaningful) {
|
|
560
|
+
if (tok.type === 'symbol' && tok.value !== ',') {
|
|
561
|
+
throw new ReadonlyError(`LIMIT 不允许运算符 ${tok.value}(必须是纯整数)`);
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
// ── 形式 1:LIMIT <count>(单个整数)──
|
|
566
|
+
if (meaningful.length === 1) {
|
|
567
|
+
if (firstVal > maxRows) throw new ReadonlyError(`LIMIT ${firstVal} 超过最大行数 ${maxRows}`);
|
|
568
|
+
return { hasLimit: true, limitCount: firstVal };
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
// ── 形式 2:LIMIT <offset>, <count>(逗号分隔)──
|
|
572
|
+
if (meaningful.length === 3 && meaningful[1].type === 'symbol' && meaningful[1].value === ',') {
|
|
573
|
+
if (meaningful[2].type !== 'number') throw new ReadonlyError('LIMIT 偏移量/行数必须是纯整数');
|
|
574
|
+
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}`);
|
|
577
|
+
return { hasLimit: true, limitCount: countVal };
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
// ── 形式 3:LIMIT <count> OFFSET <offset>(关键字分隔)──
|
|
581
|
+
if (meaningful.length === 3 && meaningful[1].type === 'word' && meaningful[1].value.toUpperCase() === 'OFFSET') {
|
|
582
|
+
if (meaningful[2].type !== 'number') throw new ReadonlyError('LIMIT OFFSET 值必须是纯整数');
|
|
583
|
+
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}`);
|
|
587
|
+
return { hasLimit: true, limitCount: firstVal };
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
// 不符合以上任何形式 → 拒绝
|
|
591
|
+
throw new ReadonlyError('LIMIT 只允许:纯整数 / 整数,整数 / 整数 OFFSET 整数');
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
// ── 主校验入口 ──
|
|
595
|
+
|
|
596
|
+
/**
|
|
597
|
+
* 对 SQL 做完整只读校验,返回处理后的 SQL 和元信息
|
|
598
|
+
* 任何歧义都 throw,绝不放过
|
|
599
|
+
*
|
|
600
|
+
* @param {string} sql - 原始 SQL
|
|
601
|
+
* @param {object} opts - { maxRows: number }
|
|
602
|
+
* @returns {{ sql: string, kind: string, limitInjected: boolean }}
|
|
603
|
+
*/
|
|
604
|
+
function validateMysqlSql(sql, { maxRows } = {}) {
|
|
605
|
+
if (!maxRows || maxRows < 1) maxRows = 200;
|
|
606
|
+
|
|
607
|
+
const tokens = tokenizeMysql(sql);
|
|
608
|
+
checkSingleStatement(tokens);
|
|
609
|
+
|
|
610
|
+
const kind = classifyMysqlStatement(tokens);
|
|
611
|
+
|
|
612
|
+
// 修复:SHOW/DESCRIBE 是元数据查询,其文本里合法包含 CREATE/PROCEDURE 等词
|
|
613
|
+
// (如 SHOW CREATE TABLE),若走 checkForbiddenPatterns 会被禁止词表误杀。
|
|
614
|
+
// 这两类语句全部为只读元数据查询,安全,提前返回跳过禁止词检查。
|
|
615
|
+
if (kind === 'SHOW' || kind === 'DESCRIBE') {
|
|
616
|
+
return { sql: sql.trimEnd(), kind, limitInjected: false };
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
// SELECT / WITH_SELECT / EXPLAIN 才做禁止关键字/危险函数检查
|
|
620
|
+
checkForbiddenPatterns(tokens);
|
|
621
|
+
|
|
622
|
+
// SELECT / WITH_SELECT / EXPLAIN 处理 LIMIT
|
|
623
|
+
const limitInfo = analyzeTopLevelLimit(tokens, maxRows);
|
|
624
|
+
|
|
625
|
+
if (limitInfo.hasLimit) {
|
|
626
|
+
// analyzeTopLevelLimit 已校验 count ≤ maxRows,这里直接返回
|
|
627
|
+
return { sql: sql.trimEnd(), kind, limitInjected: false };
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
// 无 LIMIT → 注入截断哨兵
|
|
631
|
+
// 去掉尾部分号后追加 LIMIT (maxRows+1),哨兵用于判断是否有截断
|
|
632
|
+
let boundedSql = sql.trimEnd();
|
|
633
|
+
if (boundedSql.endsWith(';')) {
|
|
634
|
+
boundedSql = boundedSql.slice(0, -1).trimEnd();
|
|
635
|
+
}
|
|
636
|
+
boundedSql = boundedSql + ` LIMIT ${maxRows + 1}`;
|
|
637
|
+
|
|
638
|
+
return { sql: boundedSql, kind, limitInjected: true };
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
// ── 自定义错误 ──
|
|
642
|
+
|
|
643
|
+
class ReadonlyError extends Error {
|
|
644
|
+
constructor(msg) {
|
|
645
|
+
super(msg);
|
|
646
|
+
this.name = 'ReadonlyError';
|
|
647
|
+
}
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
module.exports = {
|
|
651
|
+
tokenizeMysql,
|
|
652
|
+
classifyMysqlStatement,
|
|
653
|
+
validateMysqlSql,
|
|
654
|
+
ReadonlyError,
|
|
655
|
+
};
|