resplite 1.5.0 → 1.5.4
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.md +27 -1
- package/package.json +1 -1
- package/scripts/benchmark-redis-vs-resplite.js +37 -15
- package/src/commands/command.js +18 -15
- package/src/commands/registry.js +117 -2
- package/src/embed.js +5 -1
- package/src/index.js +2 -1
- package/src/server/connection.js +5 -2
- package/src/server/tcp-server.js +5 -2
- package/test/helpers/server.js +4 -1
- package/test/integration/command-security.test.js +90 -0
- package/test/integration/embed.test.js +27 -0
- package/test/integration/migration-dirty-tracker.test.js +7 -1
package/README.md
CHANGED
|
@@ -62,6 +62,10 @@ const log = new LemonLog('RESPlite');
|
|
|
62
62
|
const srv = await createRESPlite({
|
|
63
63
|
port: 6380,
|
|
64
64
|
db: './data.db',
|
|
65
|
+
commandPolicy: {
|
|
66
|
+
rename: { KEYS: 'SAFE_KEYS' }, // original KEYS is blocked
|
|
67
|
+
disabled: ['MONITOR'], // blocked as unsupported
|
|
68
|
+
},
|
|
65
69
|
hooks: {
|
|
66
70
|
onUnknownCommand({ command, argv, clientAddress }) {
|
|
67
71
|
log.warn({ command, argv, clientAddress }, 'unsupported command');
|
|
@@ -84,6 +88,28 @@ Available hooks:
|
|
|
84
88
|
|
|
85
89
|
If you want a tiny in-process smoke test that starts RESPLite and connects with the `redis` client in the same script, see [Minimal embedded example](#minimal-embedded-example) below.
|
|
86
90
|
|
|
91
|
+
### Command hardening (rename/disable)
|
|
92
|
+
|
|
93
|
+
You can harden the command surface by renaming sensitive commands and/or disabling them:
|
|
94
|
+
|
|
95
|
+
```javascript
|
|
96
|
+
const srv = await createRESPlite({
|
|
97
|
+
db: './secure.db',
|
|
98
|
+
commandPolicy: {
|
|
99
|
+
rename: {
|
|
100
|
+
KEYS: 'SAFE_KEYS',
|
|
101
|
+
DEL: 'RMDEL',
|
|
102
|
+
},
|
|
103
|
+
disabled: ['MONITOR', 'CLIENT'],
|
|
104
|
+
},
|
|
105
|
+
});
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
Behavior:
|
|
109
|
+
- `rename`: only the new alias is accepted; the original command name is blocked.
|
|
110
|
+
- `disabled`: command is blocked and replies with `ERR command not supported yet`.
|
|
111
|
+
- `COMMAND`, `COMMAND COUNT`, and `COMMAND INFO` expose only visible commands (including aliases, excluding blocked names).
|
|
112
|
+
|
|
87
113
|
## Migration from Redis
|
|
88
114
|
|
|
89
115
|
RESPLite is a good fit for migrating **non-replicated Redis** instances that have **grown large** (e.g. tens of GB) and where RESPLite's latency is acceptable. The recommended path is to drive the migration from a Node.js script via `resplite/migration`, keeping preflight, dirty tracking, bulk import, cutover, and verification in one place.
|
|
@@ -574,7 +600,7 @@ To reproduce the benchmark, run `npm run benchmark -- --template default`. Numbe
|
|
|
574
600
|
| **Connection** | PING, ECHO, QUIT |
|
|
575
601
|
| **Strings** | GET, SET, MGET, MSET, DEL, EXISTS, INCR, DECR, INCRBY, DECRBY, STRLEN |
|
|
576
602
|
| **TTL** | EXPIRE, PEXPIRE, TTL, PTTL, PERSIST |
|
|
577
|
-
| **Hashes** | HSET, HGET, HMGET, HGETALL, HKEYS, HVALS, HDEL, HEXISTS, HINCRBY |
|
|
603
|
+
| **Hashes** | HSET, HGET, HMGET, HGETALL, HKEYS, HVALS, HDEL, HEXISTS, HINCRBY, HEXPIRE, HTTL, HPERSIST |
|
|
578
604
|
| **Sets** | SADD, SREM, SMEMBERS, SISMEMBER, SCARD, SPOP, SRANDMEMBER |
|
|
579
605
|
| **Lists** | LPUSH, RPUSH, LLEN, LRANGE, LINDEX, LPOP, RPOP, LSET, LTRIM, BLPOP, BRPOP |
|
|
580
606
|
| **Sorted sets** | ZADD, ZREM, ZCARD, ZSCORE, ZRANGE, ZREVRANGE, ZRANGEBYSCORE, ZREVRANGEBYSCORE, ZRANK, ZREVRANK, ZCOUNT, ZINCRBY, ZREMRANGEBYRANK, ZREMRANGEBYSCORE |
|
package/package.json
CHANGED
|
@@ -98,9 +98,13 @@ function spawnResplite(templateName, port, dbPath) {
|
|
|
98
98
|
}
|
|
99
99
|
|
|
100
100
|
function formatNum(n) {
|
|
101
|
+
if (!Number.isFinite(n)) return '—';
|
|
101
102
|
if (n >= 1e6) return (n / 1e6).toFixed(2) + 'M';
|
|
102
103
|
if (n >= 1e3) return (n / 1e3).toFixed(2) + 'K';
|
|
103
|
-
|
|
104
|
+
if (n >= 100) return n.toFixed(2);
|
|
105
|
+
if (n >= 10) return n.toFixed(2);
|
|
106
|
+
if (n >= 1) return n.toFixed(2);
|
|
107
|
+
return n.toFixed(3);
|
|
104
108
|
}
|
|
105
109
|
|
|
106
110
|
function formatMs(ms) {
|
|
@@ -603,23 +607,41 @@ async function main() {
|
|
|
603
607
|
for (const { client } of respliteClients) await client.quit();
|
|
604
608
|
for (const { child } of children) child.kill();
|
|
605
609
|
|
|
606
|
-
const colWidth = 10;
|
|
607
610
|
const headerCols = ['Suite', 'Redis', ...templateNames];
|
|
608
|
-
const
|
|
609
|
-
|
|
610
|
-
console.log('--- Summary (ops/sec) ---');
|
|
611
|
-
console.log(headerCols.map((h, i) => (i === 0 ? h.padEnd(18) : h.padStart(colWidth))).join(' | '));
|
|
612
|
-
console.log(sep);
|
|
613
|
-
for (const r of results) {
|
|
614
|
-
if (r.error) {
|
|
615
|
-
console.log(`${r.suite.padEnd(18)} | ERROR: ${r.error}`);
|
|
616
|
-
continue;
|
|
617
|
-
}
|
|
611
|
+
const summaryRows = results.map((r) => {
|
|
612
|
+
if (r.error) return { suite: r.suite, values: [`ERROR: ${r.error}`] };
|
|
618
613
|
const redisVal = r.redis.error ? '—' : formatNum(r.redis.opsPerSec);
|
|
619
614
|
const templateVals = templateNames.map((t) => (r.templates[t]?.error ? '—' : formatNum(r.templates[t]?.opsPerSec)));
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
615
|
+
return { suite: r.suite, values: [redisVal, ...templateVals] };
|
|
616
|
+
});
|
|
617
|
+
|
|
618
|
+
const suiteWidth = Math.max(
|
|
619
|
+
headerCols[0].length,
|
|
620
|
+
...summaryRows.map((r) => r.suite.length)
|
|
621
|
+
);
|
|
622
|
+
const valueWidths = headerCols.slice(1).map((h, idx) =>
|
|
623
|
+
Math.max(
|
|
624
|
+
h.length,
|
|
625
|
+
...summaryRows.map((r) => (r.values[idx] || '').length)
|
|
626
|
+
)
|
|
627
|
+
);
|
|
628
|
+
|
|
629
|
+
console.log('');
|
|
630
|
+
console.log('--- Summary (ops/sec) ---');
|
|
631
|
+
console.log([
|
|
632
|
+
headerCols[0].padEnd(suiteWidth),
|
|
633
|
+
...headerCols.slice(1).map((h, idx) => h.padStart(valueWidths[idx])),
|
|
634
|
+
].join(' | '));
|
|
635
|
+
console.log([
|
|
636
|
+
'-'.repeat(suiteWidth),
|
|
637
|
+
...valueWidths.map((w) => '-'.repeat(w)),
|
|
638
|
+
].join('-|-'));
|
|
639
|
+
|
|
640
|
+
for (const row of summaryRows) {
|
|
641
|
+
console.log([
|
|
642
|
+
row.suite.padEnd(suiteWidth),
|
|
643
|
+
...row.values.map((v, idx) => v.padStart(valueWidths[idx])),
|
|
644
|
+
].join(' | '));
|
|
623
645
|
}
|
|
624
646
|
|
|
625
647
|
console.log('');
|
package/src/commands/command.js
CHANGED
|
@@ -15,42 +15,42 @@ const WRITE_COMMANDS = new Set([
|
|
|
15
15
|
* @param {string} name - Command name (lowercase for reply).
|
|
16
16
|
* @returns {Array<string|number|string[]>}
|
|
17
17
|
*/
|
|
18
|
-
function docFor(name) {
|
|
18
|
+
function docFor(name, canonicalName = name) {
|
|
19
19
|
const lower = name.toLowerCase();
|
|
20
|
-
const flags = WRITE_COMMANDS.has(
|
|
20
|
+
const flags = WRITE_COMMANDS.has(canonicalName) ? ['write', 'fast'] : ['readonly', 'fast'];
|
|
21
21
|
let arity = 2;
|
|
22
22
|
let firstKey = 1;
|
|
23
23
|
let lastKey = 1;
|
|
24
24
|
let step = 1;
|
|
25
|
-
if (['MGET', 'MSET', 'DEL', 'UNLINK', 'EXISTS', 'KEYS', 'SCAN', 'PING', 'ECHO', 'QUIT', 'TYPE', 'OBJECT', 'SQLITE.INFO', 'CACHE.INFO', 'MEMORY.INFO', 'COMMAND', 'MONITOR', 'CLIENT'].includes(
|
|
26
|
-
if (['PING', 'ECHO', 'QUIT', 'COMMAND', 'MONITOR'].includes(
|
|
25
|
+
if (['MGET', 'MSET', 'DEL', 'UNLINK', 'EXISTS', 'KEYS', 'SCAN', 'PING', 'ECHO', 'QUIT', 'TYPE', 'OBJECT', 'SQLITE.INFO', 'CACHE.INFO', 'MEMORY.INFO', 'COMMAND', 'MONITOR', 'CLIENT'].includes(canonicalName)) {
|
|
26
|
+
if (['PING', 'ECHO', 'QUIT', 'COMMAND', 'MONITOR'].includes(canonicalName)) {
|
|
27
27
|
firstKey = 0;
|
|
28
28
|
lastKey = 0;
|
|
29
29
|
step = 0;
|
|
30
|
-
arity =
|
|
31
|
-
} else if (['MGET', 'EXISTS', 'KEYS', 'SCAN'].includes(
|
|
30
|
+
arity = canonicalName === 'COMMAND' ? -1 : (canonicalName === 'ECHO' ? 2 : 1);
|
|
31
|
+
} else if (['MGET', 'EXISTS', 'KEYS', 'SCAN'].includes(canonicalName)) {
|
|
32
32
|
arity = -2;
|
|
33
33
|
lastKey = -1;
|
|
34
|
-
} else if (
|
|
34
|
+
} else if (canonicalName === 'MSET') {
|
|
35
35
|
arity = -3;
|
|
36
36
|
lastKey = -1;
|
|
37
37
|
step = 2;
|
|
38
|
-
} else if (['DEL', 'UNLINK'].includes(
|
|
38
|
+
} else if (['DEL', 'UNLINK'].includes(canonicalName)) {
|
|
39
39
|
arity = -2;
|
|
40
40
|
lastKey = -1;
|
|
41
41
|
}
|
|
42
|
-
} else if (
|
|
42
|
+
} else if (canonicalName.startsWith('FT.') || canonicalName.startsWith('SQLITE.') || canonicalName.startsWith('CACHE.') || canonicalName.startsWith('MEMORY.')) {
|
|
43
43
|
firstKey = 0;
|
|
44
44
|
lastKey = 0;
|
|
45
45
|
step = 0;
|
|
46
46
|
arity = -2;
|
|
47
|
-
} else if (['BLPOP', 'BRPOP'].includes(
|
|
47
|
+
} else if (['BLPOP', 'BRPOP'].includes(canonicalName)) {
|
|
48
48
|
arity = -3;
|
|
49
49
|
lastKey = -1;
|
|
50
50
|
step = 1;
|
|
51
|
-
} else if (['HMGET', 'HGETALL', 'HGET', 'HSET', 'HDEL', 'HEXISTS', 'HINCRBY', 'HLEN'].includes(
|
|
52
|
-
arity = (
|
|
53
|
-
} else if (
|
|
51
|
+
} else if (['HMGET', 'HGETALL', 'HGET', 'HSET', 'HDEL', 'HEXISTS', 'HINCRBY', 'HLEN'].includes(canonicalName)) {
|
|
52
|
+
arity = (canonicalName === 'HGET' || canonicalName === 'HLEN' || canonicalName === 'HEXISTS') ? 3 : -3;
|
|
53
|
+
} else if (canonicalName === 'SETEX') {
|
|
54
54
|
arity = 4;
|
|
55
55
|
}
|
|
56
56
|
return [lower, arity, flags, firstKey, lastKey, step, []];
|
|
@@ -63,10 +63,13 @@ function docFor(name) {
|
|
|
63
63
|
*/
|
|
64
64
|
export function handleCommand(engine, args, context) {
|
|
65
65
|
const allNames = context?.getCommandNames ? context.getCommandNames() : [];
|
|
66
|
+
const resolveCanonical = context?.resolveCommandForIntrospection
|
|
67
|
+
? (name) => context.resolveCommandForIntrospection(name)
|
|
68
|
+
: (name) => name;
|
|
66
69
|
const sub = (args && args.length > 0 && Buffer.isBuffer(args[0])) ? args[0].toString('utf8').toUpperCase() : '';
|
|
67
70
|
|
|
68
71
|
if (!sub || sub === '') {
|
|
69
|
-
const reply = allNames.map((n) => docFor(n));
|
|
72
|
+
const reply = allNames.map((n) => docFor(n, resolveCanonical(n)));
|
|
70
73
|
return reply;
|
|
71
74
|
}
|
|
72
75
|
if (sub === 'COUNT') {
|
|
@@ -75,7 +78,7 @@ export function handleCommand(engine, args, context) {
|
|
|
75
78
|
if (sub === 'INFO') {
|
|
76
79
|
const names = (args.slice(1) || []).map((b) => (Buffer.isBuffer(b) ? b.toString('utf8') : String(b)).toUpperCase());
|
|
77
80
|
const set = new Set(allNames);
|
|
78
|
-
const reply = names.map((n) => set.has(n) ? docFor(n) : null).filter((x) => x != null);
|
|
81
|
+
const reply = names.map((n) => set.has(n) ? docFor(n, resolveCanonical(n)) : null).filter((x) => x != null);
|
|
79
82
|
return reply;
|
|
80
83
|
}
|
|
81
84
|
|
package/src/commands/registry.js
CHANGED
|
@@ -91,6 +91,8 @@ import * as monitor from './monitor.js';
|
|
|
91
91
|
import * as client from './client.js';
|
|
92
92
|
import * as command from './command.js';
|
|
93
93
|
|
|
94
|
+
const COMPILED_POLICY_TAG = Symbol('compiled-command-policy');
|
|
95
|
+
|
|
94
96
|
const HANDLERS = new Map([
|
|
95
97
|
['PING', (e, a) => ping.handlePing()],
|
|
96
98
|
['ECHO', (e, a) => echo.handleEcho(a)],
|
|
@@ -181,6 +183,100 @@ const HANDLERS = new Map([
|
|
|
181
183
|
['COMMAND', (e, a, ctx) => command.handleCommand(e, a, ctx)],
|
|
182
184
|
]);
|
|
183
185
|
|
|
186
|
+
function assertCommandName(value, field) {
|
|
187
|
+
if (typeof value !== 'string' || value.trim() === '') {
|
|
188
|
+
throw new Error(`invalid command policy: ${field} must be a non-empty string`);
|
|
189
|
+
}
|
|
190
|
+
return value.trim().toUpperCase();
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function isCompiledPolicy(value) {
|
|
194
|
+
return !!(value && typeof value === 'object' && value[COMPILED_POLICY_TAG] === true);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
export function compileCommandPolicy(policy = {}) {
|
|
198
|
+
if (policy == null) return null;
|
|
199
|
+
if (isCompiledPolicy(policy)) return policy;
|
|
200
|
+
if (typeof policy !== 'object') {
|
|
201
|
+
throw new Error('invalid command policy: expected an object');
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
const rename = policy.rename ?? {};
|
|
205
|
+
const disabled = policy.disabled ?? [];
|
|
206
|
+
|
|
207
|
+
if (rename == null || typeof rename !== 'object' || Array.isArray(rename)) {
|
|
208
|
+
throw new Error('invalid command policy: rename must be an object');
|
|
209
|
+
}
|
|
210
|
+
if (!Array.isArray(disabled)) {
|
|
211
|
+
throw new Error('invalid command policy: disabled must be an array');
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
const knownCommands = new Set(HANDLERS.keys());
|
|
215
|
+
const renamedOriginals = new Set();
|
|
216
|
+
const aliasToOriginal = new Map();
|
|
217
|
+
|
|
218
|
+
for (const [rawOriginal, rawAlias] of Object.entries(rename)) {
|
|
219
|
+
const original = assertCommandName(rawOriginal, 'rename key');
|
|
220
|
+
const alias = assertCommandName(rawAlias, `rename.${rawOriginal}`);
|
|
221
|
+
if (!knownCommands.has(original)) {
|
|
222
|
+
throw new Error(`invalid command policy: cannot rename unknown command "${original}"`);
|
|
223
|
+
}
|
|
224
|
+
if (alias === original) {
|
|
225
|
+
throw new Error(`invalid command policy: alias for "${original}" must be different`);
|
|
226
|
+
}
|
|
227
|
+
if (knownCommands.has(alias)) {
|
|
228
|
+
throw new Error(`invalid command policy: alias "${alias}" conflicts with existing command`);
|
|
229
|
+
}
|
|
230
|
+
if (aliasToOriginal.has(alias)) {
|
|
231
|
+
throw new Error(`invalid command policy: alias "${alias}" is duplicated`);
|
|
232
|
+
}
|
|
233
|
+
renamedOriginals.add(original);
|
|
234
|
+
aliasToOriginal.set(alias, original);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
const disabledSet = new Set();
|
|
238
|
+
for (const rawName of disabled) {
|
|
239
|
+
const name = assertCommandName(rawName, 'disabled[] item');
|
|
240
|
+
disabledSet.add(name);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
return {
|
|
244
|
+
[COMPILED_POLICY_TAG]: true,
|
|
245
|
+
disabledSet,
|
|
246
|
+
aliasToOriginal,
|
|
247
|
+
renamedOriginals,
|
|
248
|
+
};
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
function listVisibleCommandNames(policy) {
|
|
252
|
+
const names = [];
|
|
253
|
+
for (const name of HANDLERS.keys()) {
|
|
254
|
+
if (policy?.renamedOriginals?.has(name)) continue;
|
|
255
|
+
if (policy?.disabledSet?.has(name)) continue;
|
|
256
|
+
names.push(name);
|
|
257
|
+
}
|
|
258
|
+
if (policy?.aliasToOriginal) {
|
|
259
|
+
for (const [alias] of policy.aliasToOriginal.entries()) {
|
|
260
|
+
if (policy.disabledSet.has(alias)) continue;
|
|
261
|
+
names.push(alias);
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
return names;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
function resolveIncomingCommand(inputCommand, policy) {
|
|
268
|
+
if (!policy) return { blocked: false, resolvedCommand: inputCommand };
|
|
269
|
+
if (policy.disabledSet.has(inputCommand)) {
|
|
270
|
+
return { blocked: true, resolvedCommand: inputCommand };
|
|
271
|
+
}
|
|
272
|
+
const target = policy.aliasToOriginal.get(inputCommand);
|
|
273
|
+
if (target) return { blocked: false, resolvedCommand: target };
|
|
274
|
+
if (policy.renamedOriginals.has(inputCommand)) {
|
|
275
|
+
return { blocked: true, resolvedCommand: inputCommand };
|
|
276
|
+
}
|
|
277
|
+
return { blocked: false, resolvedCommand: inputCommand };
|
|
278
|
+
}
|
|
279
|
+
|
|
184
280
|
/**
|
|
185
281
|
* Dispatch command. Full argv: [commandNameBuf, ...argBuffers].
|
|
186
282
|
* @param {object} engine
|
|
@@ -195,8 +291,27 @@ export function dispatch(engine, argv, context) {
|
|
|
195
291
|
const cmd = (Buffer.isBuffer(argv[0]) ? argv[0].toString('utf8') : String(argv[0])).toUpperCase();
|
|
196
292
|
const args = argv.slice(1);
|
|
197
293
|
const argvStrings = argv.map((b) => (Buffer.isBuffer(b) ? b.toString('utf8') : String(b)));
|
|
198
|
-
|
|
199
|
-
const
|
|
294
|
+
const policy = compileCommandPolicy(context?.commandPolicy);
|
|
295
|
+
const commandResolution = resolveIncomingCommand(cmd, policy);
|
|
296
|
+
if (commandResolution.blocked) {
|
|
297
|
+
context?.onUnknownCommand?.({
|
|
298
|
+
command: cmd,
|
|
299
|
+
argsCount: args.length,
|
|
300
|
+
argv: argvStrings ?? [cmd],
|
|
301
|
+
clientAddress: context?.clientAddress ?? '',
|
|
302
|
+
connectionId: context?.connectionId ?? 0,
|
|
303
|
+
});
|
|
304
|
+
return { error: unsupported() };
|
|
305
|
+
}
|
|
306
|
+
const resolvedCommand = commandResolution.resolvedCommand;
|
|
307
|
+
if (context) {
|
|
308
|
+
context.getCommandNames = () => listVisibleCommandNames(policy);
|
|
309
|
+
context.resolveCommandForIntrospection = (name) => {
|
|
310
|
+
if (!policy) return name;
|
|
311
|
+
return policy.aliasToOriginal.get(String(name).toUpperCase()) ?? String(name).toUpperCase();
|
|
312
|
+
};
|
|
313
|
+
}
|
|
314
|
+
const handler = HANDLERS.get(resolvedCommand);
|
|
200
315
|
if (!handler) {
|
|
201
316
|
context?.onUnknownCommand?.({
|
|
202
317
|
command: cmd,
|
package/src/embed.js
CHANGED
|
@@ -13,6 +13,7 @@ import net from 'node:net';
|
|
|
13
13
|
import { handleConnection } from './server/connection.js';
|
|
14
14
|
import { createEngine } from './engine/engine.js';
|
|
15
15
|
import { openDb } from './storage/sqlite/db.js';
|
|
16
|
+
import { compileCommandPolicy } from './commands/registry.js';
|
|
16
17
|
|
|
17
18
|
export { handleConnection, createEngine, openDb };
|
|
18
19
|
|
|
@@ -37,6 +38,7 @@ export { handleConnection, createEngine, openDb };
|
|
|
37
38
|
* @param {Record<string, string|number>} [options.pragma] Override specific pragmas only when needed (e.g. { synchronous: 'FULL' }). Applied after the template.
|
|
38
39
|
* @param {RESPliteHooks} [options.hooks] Optional event hooks for observability (onUnknownCommand, onCommandError, onSocketError).
|
|
39
40
|
* @param {boolean} [options.gracefulShutdown=true] If true, register SIGTERM/SIGINT to call close(). Set false if you handle shutdown yourself to avoid double handlers.
|
|
41
|
+
* @param {{ rename?: Record<string, string>, disabled?: string[] } | null} [options.commandPolicy] Optional: rename/disable commands for hardening.
|
|
40
42
|
* @returns {Promise<{ port: number, host: string, close: () => Promise<void> }>}
|
|
41
43
|
*/
|
|
42
44
|
export async function createRESPlite({
|
|
@@ -47,7 +49,9 @@ export async function createRESPlite({
|
|
|
47
49
|
pragma,
|
|
48
50
|
hooks = {},
|
|
49
51
|
gracefulShutdown = true,
|
|
52
|
+
commandPolicy = null,
|
|
50
53
|
} = {}) {
|
|
54
|
+
const compiledCommandPolicy = compileCommandPolicy(commandPolicy);
|
|
51
55
|
const db = openDb(dbPath, { pragmaTemplate, pragma });
|
|
52
56
|
const engine = createEngine({ db });
|
|
53
57
|
const connections = new Set();
|
|
@@ -55,7 +59,7 @@ export async function createRESPlite({
|
|
|
55
59
|
const server = net.createServer((socket) => {
|
|
56
60
|
connections.add(socket);
|
|
57
61
|
socket.once('close', () => connections.delete(socket));
|
|
58
|
-
handleConnection(socket, engine, hooks);
|
|
62
|
+
handleConnection(socket, engine, hooks, compiledCommandPolicy);
|
|
59
63
|
});
|
|
60
64
|
await new Promise((resolve) => server.listen(port, host, resolve));
|
|
61
65
|
|
package/src/index.js
CHANGED
|
@@ -26,6 +26,7 @@ const DEFAULT_PORT = 6379;
|
|
|
26
26
|
* @param {string} [options.pragmaTemplate]
|
|
27
27
|
* @param {Record<string, string|number>} [options.pragma] Override specific pragmas when needed (e.g. { synchronous: 'FULL' }). Convention: template is applied by default.
|
|
28
28
|
* @param {boolean} [options.gracefulShutdown=true] If true, register SIGTERM/SIGINT to close server and DB. Set false if you handle shutdown yourself.
|
|
29
|
+
* @param {{ rename?: Record<string, string>, disabled?: string[] } | null} [options.commandPolicy] Optional: rename/disable commands for hardening.
|
|
29
30
|
*/
|
|
30
31
|
export function startServer(options = {}) {
|
|
31
32
|
const dbPath = options.dbPath ?? process.env.RESPLITE_DB ?? DEFAULT_DB_PATH;
|
|
@@ -45,7 +46,7 @@ export function startServer(options = {}) {
|
|
|
45
46
|
sweeper.start();
|
|
46
47
|
|
|
47
48
|
const connections = new Set();
|
|
48
|
-
const server = createServer({ engine, port, connections });
|
|
49
|
+
const server = createServer({ engine, port, connections, commandPolicy: options.commandPolicy ?? null });
|
|
49
50
|
|
|
50
51
|
if (gracefulShutdown) {
|
|
51
52
|
let shuttingDown = false;
|
package/src/server/connection.js
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
5
|
import { RESPReader } from '../resp/parser.js';
|
|
6
|
-
import { dispatch } from '../commands/registry.js';
|
|
6
|
+
import { compileCommandPolicy, dispatch } from '../commands/registry.js';
|
|
7
7
|
import { encode, encodeSimpleString, encodeError } from '../resp/encoder.js';
|
|
8
8
|
import { registerMonitorClient, unregisterMonitorClient, broadcastMonitorCommand } from './monitor.js';
|
|
9
9
|
|
|
@@ -13,11 +13,13 @@ let nextConnectionId = 0;
|
|
|
13
13
|
* @param {import('net').Socket} socket
|
|
14
14
|
* @param {object} engine
|
|
15
15
|
* @param {object} [hooks] Optional: onUnknownCommand, onCommandError, onSocketError
|
|
16
|
+
* @param {object|null} [commandPolicy] Optional: command rename/disable policy.
|
|
16
17
|
*/
|
|
17
|
-
export function handleConnection(socket, engine, hooks = {}) {
|
|
18
|
+
export function handleConnection(socket, engine, hooks = {}, commandPolicy = null) {
|
|
18
19
|
const reader = new RESPReader();
|
|
19
20
|
const connectionId = ++nextConnectionId;
|
|
20
21
|
const clientAddress = `${socket.remoteAddress ?? 'unknown'}:${socket.remotePort ?? 0}`;
|
|
22
|
+
const compiledCommandPolicy = compileCommandPolicy(commandPolicy);
|
|
21
23
|
const context = {
|
|
22
24
|
connectionId,
|
|
23
25
|
clientAddress,
|
|
@@ -29,6 +31,7 @@ export function handleConnection(socket, engine, hooks = {}) {
|
|
|
29
31
|
onUnknownCommand: hooks.onUnknownCommand,
|
|
30
32
|
onCommandError: hooks.onCommandError,
|
|
31
33
|
onSocketError: hooks.onSocketError,
|
|
34
|
+
commandPolicy: compiledCommandPolicy,
|
|
32
35
|
};
|
|
33
36
|
|
|
34
37
|
function writeResult(out) {
|
package/src/server/tcp-server.js
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
|
|
5
5
|
import net from 'node:net';
|
|
6
6
|
import { handleConnection } from './connection.js';
|
|
7
|
+
import { compileCommandPolicy } from '../commands/registry.js';
|
|
7
8
|
|
|
8
9
|
/**
|
|
9
10
|
* @param {object} options
|
|
@@ -11,15 +12,17 @@ import { handleConnection } from './connection.js';
|
|
|
11
12
|
* @param {number} [options.port=6379]
|
|
12
13
|
* @param {string} [options.host='0.0.0.0']
|
|
13
14
|
* @param {Set<import('node:net').Socket>} [options.connections] If provided, each accepted socket is added here (for graceful shutdown).
|
|
15
|
+
* @param {object|null} [options.commandPolicy] Optional: command rename/disable policy.
|
|
14
16
|
* @returns {import('node:net').Server}
|
|
15
17
|
*/
|
|
16
|
-
export function createServer({ engine, port = 6379, host = '0.0.0.0', connections = null }) {
|
|
18
|
+
export function createServer({ engine, port = 6379, host = '0.0.0.0', connections = null, commandPolicy = null }) {
|
|
19
|
+
const compiledCommandPolicy = compileCommandPolicy(commandPolicy);
|
|
17
20
|
const server = net.createServer((socket) => {
|
|
18
21
|
if (connections) {
|
|
19
22
|
connections.add(socket);
|
|
20
23
|
socket.once('close', () => connections.delete(socket));
|
|
21
24
|
}
|
|
22
|
-
handleConnection(socket, engine);
|
|
25
|
+
handleConnection(socket, engine, {}, compiledCommandPolicy);
|
|
23
26
|
});
|
|
24
27
|
return server;
|
|
25
28
|
}
|
package/test/helpers/server.js
CHANGED
|
@@ -7,17 +7,20 @@ import net from 'node:net';
|
|
|
7
7
|
import { handleConnection } from '../../src/server/connection.js';
|
|
8
8
|
import { createEngine } from '../../src/engine/engine.js';
|
|
9
9
|
import { openDb } from '../../src/storage/sqlite/db.js';
|
|
10
|
+
import { compileCommandPolicy } from '../../src/commands/registry.js';
|
|
10
11
|
import { tmpDbPath } from './tmp.js';
|
|
11
12
|
|
|
12
13
|
export function createTestServer(options = {}) {
|
|
13
14
|
const dbPath = options.dbPath || tmpDbPath();
|
|
14
15
|
const db = openDb(dbPath);
|
|
15
16
|
const engine = createEngine({ db });
|
|
17
|
+
const hooks = options.hooks || {};
|
|
18
|
+
const commandPolicy = compileCommandPolicy(options.commandPolicy ?? null);
|
|
16
19
|
const connections = new Set();
|
|
17
20
|
const server = net.createServer((socket) => {
|
|
18
21
|
connections.add(socket);
|
|
19
22
|
socket.once('close', () => connections.delete(socket));
|
|
20
|
-
handleConnection(socket, engine);
|
|
23
|
+
handleConnection(socket, engine, hooks, commandPolicy);
|
|
21
24
|
});
|
|
22
25
|
return new Promise((resolve, reject) => {
|
|
23
26
|
server.listen(0, '127.0.0.1', () => {
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Integration tests for command hardening policy (rename/disable).
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { describe, it, before, after } from 'node:test';
|
|
6
|
+
import assert from 'node:assert/strict';
|
|
7
|
+
import { createTestServer } from '../helpers/server.js';
|
|
8
|
+
import { sendCommand, argv } from '../helpers/client.js';
|
|
9
|
+
import { tryParseValue } from '../../src/resp/parser.js';
|
|
10
|
+
|
|
11
|
+
function parseResp(buffer) {
|
|
12
|
+
const parsed = tryParseValue(buffer, 0);
|
|
13
|
+
return parsed ? parsed.value : null;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function asUtf8(value) {
|
|
17
|
+
return Buffer.isBuffer(value) ? value.toString('utf8') : String(value);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
describe('command hardening policy', () => {
|
|
21
|
+
let s;
|
|
22
|
+
let port;
|
|
23
|
+
|
|
24
|
+
before(async () => {
|
|
25
|
+
s = await createTestServer({
|
|
26
|
+
commandPolicy: {
|
|
27
|
+
rename: {
|
|
28
|
+
KEYS: 'SAFE_KEYS',
|
|
29
|
+
DEL: 'RMDEL',
|
|
30
|
+
},
|
|
31
|
+
disabled: ['MONITOR'],
|
|
32
|
+
},
|
|
33
|
+
});
|
|
34
|
+
port = s.port;
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
after(async () => {
|
|
38
|
+
await s.closeAsync();
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it('renamed command is available only through alias', async () => {
|
|
42
|
+
const setReply = parseResp(await sendCommand(port, argv('SET', 'policy:k1', 'v1')));
|
|
43
|
+
assert.equal(asUtf8(setReply), 'OK');
|
|
44
|
+
|
|
45
|
+
const aliasReply = parseResp(await sendCommand(port, argv('SAFE_KEYS', 'policy:*')));
|
|
46
|
+
assert.ok(Array.isArray(aliasReply));
|
|
47
|
+
assert.ok(aliasReply.map(asUtf8).includes('policy:k1'));
|
|
48
|
+
|
|
49
|
+
const oldNameReply = parseResp(await sendCommand(port, argv('KEYS', 'policy:*')));
|
|
50
|
+
assert.ok(oldNameReply && oldNameReply.error);
|
|
51
|
+
assert.equal(asUtf8(oldNameReply.error), 'ERR command not supported yet');
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it('explicitly disabled command returns unsupported', async () => {
|
|
55
|
+
const reply = parseResp(await sendCommand(port, argv('MONITOR')));
|
|
56
|
+
assert.ok(reply && reply.error);
|
|
57
|
+
assert.equal(asUtf8(reply.error), 'ERR command not supported yet');
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it('COMMAND only exposes visible names and keeps canonical metadata', async () => {
|
|
61
|
+
const listReply = parseResp(await sendCommand(port, argv('COMMAND')));
|
|
62
|
+
assert.ok(Array.isArray(listReply));
|
|
63
|
+
const names = listReply.map((doc) => asUtf8(doc[0]).toUpperCase());
|
|
64
|
+
assert.ok(names.includes('SAFE_KEYS'));
|
|
65
|
+
assert.ok(names.includes('RMDEL'));
|
|
66
|
+
assert.ok(!names.includes('KEYS'));
|
|
67
|
+
assert.ok(!names.includes('DEL'));
|
|
68
|
+
assert.ok(!names.includes('MONITOR'));
|
|
69
|
+
|
|
70
|
+
const infoReply = parseResp(await sendCommand(port, argv('COMMAND', 'INFO', 'RMDEL')));
|
|
71
|
+
assert.ok(Array.isArray(infoReply));
|
|
72
|
+
assert.equal(infoReply.length, 1);
|
|
73
|
+
const rmDelDoc = infoReply[0];
|
|
74
|
+
assert.equal(asUtf8(rmDelDoc[0]), 'rmdel');
|
|
75
|
+
assert.equal(rmDelDoc[1], -2);
|
|
76
|
+
const flags = rmDelDoc[2].map(asUtf8);
|
|
77
|
+
assert.ok(flags.includes('write'));
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it('renamed write command executes through alias and blocks original', async () => {
|
|
81
|
+
await sendCommand(port, argv('SET', 'policy:del', 'to-delete'));
|
|
82
|
+
|
|
83
|
+
const aliasDeleteReply = parseResp(await sendCommand(port, argv('RMDEL', 'policy:del')));
|
|
84
|
+
assert.equal(aliasDeleteReply, 1);
|
|
85
|
+
|
|
86
|
+
const originalDeleteReply = parseResp(await sendCommand(port, argv('DEL', 'policy:del')));
|
|
87
|
+
assert.ok(originalDeleteReply && originalDeleteReply.error);
|
|
88
|
+
assert.equal(asUtf8(originalDeleteReply.error), 'ERR command not supported yet');
|
|
89
|
+
});
|
|
90
|
+
});
|
|
@@ -142,6 +142,33 @@ describe('createRESPlite', () => {
|
|
|
142
142
|
assert.ok(sub.clientAddress.length > 0);
|
|
143
143
|
});
|
|
144
144
|
|
|
145
|
+
it('onUnknownCommand hook is also called for disabled commands', async () => {
|
|
146
|
+
const unknownCalls = [];
|
|
147
|
+
const srv = await createRESPlite({
|
|
148
|
+
commandPolicy: {
|
|
149
|
+
disabled: ['MONITOR'],
|
|
150
|
+
},
|
|
151
|
+
hooks: {
|
|
152
|
+
onUnknownCommand(payload) {
|
|
153
|
+
unknownCalls.push(payload);
|
|
154
|
+
},
|
|
155
|
+
},
|
|
156
|
+
});
|
|
157
|
+
const client = await redisClient(srv.port);
|
|
158
|
+
try {
|
|
159
|
+
await client.sendCommand(['MONITOR']);
|
|
160
|
+
assert.fail('expected error');
|
|
161
|
+
} catch (e) {
|
|
162
|
+
assert.ok(e.message.includes('not supported'), e.message);
|
|
163
|
+
}
|
|
164
|
+
await client.quit();
|
|
165
|
+
await srv.close();
|
|
166
|
+
|
|
167
|
+
assert.equal(unknownCalls.length, 1);
|
|
168
|
+
assert.equal(unknownCalls[0].command, 'MONITOR');
|
|
169
|
+
assert.equal(unknownCalls[0].argsCount, 0);
|
|
170
|
+
});
|
|
171
|
+
|
|
145
172
|
it('onCommandError hook is called when command returns or throws error', async () => {
|
|
146
173
|
const errorCalls = [];
|
|
147
174
|
const srv = await createRESPlite({
|
|
@@ -27,7 +27,13 @@ const PREFIX = `__resplite_tracker_test_${process.pid}__`;
|
|
|
27
27
|
|
|
28
28
|
/** Connect to local Redis; return null if unavailable. */
|
|
29
29
|
async function tryConnectRedis() {
|
|
30
|
-
const client = createClient({
|
|
30
|
+
const client = createClient({
|
|
31
|
+
url: REDIS_URL,
|
|
32
|
+
socket: {
|
|
33
|
+
connectTimeout: 1500,
|
|
34
|
+
reconnectStrategy: () => new Error('no reconnect in tests'),
|
|
35
|
+
},
|
|
36
|
+
});
|
|
31
37
|
try {
|
|
32
38
|
await client.connect();
|
|
33
39
|
await client.ping();
|