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,31 +1,31 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
|
-
* Redis
|
|
5
|
-
*
|
|
4
|
+
* Redis read-only command allowlist validation
|
|
5
|
+
* Unknown commands are always rejected (fail-closed); precise argument protection
|
|
6
6
|
*
|
|
7
|
-
*
|
|
8
|
-
* - Object.freeze
|
|
9
|
-
* - policy.shape
|
|
10
|
-
* - KEYS/HGETALL
|
|
11
|
-
* - SCAN
|
|
7
|
+
* Design notes:
|
|
8
|
+
* - Object.freeze'd allowlist, tamper-proof at runtime
|
|
9
|
+
* - policy.shape drives the output format
|
|
10
|
+
* - Unbounded commands like KEYS/HGETALL require an explicit --allow-unbounded
|
|
11
|
+
* - SCAN-family commands accept only structured arguments (MATCH/COUNT/TYPE)
|
|
12
12
|
*/
|
|
13
13
|
|
|
14
|
-
// ──
|
|
14
|
+
// ── Allowlist policy ──
|
|
15
15
|
|
|
16
16
|
/**
|
|
17
|
-
*
|
|
18
|
-
* - arity:
|
|
19
|
-
* - shape:
|
|
20
|
-
* - validate: (args) => void
|
|
21
|
-
* - unbounded: true
|
|
17
|
+
* Policy fields:
|
|
18
|
+
* - arity: argument count (excluding the command itself), null for variable
|
|
19
|
+
* - shape: output shape 'scalar' | 'list' | 'pairs' | 'scan' | 'info' | 'client_list'
|
|
20
|
+
* - validate: (args) => void, argument validation function
|
|
21
|
+
* - unbounded: true means the result set can be large and needs --allow-unbounded
|
|
22
22
|
*/
|
|
23
23
|
const _READ_ONLY_COMMANDS = {
|
|
24
|
-
// ──
|
|
24
|
+
// ── Generic/keys ──
|
|
25
25
|
PING: { arity: 0, shape: 'scalar', validate: () => {} },
|
|
26
26
|
GET: { arity: 1, shape: 'scalar', validate: () => {} },
|
|
27
|
-
MGET: { arity: null, shape: 'list', validate: (a) => { if (!a.length) throw 'MGET
|
|
28
|
-
EXISTS: { arity: null, shape: 'scalar', validate: (a) => { if (!a.length) throw 'EXISTS
|
|
27
|
+
MGET: { arity: null, shape: 'list', validate: (a) => { if (!a.length) throw 'MGET requires at least one key'; } },
|
|
28
|
+
EXISTS: { arity: null, shape: 'scalar', validate: (a) => { if (!a.length) throw 'EXISTS requires at least one key'; } },
|
|
29
29
|
TYPE: { arity: 1, shape: 'scalar', validate: () => {} },
|
|
30
30
|
TTL: { arity: 1, shape: 'scalar', validate: () => {} },
|
|
31
31
|
PTTL: { arity: 1, shape: 'scalar', validate: () => {} },
|
|
@@ -37,7 +37,7 @@ const _READ_ONLY_COMMANDS = {
|
|
|
37
37
|
|
|
38
38
|
// ── Hash ──
|
|
39
39
|
HGET: { arity: 2, shape: 'scalar', validate: () => {} },
|
|
40
|
-
HMGET: { arity: null, shape: 'list', validate: (a) => { if (a.length < 2) throw 'HMGET
|
|
40
|
+
HMGET: { arity: null, shape: 'list', validate: (a) => { if (a.length < 2) throw 'HMGET requires at least a key and one field'; } },
|
|
41
41
|
HEXISTS: { arity: 2, shape: 'scalar', validate: () => {} },
|
|
42
42
|
HLEN: { arity: 1, shape: 'scalar', validate: () => {} },
|
|
43
43
|
HGETALL: { arity: 1, shape: 'pairs', unbounded: true, validate: () => {} },
|
|
@@ -66,113 +66,116 @@ const _READ_ONLY_COMMANDS = {
|
|
|
66
66
|
ZRANGEBYSCORE: { arity: null, shape: 'list', validate: validateZrangeByScore },
|
|
67
67
|
ZSCAN: { arity: null, shape: 'scan', validate: validateScan },
|
|
68
68
|
|
|
69
|
-
// ──
|
|
69
|
+
// ── Server info ──
|
|
70
70
|
INFO: { arity: null, shape: 'info', validate: () => {} },
|
|
71
71
|
CLIENT: { arity: null, shape: 'client_list', validate: validateClient },
|
|
72
72
|
CONFIG: { arity: null, shape: 'list', validate: validateConfig },
|
|
73
73
|
};
|
|
74
74
|
|
|
75
|
-
/**
|
|
75
|
+
/** Freeze the allowlist to prevent runtime tampering */
|
|
76
76
|
const READ_ONLY_COMMANDS = Object.freeze(_READ_ONLY_COMMANDS);
|
|
77
77
|
|
|
78
|
-
// ──
|
|
78
|
+
// ── Argument validators ──
|
|
79
79
|
|
|
80
80
|
/**
|
|
81
|
-
* SCAN/HSCAN/SSCAN/ZSCAN
|
|
82
|
-
*
|
|
81
|
+
* Structured argument validation for SCAN/HSCAN/SSCAN/ZSCAN
|
|
82
|
+
* Only MATCH pattern COUNT n [TYPE type] are accepted; duplicate options are rejected
|
|
83
83
|
*
|
|
84
|
-
*
|
|
85
|
-
* HSCAN/SSCAN/ZSCAN
|
|
86
|
-
*
|
|
84
|
+
* Fix: the cursor position differs per command — SCAN has no key prefix and
|
|
85
|
+
* the cursor comes first; HSCAN/SSCAN/ZSCAN take a key first, so the cursor
|
|
86
|
+
* is second. The old implementation treated everything like SCAN and wrongly
|
|
87
|
+
* rejected a legal cursor=0 for HSCAN/SSCAN/ZSCAN.
|
|
87
88
|
*/
|
|
88
89
|
function validateScan(args, command) {
|
|
89
|
-
// SCAN
|
|
90
|
+
// SCAN argument layout: the cursor comes first; the other three commands
|
|
91
|
+
// take a key first and the cursor second
|
|
90
92
|
const hasKey = command !== 'SCAN';
|
|
91
93
|
let i;
|
|
92
94
|
if (hasKey) {
|
|
93
|
-
if (args.length < 2) throw `${command}
|
|
95
|
+
if (args.length < 2) throw `${command} requires at least a key and a cursor`;
|
|
94
96
|
const cursor = Number(args[1]);
|
|
95
|
-
if (!Number.isInteger(cursor) || cursor < 0) throw 'cursor
|
|
96
|
-
i = 2; //
|
|
97
|
+
if (!Number.isInteger(cursor) || cursor < 0) throw 'cursor must be a non-negative integer';
|
|
98
|
+
i = 2; // options start at the third position
|
|
97
99
|
} else {
|
|
98
|
-
if (args.length === 0) throw 'SCAN
|
|
100
|
+
if (args.length === 0) throw 'SCAN requires at least a cursor argument';
|
|
99
101
|
const cursor = Number(args[0]);
|
|
100
|
-
if (!Number.isInteger(cursor) || cursor < 0) throw 'cursor
|
|
101
|
-
i = 1; //
|
|
102
|
+
if (!Number.isInteger(cursor) || cursor < 0) throw 'cursor must be a non-negative integer';
|
|
103
|
+
i = 1; // options start at the second position
|
|
102
104
|
}
|
|
103
105
|
|
|
104
106
|
const seen = new Set();
|
|
105
107
|
for (; i < args.length; i++) {
|
|
106
108
|
const opt = String(args[i]).toUpperCase();
|
|
107
|
-
if (seen.has(opt)) throw
|
|
109
|
+
if (seen.has(opt)) throw `Duplicate SCAN option: ${opt}`;
|
|
108
110
|
seen.add(opt);
|
|
109
111
|
|
|
110
112
|
if (opt === 'MATCH') {
|
|
111
|
-
if (i + 1 >= args.length) throw 'MATCH
|
|
112
|
-
i++; //
|
|
113
|
+
if (i + 1 >= args.length) throw 'MATCH requires a pattern argument';
|
|
114
|
+
i++; // skip the pattern value
|
|
113
115
|
} else if (opt === 'COUNT') {
|
|
114
|
-
if (i + 1 >= args.length) throw 'COUNT
|
|
116
|
+
if (i + 1 >= args.length) throw 'COUNT requires a numeric argument';
|
|
115
117
|
const count = Number(args[i + 1]);
|
|
116
|
-
if (!Number.isInteger(count) || count < 1) throw 'COUNT
|
|
117
|
-
if (count > 5000) throw 'COUNT
|
|
118
|
+
if (!Number.isInteger(count) || count < 1) throw 'COUNT must be a positive integer';
|
|
119
|
+
if (count > 5000) throw 'COUNT exceeds the cap of 5000';
|
|
118
120
|
i++;
|
|
119
121
|
} else if (opt === 'TYPE') {
|
|
120
|
-
// TYPE
|
|
121
|
-
|
|
122
|
-
if (
|
|
122
|
+
// TYPE is SCAN-only; HSCAN/SSCAN/ZSCAN do not support it (reject
|
|
123
|
+
// explicitly rather than deferring to the server)
|
|
124
|
+
if (hasKey) throw `${command} does not support the TYPE option (SCAN only)`;
|
|
125
|
+
if (i + 1 >= args.length) throw 'TYPE requires a type-name argument';
|
|
123
126
|
i++;
|
|
124
127
|
} else {
|
|
125
|
-
throw `SCAN
|
|
128
|
+
throw `Unsupported SCAN option: ${opt}`;
|
|
126
129
|
}
|
|
127
130
|
}
|
|
128
131
|
}
|
|
129
132
|
|
|
130
133
|
/**
|
|
131
|
-
* LRANGE
|
|
132
|
-
* stop < 0
|
|
133
|
-
* start < 0
|
|
134
|
+
* LRANGE range validation: prevents client-side memory DoS
|
|
135
|
+
* stop < 0 (negative index) → length unknown, require --allow-unbounded
|
|
136
|
+
* when start < 0 or stop ≥ 0, check (stop - start + 1) ≤ maxItems
|
|
134
137
|
*/
|
|
135
138
|
function validateLrange(args, command, limits) {
|
|
136
|
-
if (args.length < 3) throw '
|
|
139
|
+
if (args.length < 3) throw 'At least 3 arguments required (key start stop)';
|
|
137
140
|
const start = Number(args[1]);
|
|
138
141
|
const stop = Number(args[2]);
|
|
139
142
|
if (!Number.isInteger(start) || !Number.isInteger(stop)) {
|
|
140
|
-
throw 'start/stop
|
|
143
|
+
throw 'start/stop must be integers';
|
|
141
144
|
}
|
|
142
145
|
const maxItems = (limits && limits.maxItems) || 200;
|
|
143
146
|
|
|
144
|
-
//
|
|
147
|
+
// A negative stop index means "to the end"; the actual length is unknown → require explicit opt-in
|
|
145
148
|
if (stop < 0) {
|
|
146
149
|
if (!(limits && limits.allowUnbounded)) {
|
|
147
|
-
throw 'stop
|
|
150
|
+
throw 'A negative stop index makes the range unbounded; use --allow-unbounded or SCAN/LRANGE with non-negative indexes';
|
|
148
151
|
}
|
|
149
|
-
return; //
|
|
152
|
+
return; // opted in; skip the range check
|
|
150
153
|
}
|
|
151
|
-
// start
|
|
154
|
+
// A negative start index resolves to an unpredictable position in Redis → also require opt-in
|
|
152
155
|
if (start < 0) {
|
|
153
156
|
if (!(limits && limits.allowUnbounded)) {
|
|
154
|
-
throw 'start
|
|
157
|
+
throw 'A negative start index makes the range unbounded; use --allow-unbounded or LRANGE with non-negative indexes';
|
|
155
158
|
}
|
|
156
159
|
return;
|
|
157
160
|
}
|
|
158
|
-
//
|
|
161
|
+
// Both indexes non-negative: the returned count can be computed exactly
|
|
159
162
|
const count = stop - start + 1;
|
|
160
163
|
if (count > maxItems) {
|
|
161
|
-
throw
|
|
164
|
+
throw `Range ${start}..${stop} yields ${count} items, exceeding maxItems=${maxItems}`;
|
|
162
165
|
}
|
|
163
166
|
}
|
|
164
167
|
|
|
165
168
|
/**
|
|
166
|
-
* ZRANGE Redis 6.2+
|
|
167
|
-
*
|
|
168
|
-
* BYLEX
|
|
169
|
-
*
|
|
169
|
+
* Full ZRANGE syntax validation for Redis 6.2+
|
|
170
|
+
* Supports BYSCORE / BYLEX / REV / LIMIT / WITHSCORES options
|
|
171
|
+
* BYLEX without LIMIT requires --allow-unbounded (lexicographic ranges can be long)
|
|
172
|
+
* With LIMIT, count must be ≤ maxItems
|
|
170
173
|
*/
|
|
171
174
|
function validateZrange(args, command, limits) {
|
|
172
|
-
if (args.length < 3) throw 'ZRANGE
|
|
175
|
+
if (args.length < 3) throw 'ZRANGE requires at least key min max';
|
|
173
176
|
const maxItems = (limits && limits.maxItems) || 200;
|
|
174
177
|
|
|
175
|
-
//
|
|
178
|
+
// Collect option keywords (skip the 3 required arguments key min max)
|
|
176
179
|
const optsUpper = args.slice(3).map(a => String(a).toUpperCase());
|
|
177
180
|
let hasByscore = false;
|
|
178
181
|
let hasBylex = false;
|
|
@@ -188,94 +191,95 @@ function validateZrange(args, command, limits) {
|
|
|
188
191
|
if (opt === 'WITHSCORES') { continue; }
|
|
189
192
|
if (opt === 'LIMIT') {
|
|
190
193
|
hasLimit = true;
|
|
191
|
-
// LIMIT
|
|
192
|
-
if (i + 2 >= optsUpper.length) throw 'LIMIT
|
|
193
|
-
const offset = Number(args[3 + i + 1]); //
|
|
194
|
+
// LIMIT takes offset and count
|
|
195
|
+
if (i + 2 >= optsUpper.length) throw 'LIMIT requires offset and count';
|
|
196
|
+
const offset = Number(args[3 + i + 1]); // corresponding position in the original args
|
|
194
197
|
limitCount = Number(args[3 + i + 2]);
|
|
195
198
|
if (!Number.isInteger(offset) || !Number.isInteger(limitCount) || offset < 0 || limitCount < 0) {
|
|
196
|
-
throw 'LIMIT offset/count
|
|
199
|
+
throw 'LIMIT offset/count must be non-negative integers';
|
|
197
200
|
}
|
|
198
|
-
if (limitCount > maxItems) throw `LIMIT count ${limitCount}
|
|
199
|
-
i += 2; //
|
|
201
|
+
if (limitCount > maxItems) throw `LIMIT count ${limitCount} exceeds maxItems=${maxItems}`;
|
|
202
|
+
i += 2; // skip offset and count
|
|
200
203
|
continue;
|
|
201
204
|
}
|
|
202
|
-
throw `ZRANGE
|
|
205
|
+
throw `Unsupported ZRANGE option: ${opt}`;
|
|
203
206
|
}
|
|
204
207
|
|
|
205
|
-
// BYLEX
|
|
208
|
+
// BYLEX without LIMIT → the lexicographic range can be huge; require opt-in
|
|
206
209
|
if (hasBylex && !hasLimit) {
|
|
207
210
|
if (!(limits && limits.allowUnbounded)) {
|
|
208
|
-
throw 'BYLEX
|
|
211
|
+
throw 'BYLEX without LIMIT has an unbounded range; use --allow-unbounded';
|
|
209
212
|
}
|
|
210
213
|
}
|
|
211
|
-
// BYSCORE
|
|
214
|
+
// BYSCORE with the default index range (e.g. 0 -1) and no LIMIT → also require opt-in
|
|
212
215
|
if (hasByscore && !hasLimit) {
|
|
213
216
|
if (!(limits && limits.allowUnbounded)) {
|
|
214
|
-
throw 'BYSCORE
|
|
217
|
+
throw 'BYSCORE without LIMIT has an unbounded range; use --allow-unbounded';
|
|
215
218
|
}
|
|
216
219
|
}
|
|
217
|
-
//
|
|
220
|
+
// Default index mode (not BYSCORE/BYLEX): check the min/max range
|
|
218
221
|
if (!hasByscore && !hasBylex) {
|
|
219
|
-
// min/max
|
|
222
|
+
// With min/max as rank indexes, a negative index means "to the end"; the
|
|
223
|
+
// range is unbounded
|
|
220
224
|
const minVal = Number(args[1]);
|
|
221
225
|
const maxVal = Number(args[2]);
|
|
222
226
|
if ((minVal < 0 || maxVal < 0) && !hasLimit) {
|
|
223
227
|
if (!(limits && limits.allowUnbounded)) {
|
|
224
|
-
throw 'ZRANGE
|
|
228
|
+
throw 'ZRANGE with a negative index and no LIMIT has an unbounded range; use --allow-unbounded';
|
|
225
229
|
}
|
|
226
230
|
}
|
|
227
|
-
//
|
|
228
|
-
//
|
|
231
|
+
// Non-negative indexes with LIMIT: LIMIT already validated the count; safe
|
|
232
|
+
// Non-negative indexes without LIMIT: compute the range
|
|
229
233
|
if (minVal >= 0 && maxVal >= 0 && !hasLimit) {
|
|
230
234
|
const rangeCount = maxVal - minVal + 1;
|
|
231
235
|
if (rangeCount > maxItems) {
|
|
232
|
-
throw
|
|
236
|
+
throw `Range ${minVal}..${maxVal} yields ${rangeCount} items, exceeding maxItems=${maxItems}`;
|
|
233
237
|
}
|
|
234
238
|
}
|
|
235
239
|
}
|
|
236
240
|
}
|
|
237
241
|
|
|
238
242
|
/**
|
|
239
|
-
* ZRANGEBYSCORE
|
|
243
|
+
* ZRANGEBYSCORE must carry LIMIT offset count, and count must not exceed the cap
|
|
240
244
|
*/
|
|
241
245
|
function validateZrangeByScore(args) {
|
|
242
|
-
if (args.length < 3) throw 'ZRANGEBYSCORE
|
|
243
|
-
//
|
|
246
|
+
if (args.length < 3) throw 'ZRANGEBYSCORE requires at least key min max';
|
|
247
|
+
// Check for LIMIT
|
|
244
248
|
const argsUpper = args.map(a => String(a).toUpperCase());
|
|
245
249
|
const limitIdx = argsUpper.indexOf('LIMIT');
|
|
246
250
|
if (limitIdx === -1) {
|
|
247
|
-
throw 'ZRANGEBYSCORE
|
|
251
|
+
throw 'ZRANGEBYSCORE requires a LIMIT clause (prevents unbounded result sets)';
|
|
248
252
|
}
|
|
249
253
|
if (limitIdx + 2 >= args.length) {
|
|
250
|
-
throw 'LIMIT
|
|
254
|
+
throw 'LIMIT requires both offset and count arguments';
|
|
251
255
|
}
|
|
252
256
|
const count = Number(args[limitIdx + 2]);
|
|
253
|
-
if (!Number.isInteger(count) || count < 0) throw 'LIMIT count
|
|
254
|
-
if (count > 5000) throw 'LIMIT count
|
|
257
|
+
if (!Number.isInteger(count) || count < 0) throw 'LIMIT count must be a non-negative integer';
|
|
258
|
+
if (count > 5000) throw 'LIMIT count exceeds the cap of 5000';
|
|
255
259
|
}
|
|
256
260
|
|
|
257
261
|
/**
|
|
258
|
-
* CLIENT
|
|
259
|
-
* KILL/PAUSE/UNBLOCK
|
|
262
|
+
* CLIENT allows only the LIST subcommand
|
|
263
|
+
* KILL/PAUSE/UNBLOCK can disrupt other connections; reject them
|
|
260
264
|
*/
|
|
261
265
|
function validateClient(args) {
|
|
262
|
-
if (args.length === 0) throw 'CLIENT
|
|
266
|
+
if (args.length === 0) throw 'CLIENT requires a subcommand';
|
|
263
267
|
const sub = String(args[0]).toUpperCase();
|
|
264
|
-
if (sub !== 'LIST') throw
|
|
268
|
+
if (sub !== 'LIST') throw `CLIENT ${sub} is not allowed (only CLIENT LIST is allowed)`;
|
|
265
269
|
}
|
|
266
270
|
|
|
267
271
|
/**
|
|
268
|
-
* CONFIG
|
|
269
|
-
*
|
|
272
|
+
* CONFIG allows only the GET subcommand, and the config key must be on the safe list
|
|
273
|
+
* Reject * (dumps the whole config) and sensitive keys like requirepass and masterauth
|
|
270
274
|
*/
|
|
271
275
|
function validateConfig(args) {
|
|
272
|
-
if (args.length === 0) throw 'CONFIG
|
|
276
|
+
if (args.length === 0) throw 'CONFIG requires a subcommand';
|
|
273
277
|
const sub = String(args[0]).toUpperCase();
|
|
274
|
-
if (sub !== 'GET') throw
|
|
278
|
+
if (sub !== 'GET') throw `CONFIG ${sub} is not allowed (only CONFIG GET is allowed)`;
|
|
275
279
|
|
|
276
|
-
if (args.length < 2) throw 'CONFIG GET
|
|
280
|
+
if (args.length < 2) throw 'CONFIG GET requires at least one argument';
|
|
277
281
|
|
|
278
|
-
//
|
|
282
|
+
// Safe list: only non-sensitive runtime configs may be viewed
|
|
279
283
|
const SAFE_CONFIG_KEYS = new Set([
|
|
280
284
|
'databases', 'maxmemory', 'maxmemory-policy', 'maxmemory-samples',
|
|
281
285
|
'timeout', 'tcp-keepalive', 'appendonly', 'appendfsync',
|
|
@@ -287,60 +291,61 @@ function validateConfig(args) {
|
|
|
287
291
|
|
|
288
292
|
for (let i = 1; i < args.length; i++) {
|
|
289
293
|
const key = String(args[i]).toLowerCase();
|
|
290
|
-
if (key === '*') throw '
|
|
294
|
+
if (key === '*') throw 'CONFIG GET * is not allowed (dumps the entire config including passwords)';
|
|
291
295
|
if (key === 'requirepass' || key === 'masterauth') {
|
|
292
|
-
throw
|
|
296
|
+
throw `Querying ${key} is not allowed (may expose passwords)`;
|
|
293
297
|
}
|
|
294
|
-
//
|
|
298
|
+
// Reject any key containing markers like tls/key/pass/secret/acl
|
|
295
299
|
if (/tls[_-]?key|pass|secret|acl[_-]file|unixsocket/i.test(key)) {
|
|
296
|
-
throw
|
|
300
|
+
throw `Querying sensitive config is not allowed: ${key}`;
|
|
297
301
|
}
|
|
298
302
|
if (!SAFE_CONFIG_KEYS.has(key)) {
|
|
299
|
-
throw `CONFIG GET ${key}
|
|
303
|
+
throw `CONFIG GET ${key} is not on the safe list`;
|
|
300
304
|
}
|
|
301
305
|
}
|
|
302
306
|
}
|
|
303
307
|
|
|
304
|
-
// ──
|
|
308
|
+
// ── Main validation entry ──
|
|
305
309
|
|
|
306
310
|
/**
|
|
307
|
-
*
|
|
308
|
-
*
|
|
311
|
+
* Validate that a Redis command is on the read-only allowlist and validate its arguments
|
|
312
|
+
* Unknown commands are always rejected (fail-closed)
|
|
309
313
|
*
|
|
310
|
-
* @param {string[]} argv -
|
|
314
|
+
* @param {string[]} argv - full command array, [cmd, ...args]
|
|
311
315
|
* @param {object} limits - { maxItems: number, allowUnbounded: boolean }
|
|
312
316
|
* @returns {{ command: string, args: string[], shape: string, unbounded: boolean }}
|
|
313
317
|
*/
|
|
314
318
|
function validateRedisCommand(argv, limits) {
|
|
315
319
|
if (!Array.isArray(argv) || argv.length === 0) {
|
|
316
|
-
throw new RedisReadonlyError('Redis
|
|
320
|
+
throw new RedisReadonlyError('Redis command must not be empty');
|
|
317
321
|
}
|
|
318
322
|
|
|
319
323
|
const command = String(argv[0]).toUpperCase();
|
|
320
324
|
const policy = READ_ONLY_COMMANDS[command];
|
|
321
325
|
|
|
322
326
|
if (!policy) {
|
|
323
|
-
throw new RedisReadonlyError(
|
|
327
|
+
throw new RedisReadonlyError(`Command not allowed: ${command} (not on the read-only allowlist)`);
|
|
324
328
|
}
|
|
325
329
|
|
|
326
|
-
//
|
|
330
|
+
// Check arity (argument count constraint)
|
|
327
331
|
if (policy.arity !== null && argv.length - 1 !== policy.arity) {
|
|
328
|
-
throw new RedisReadonlyError(`${command}
|
|
332
|
+
throw new RedisReadonlyError(`${command} requires ${policy.arity} argument(s), got ${argv.length - 1}`);
|
|
329
333
|
}
|
|
330
334
|
|
|
331
|
-
//
|
|
335
|
+
// Unbounded commands require explicit opt-in
|
|
332
336
|
if (policy.unbounded && !(limits && limits.allowUnbounded)) {
|
|
333
337
|
throw new RedisReadonlyError(
|
|
334
|
-
`${command}
|
|
338
|
+
`${command} may return a large amount of data; add --allow-unbounded to confirm (prefer SCAN-family commands)`
|
|
335
339
|
);
|
|
336
340
|
}
|
|
337
341
|
|
|
338
|
-
//
|
|
342
|
+
// Run the policy-specific argument validator; pass the command name and
|
|
343
|
+
// limits so each function can do range/unbounded checks
|
|
339
344
|
const args = argv.slice(1);
|
|
340
345
|
try {
|
|
341
346
|
policy.validate(args, command, limits);
|
|
342
347
|
} catch (msg) {
|
|
343
|
-
throw new RedisReadonlyError(`${command}
|
|
348
|
+
throw new RedisReadonlyError(`${command} argument error: ${msg}`);
|
|
344
349
|
}
|
|
345
350
|
|
|
346
351
|
return {
|
|
@@ -2,21 +2,21 @@
|
|
|
2
2
|
'use strict';
|
|
3
3
|
|
|
4
4
|
/**
|
|
5
|
-
* MySQL
|
|
6
|
-
*
|
|
7
|
-
*
|
|
5
|
+
* MySQL read-only query entry point
|
|
6
|
+
* Pipeline: CLI parsing → SQL read-only validation → connect (read-only txn + timeout) → query → output → rollback & close
|
|
7
|
+
* Security: client-side timer + server-side MAX_EXECUTION_TIME as dual safeguards; always ROLLBACK
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
10
|
const fs = require('fs');
|
|
11
11
|
const path = require('path');
|
|
12
12
|
|
|
13
|
-
// ──
|
|
13
|
+
// ── Internal modules ──
|
|
14
14
|
const { parseCli, CliError } = require('./lib/cli');
|
|
15
15
|
const { loadEnvFile, resolveEnv, resolveEnvNumber, ConfigError, sanitizeTarget, safeMessage, buildTlsOptions } = require('./lib/config');
|
|
16
16
|
const { validateMysqlSql, ReadonlyError } = require('./lib/mysql-readonly');
|
|
17
17
|
const { formatMysqlTable, formatJson } = require('./lib/output');
|
|
18
18
|
|
|
19
|
-
// ── CLI
|
|
19
|
+
// ── CLI option spec ──
|
|
20
20
|
const MYSQL_SPEC = {
|
|
21
21
|
host: { type: 'string', default: '127.0.0.1' },
|
|
22
22
|
port: { type: 'integer', default: 3306, min: 1, max: 65535 },
|
|
@@ -37,28 +37,28 @@ const MYSQL_SPEC = {
|
|
|
37
37
|
|
|
38
38
|
const ENV_PREFIX = 'DB_QUERY_MYSQL_';
|
|
39
39
|
|
|
40
|
-
/**
|
|
40
|
+
/** Read SQL from a file (plain text, max 64KiB) */
|
|
41
41
|
function readSqlFile(filePath) {
|
|
42
42
|
const resolved = path.resolve(filePath);
|
|
43
|
-
if (!fs.existsSync(resolved)) throw new Error(`SQL
|
|
43
|
+
if (!fs.existsSync(resolved)) throw new Error(`SQL file not found: ${resolved}`);
|
|
44
44
|
const stat = fs.statSync(resolved);
|
|
45
|
-
if (stat.size > 64 * 1024) throw new Error(`SQL
|
|
45
|
+
if (stat.size > 64 * 1024) throw new Error(`SQL file exceeds 64KiB: ${resolved}`);
|
|
46
46
|
return fs.readFileSync(resolved, 'utf8');
|
|
47
47
|
}
|
|
48
48
|
|
|
49
|
-
/**
|
|
49
|
+
/** Print validation result (--validate-only mode) */
|
|
50
50
|
function printValidation(checked) {
|
|
51
|
-
process.stderr.write(`✓
|
|
52
|
-
process.stderr.write(
|
|
51
|
+
process.stderr.write(`✓ Validation passed: ${checked.kind}${checked.limitInjected ? ' (LIMIT injected for truncation)' : ''}\n`);
|
|
52
|
+
process.stderr.write(`Processed SQL:\n${checked.sql}\n`);
|
|
53
53
|
}
|
|
54
54
|
|
|
55
|
-
/**
|
|
55
|
+
/** Execute the query with a timeout (client-side timer + server-side MAX_EXECUTION_TIME as dual safeguards) */
|
|
56
56
|
function queryWithTimeout(conn, sql, timeoutMs) {
|
|
57
57
|
return new Promise((resolve, reject) => {
|
|
58
58
|
const timer = setTimeout(() => {
|
|
59
|
-
//
|
|
59
|
+
// Client-side timeout: destroy the connection to stop server transmission
|
|
60
60
|
conn.destroy();
|
|
61
|
-
reject(new Error(
|
|
61
|
+
reject(new Error(`Query timed out (${timeoutMs}ms)`));
|
|
62
62
|
}, timeoutMs);
|
|
63
63
|
|
|
64
64
|
conn.query(sql)
|
|
@@ -67,56 +67,59 @@ function queryWithTimeout(conn, sql, timeoutMs) {
|
|
|
67
67
|
});
|
|
68
68
|
}
|
|
69
69
|
|
|
70
|
-
/**
|
|
70
|
+
/** Roll back safely (ignore errors so the connection always closes) */
|
|
71
71
|
async function safeRollback(conn) {
|
|
72
|
-
try { await conn.query('ROLLBACK'); } catch (_) { /*
|
|
72
|
+
try { await conn.query('ROLLBACK'); } catch (_) { /* ignore errors such as no active transaction */ }
|
|
73
73
|
}
|
|
74
74
|
|
|
75
|
-
/**
|
|
75
|
+
/** Close the connection safely */
|
|
76
76
|
async function safeClose(conn) {
|
|
77
|
-
try { await conn.end(); } catch (_) { /*
|
|
77
|
+
try { await conn.end(); } catch (_) { /* ignore errors such as connection already closed */ }
|
|
78
78
|
}
|
|
79
79
|
|
|
80
|
-
// ──
|
|
80
|
+
// ── Main ──
|
|
81
81
|
async function main() {
|
|
82
82
|
const { options, rest } = parseCli(process.argv.slice(2), MYSQL_SPEC);
|
|
83
83
|
|
|
84
|
-
// SQL
|
|
84
|
+
// SQL source check: --file and a positional argument are mutually exclusive
|
|
85
85
|
if (options.file && rest.length > 0) {
|
|
86
|
-
throw new CliError('--file
|
|
86
|
+
throw new CliError('--file and a positional SQL argument cannot both be given');
|
|
87
87
|
}
|
|
88
88
|
if (!options.file && rest.length === 0) {
|
|
89
|
-
throw new CliError('
|
|
89
|
+
throw new CliError('SQL must be provided (positional argument or --file)');
|
|
90
90
|
}
|
|
91
91
|
|
|
92
|
-
//
|
|
92
|
+
// Read the SQL (before any connection setup)
|
|
93
93
|
const rawSql = options.file ? readSqlFile(options.file) : rest[0];
|
|
94
94
|
|
|
95
|
-
//
|
|
95
|
+
// Read-only validation (fully offline, independent of connection parameters)
|
|
96
96
|
const maxRows = options.maxRows;
|
|
97
97
|
const checked = validateMysqlSql(rawSql, { maxRows });
|
|
98
98
|
|
|
99
|
-
// --validate-only
|
|
99
|
+
// --validate-only: pure offline validation, no credential resolution, no connection
|
|
100
100
|
if (options['validate-only']) {
|
|
101
101
|
return printValidation(checked);
|
|
102
102
|
}
|
|
103
103
|
|
|
104
|
-
// ──
|
|
105
|
-
//
|
|
104
|
+
// ── Connection path below: requires connection parameters ──
|
|
105
|
+
// Fix: env file loading must happen before the required user/database checks,
|
|
106
|
+
// otherwise credentials provided by the env file are not visible
|
|
106
107
|
const envPairs = loadEnvFile(options['env-file']);
|
|
107
108
|
|
|
108
|
-
//
|
|
109
|
+
// Merge environment overrides (resolveEnvNumber uniformly enforces bounds,
|
|
110
|
+
// so env files cannot bypass hard limits)
|
|
109
111
|
const host = resolveEnv(envPairs, ENV_PREFIX + 'HOST', options.host);
|
|
110
112
|
const port = resolveEnvNumber(envPairs, ENV_PREFIX + 'PORT', options.port, { min: 1, max: 65535 });
|
|
111
113
|
const user = resolveEnv(envPairs, ENV_PREFIX + 'USER', options.user);
|
|
112
114
|
const password = resolveEnv(envPairs, ENV_PREFIX + 'PASSWORD', options.password);
|
|
113
115
|
const database = resolveEnv(envPairs, ENV_PREFIX + 'DATABASE', options.database);
|
|
114
116
|
|
|
115
|
-
// user
|
|
116
|
-
//
|
|
117
|
-
|
|
117
|
+
// user is required; database is optional — the first triage step is often
|
|
118
|
+
// SHOW DATABASES / SELECT 1 / db-prefixed table queries with no default db;
|
|
119
|
+
// MySQL itself reports ER_NO_DB_ERROR when a table query omits the database
|
|
120
|
+
if (!user) throw new CliError('Missing user (provide via --user, DB_QUERY_MYSQL_USER, environment variable, or env file)');
|
|
118
121
|
if (!database) {
|
|
119
|
-
process.stderr.write('
|
|
122
|
+
process.stderr.write('(no database specified: only queries that need no default db will work, e.g. SHOW DATABASES / SELECT 1 / db-prefixed tables)\n');
|
|
120
123
|
}
|
|
121
124
|
|
|
122
125
|
const maxRowsResolved = resolveEnvNumber(envPairs, ENV_PREFIX + 'MAX_ROWS', options.maxRows, { min: 1, max: 5000 });
|
|
@@ -124,11 +127,11 @@ async function main() {
|
|
|
124
127
|
const timeoutMs = resolveEnvNumber(envPairs, ENV_PREFIX + 'TIMEOUT_MS', options['timeout-ms'], { min: 1000, max: 60000 });
|
|
125
128
|
const connectTimeoutMs = resolveEnvNumber(envPairs, ENV_PREFIX + 'CONNECT_TIMEOUT_MS', options['connect-timeout-ms'], { min: 1000, max: 30000 });
|
|
126
129
|
|
|
127
|
-
//
|
|
130
|
+
// Print sanitized target (confirm the destination before connecting; never expose the password)
|
|
128
131
|
const target = sanitizeTarget({ host, port, user, database });
|
|
129
132
|
process.stderr.write(`→ MySQL ${target}\n`);
|
|
130
133
|
|
|
131
|
-
//
|
|
134
|
+
// Load mysql2 lazily (only when a connection is needed)
|
|
132
135
|
const mysql = require('mysql2/promise');
|
|
133
136
|
|
|
134
137
|
const conn = await mysql.createConnection({
|
|
@@ -138,11 +141,11 @@ async function main() {
|
|
|
138
141
|
password: password || undefined,
|
|
139
142
|
database,
|
|
140
143
|
connectTimeout: connectTimeoutMs,
|
|
141
|
-
multipleStatements: false, //
|
|
144
|
+
multipleStatements: false, // explicitly disabled; do not rely on driver defaults
|
|
142
145
|
supportBigNumbers: true,
|
|
143
|
-
bigNumberStrings: true, // BIGINT/DECIMAL
|
|
146
|
+
bigNumberStrings: true, // keep BIGINT/DECIMAL as strings for precision
|
|
144
147
|
decimalNumbers: false,
|
|
145
|
-
dateStrings: true, //
|
|
148
|
+
dateStrings: true, // keep dates as server-side text
|
|
146
149
|
...(options.tls || options['ca-file']
|
|
147
150
|
? { ssl: buildTlsOptions(options['ca-file']) || { rejectUnauthorized: true } }
|
|
148
151
|
: {}),
|
|
@@ -150,7 +153,8 @@ async function main() {
|
|
|
150
153
|
|
|
151
154
|
const startMs = Date.now();
|
|
152
155
|
try {
|
|
153
|
-
//
|
|
156
|
+
// Server-side read-only transaction (unsupported versions raise an error;
|
|
157
|
+
// fail-closed, no silent downgrade)
|
|
154
158
|
await conn.query('SET SESSION TRANSACTION READ ONLY');
|
|
155
159
|
await conn.query('SET SESSION MAX_EXECUTION_TIME = ?', [timeoutMs]);
|
|
156
160
|
await conn.query('START TRANSACTION READ ONLY');
|
|
@@ -158,12 +162,12 @@ async function main() {
|
|
|
158
162
|
const [rows, fields] = await queryWithTimeout(conn, checked.sql, timeoutMs);
|
|
159
163
|
const elapsedMs = Date.now() - startMs;
|
|
160
164
|
|
|
161
|
-
//
|
|
165
|
+
// Detect truncation (LIMIT injected a maxRows+1 sentinel row)
|
|
162
166
|
const truncated = checked.limitInjected && rows.length > maxRowsResolved;
|
|
163
167
|
const displayRows = truncated ? rows.slice(0, maxRowsResolved) : rows;
|
|
164
168
|
|
|
165
169
|
if (options.format === 'json') {
|
|
166
|
-
// JSON
|
|
170
|
+
// JSON to stdout, status to stderr, so stdout stays valid JSON
|
|
167
171
|
process.stdout.write(JSON.stringify({
|
|
168
172
|
source: 'mysql',
|
|
169
173
|
target,
|
|
@@ -180,9 +184,9 @@ async function main() {
|
|
|
180
184
|
}) + '\n');
|
|
181
185
|
}
|
|
182
186
|
|
|
183
|
-
process.stderr.write(`✓ ${rows.length}
|
|
187
|
+
process.stderr.write(`✓ ${rows.length} row(s)${truncated ? ' (truncated)' : ''} in ${elapsedMs}ms\n`);
|
|
184
188
|
} finally {
|
|
185
|
-
//
|
|
189
|
+
// Always ROLLBACK, never COMMIT (defensive: guarantees no transaction is left open)
|
|
186
190
|
await safeRollback(conn);
|
|
187
191
|
await safeClose(conn);
|
|
188
192
|
}
|