@shibbirweb/mcp-db-read-only 0.1.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/CHANGELOG.md +22 -0
- package/LICENSE +21 -0
- package/README.dockerhub.md +354 -0
- package/README.md +387 -0
- package/dist/ApplicationFactory.js +144 -0
- package/dist/config/EnvironmentConfigLoader.js +179 -0
- package/dist/config/PackageVersionLoader.js +43 -0
- package/dist/connections/ConnectionManager.js +101 -0
- package/dist/connections/ConnectionRegistry.js +109 -0
- package/dist/connections/ConnectionTargetFactory.js +104 -0
- package/dist/connections/ConnectionUrlParser.js +195 -0
- package/dist/domain/ConnectionProfile.js +39 -0
- package/dist/domain/ConnectionTarget.js +139 -0
- package/dist/domain/Engine.js +159 -0
- package/dist/drivers/BaseDriver.js +35 -0
- package/dist/drivers/DatabaseDriver.js +1 -0
- package/dist/drivers/DriverCache.js +107 -0
- package/dist/drivers/DriverProvider.js +71 -0
- package/dist/drivers/DriverRegistry.js +24 -0
- package/dist/drivers/GlobPattern.js +41 -0
- package/dist/drivers/LazyResource.js +56 -0
- package/dist/drivers/document/MongoDriver.js +187 -0
- package/dist/drivers/document/MongoSchemaSampler.js +74 -0
- package/dist/drivers/document/MongoStageAllowlist.js +87 -0
- package/dist/drivers/keyvalue/RedisCommandFlagsGuard.js +69 -0
- package/dist/drivers/keyvalue/RedisDriver.js +224 -0
- package/dist/drivers/search/ElasticsearchDriver.js +159 -0
- package/dist/drivers/sql/ClickHouseDriver.js +156 -0
- package/dist/drivers/sql/MsSqlDriver.js +147 -0
- package/dist/drivers/sql/MySqlDriver.js +144 -0
- package/dist/drivers/sql/MySqlSessionInitializer.js +100 -0
- package/dist/drivers/sql/PostgresDriver.js +176 -0
- package/dist/drivers/sql/SqlIdentifier.js +36 -0
- package/dist/drivers/sql/SqliteDriver.js +202 -0
- package/dist/drivers/sql/SqliteProtocol.js +7 -0
- package/dist/drivers/sql/SqliteWorker.js +71 -0
- package/dist/errors/ApplicationError.js +15 -0
- package/dist/errors/EngineMismatchError.js +15 -0
- package/dist/errors/InvalidConnectionUrlError.js +14 -0
- package/dist/errors/InvalidProfileDefinitionError.js +15 -0
- package/dist/errors/NoActiveConnectionError.js +13 -0
- package/dist/errors/NoDatabaseSelectedError.js +13 -0
- package/dist/errors/ObjectNotFoundError.js +14 -0
- package/dist/errors/UnknownProfileError.js +16 -0
- package/dist/errors/UnsupportedOperationError.js +14 -0
- package/dist/errors/index.js +9 -0
- package/dist/formatting/JsonSerializer.js +49 -0
- package/dist/formatting/RowFormatter.js +43 -0
- package/dist/formatting/ToolResponse.js +25 -0
- package/dist/index.js +15 -0
- package/dist/server/McpDbServer.js +69 -0
- package/dist/tools/BaseTool.js +42 -0
- package/dist/tools/DatabaseScopedTool.js +61 -0
- package/dist/tools/QueryTools.js +13 -0
- package/dist/tools/browse/DescribeTableTool.js +37 -0
- package/dist/tools/browse/GetForeignKeysTool.js +36 -0
- package/dist/tools/browse/GetTableIndexesTool.js +30 -0
- package/dist/tools/browse/GetTableSampleTool.js +50 -0
- package/dist/tools/browse/ListTablesTool.js +51 -0
- package/dist/tools/connection/ConnectTool.js +67 -0
- package/dist/tools/connection/CurrentConnectionTool.js +36 -0
- package/dist/tools/connection/ListConnectionsTool.js +38 -0
- package/dist/tools/connection/ListDatabasesTool.js +41 -0
- package/dist/tools/connection/UseConnectionTool.js +57 -0
- package/dist/tools/connection/UseDatabaseTool.js +45 -0
- package/dist/tools/document/AggregateTool.js +44 -0
- package/dist/tools/document/CountDocumentsTool.js +32 -0
- package/dist/tools/document/DistinctValuesTool.js +36 -0
- package/dist/tools/document/DocumentTool.js +38 -0
- package/dist/tools/document/FindDocumentsTool.js +56 -0
- package/dist/tools/keyvalue/RedisCommandTool.js +40 -0
- package/dist/tools/search/SearchTool.js +54 -0
- package/dist/tools/sql/RunQueryTool.js +46 -0
- package/dist/types/config.types.js +1 -0
- package/dist/types/connection.types.js +1 -0
- package/dist/types/driver.types.js +1 -0
- package/dist/types/index.js +1 -0
- package/dist/types/tool.types.js +1 -0
- package/dist/types/validation.types.js +1 -0
- package/dist/validation/document/MongoOperatorGuard.js +72 -0
- package/dist/validation/keyvalue/RedisCommandValidator.js +176 -0
- package/dist/validation/names/NamePolicy.js +122 -0
- package/dist/validation/names/NamePolicyRegistry.js +33 -0
- package/dist/validation/search/SearchBodyValidator.js +56 -0
- package/dist/validation/sql/ReadOnlyQueryValidator.js +82 -0
- package/dist/validation/sql/SqlDialect.js +196 -0
- package/dist/validation/sql/SqlSkeletonizer.js +197 -0
- package/dist/validation/sql/SqlValidatorRegistry.js +24 -0
- package/dist/validation/sql/rules/AmbiguousSyntaxRule.js +23 -0
- package/dist/validation/sql/rules/EmptyQueryRule.js +16 -0
- package/dist/validation/sql/rules/ForbiddenPatternRule.js +30 -0
- package/dist/validation/sql/rules/LeadingKeywordRule.js +29 -0
- package/dist/validation/sql/rules/SingleStatementRule.js +26 -0
- package/dist/validation/sql/rules/SmuggledWriteRule.js +50 -0
- package/dist/validation/sql/rules/index.js +6 -0
- package/package.json +76 -0
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { DatabaseScopedTool } from "../DatabaseScopedTool.js";
|
|
3
|
+
import { ToolResponse } from "../../formatting/ToolResponse.js";
|
|
4
|
+
/**
|
|
5
|
+
* An Elasticsearch or OpenSearch `_search`, with the body in Query DSL.
|
|
6
|
+
*
|
|
7
|
+
* One tool rather than separate search and count tools: `size: 0` with
|
|
8
|
+
* `track_total_hits: true` is a count, and aggregations ride in the same
|
|
9
|
+
* body, so a second tool would only duplicate this one's argument.
|
|
10
|
+
*/
|
|
11
|
+
export class SearchTool extends DatabaseScopedTool {
|
|
12
|
+
bodies;
|
|
13
|
+
name = "search";
|
|
14
|
+
description = "Search an Elasticsearch or OpenSearch index with a Query DSL body. Use size 0 with track_total_hits true to count, and aggs for aggregations";
|
|
15
|
+
family = "search";
|
|
16
|
+
static MAX_SIZE = 100;
|
|
17
|
+
static DEFAULT_SIZE = 10;
|
|
18
|
+
annotations = {
|
|
19
|
+
title: "Search Index",
|
|
20
|
+
readOnlyHint: true,
|
|
21
|
+
destructiveHint: false,
|
|
22
|
+
idempotentHint: true,
|
|
23
|
+
openWorldHint: true,
|
|
24
|
+
};
|
|
25
|
+
inputSchema = {
|
|
26
|
+
index: z.string().describe("Index name, pattern such as logs-*, or comma-separated list"),
|
|
27
|
+
body: z
|
|
28
|
+
.record(z.unknown())
|
|
29
|
+
.default({})
|
|
30
|
+
.describe('Search body, e.g. {"query": {"match": {"title": "error"}}, "size": 10, "sort": [{"@timestamp": "desc"}]}'),
|
|
31
|
+
};
|
|
32
|
+
constructor(drivers, names, bodies) {
|
|
33
|
+
super(drivers, names);
|
|
34
|
+
this.bodies = bodies;
|
|
35
|
+
}
|
|
36
|
+
async read(args, target) {
|
|
37
|
+
const rejection = this.validateObjectName(target, args.index, "index name") ?? this.reject(this.bodies.validate(args.body));
|
|
38
|
+
if (rejection) {
|
|
39
|
+
return rejection;
|
|
40
|
+
}
|
|
41
|
+
const driver = await this.drivers.acquireSearch(target, this.name);
|
|
42
|
+
return ToolResponse.json(await driver.search(args.index, { ...args.body, size: this.size(args.body.size) }));
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Capped rather than rejected when too large: the caller wanted results,
|
|
46
|
+
* and a hundred of them answers the question better than an error does.
|
|
47
|
+
*/
|
|
48
|
+
size(requested) {
|
|
49
|
+
if (typeof requested !== "number" || !Number.isFinite(requested)) {
|
|
50
|
+
return SearchTool.DEFAULT_SIZE;
|
|
51
|
+
}
|
|
52
|
+
return Math.min(Math.max(Math.trunc(requested), 0), SearchTool.MAX_SIZE);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { DatabaseScopedTool } from "../DatabaseScopedTool.js";
|
|
3
|
+
import { ToolResponse } from "../../formatting/ToolResponse.js";
|
|
4
|
+
/**
|
|
5
|
+
* The general escape hatch for reads on the SQL engines.
|
|
6
|
+
*
|
|
7
|
+
* No parameter binding is exposed. Adding a `params` argument would let an
|
|
8
|
+
* assistant separate values from SQL properly, but models inline their values
|
|
9
|
+
* in practice, and the validator plus each driver's server-side read-only
|
|
10
|
+
* mode already bound what a query can do.
|
|
11
|
+
*/
|
|
12
|
+
export class RunQueryTool extends DatabaseScopedTool {
|
|
13
|
+
validators;
|
|
14
|
+
rows;
|
|
15
|
+
name = "run_query";
|
|
16
|
+
description = "Execute a read-only SQL query (SELECT, WITH, and the engine's SHOW, DESCRIBE or EXPLAIN) on a MySQL, MariaDB, PostgreSQL, SQLite, SQL Server or ClickHouse connection";
|
|
17
|
+
family = "sql";
|
|
18
|
+
annotations = {
|
|
19
|
+
title: "Run Read-Only SQL Query",
|
|
20
|
+
readOnlyHint: true,
|
|
21
|
+
destructiveHint: false,
|
|
22
|
+
idempotentHint: true,
|
|
23
|
+
openWorldHint: true,
|
|
24
|
+
};
|
|
25
|
+
inputSchema = {
|
|
26
|
+
query: z.string().describe("SQL query to execute, in the active engine's dialect"),
|
|
27
|
+
database: DatabaseScopedTool.databaseParam,
|
|
28
|
+
};
|
|
29
|
+
constructor(drivers, names, validators, rows) {
|
|
30
|
+
super(drivers, names);
|
|
31
|
+
this.validators = validators;
|
|
32
|
+
this.rows = rows;
|
|
33
|
+
}
|
|
34
|
+
async read(args, target) {
|
|
35
|
+
const driver = await this.drivers.acquireSql(target, this.name);
|
|
36
|
+
// Validation happens before anything touches the network, so a rejected
|
|
37
|
+
// query costs no connection. The dialect comes from the driver, so a
|
|
38
|
+
// PostgreSQL query is lexed as PostgreSQL, dollar quotes and all.
|
|
39
|
+
const check = this.validators.for(driver.dialect).validate(args.query);
|
|
40
|
+
if (!check.valid) {
|
|
41
|
+
return ToolResponse.failure(check.error ?? "Query rejected.");
|
|
42
|
+
}
|
|
43
|
+
const result = await driver.query(args.query);
|
|
44
|
+
return ToolResponse.text(this.rows.format(result, "Add a LIMIT clause (TOP on SQL Server)"));
|
|
45
|
+
}
|
|
46
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Rejects MongoDB filters and pipelines that contain a forbidden operator
|
|
3
|
+
* anywhere, at any depth.
|
|
4
|
+
*
|
|
5
|
+
* Layer one for MongoDB, run by the tools before a driver is touched. It is a
|
|
6
|
+
* denylist, walked recursively, because the dangerous operators can hide
|
|
7
|
+
* inside `$facet`, `$lookup.pipeline`, `$unionWith`, `$expr` and each other:
|
|
8
|
+
*
|
|
9
|
+
* - `$out` and `$merge` write the pipeline's result into a collection.
|
|
10
|
+
* - `$function`, `$accumulator` and `$where` run JavaScript on the server,
|
|
11
|
+
* which cannot write through a read but can run for as long as it likes.
|
|
12
|
+
* - `$changeStream` opens a cursor that never finishes by design.
|
|
13
|
+
*
|
|
14
|
+
* Layer two is a different mechanism on purpose: MongoStageAllowlist, inside
|
|
15
|
+
* the driver, permits only known read stages. One is a denylist of operators,
|
|
16
|
+
* the other an allowlist of stages, so a mistake in one is not repeated in the
|
|
17
|
+
* other.
|
|
18
|
+
*/
|
|
19
|
+
export class MongoOperatorGuard {
|
|
20
|
+
static FORBIDDEN = new Map([
|
|
21
|
+
["$out", "$out writes the result into a collection"],
|
|
22
|
+
["$merge", "$merge writes the result into a collection"],
|
|
23
|
+
["$function", "$function runs JavaScript on the server"],
|
|
24
|
+
["$accumulator", "$accumulator runs JavaScript on the server"],
|
|
25
|
+
["$where", "$where runs JavaScript on the server"],
|
|
26
|
+
["$changeStream", "$changeStream opens a cursor that never completes"],
|
|
27
|
+
]);
|
|
28
|
+
/**
|
|
29
|
+
* Deep enough for any real pipeline, and a bound on a hostile one: a value
|
|
30
|
+
* nested ten thousand levels deep would otherwise exhaust the stack.
|
|
31
|
+
*/
|
|
32
|
+
static MAX_DEPTH = 64;
|
|
33
|
+
validate(value, label) {
|
|
34
|
+
const found = this.findForbidden(value, 0);
|
|
35
|
+
if (found === "too-deep") {
|
|
36
|
+
return { valid: false, error: `The ${label} is nested too deeply.` };
|
|
37
|
+
}
|
|
38
|
+
if (found) {
|
|
39
|
+
return {
|
|
40
|
+
valid: false,
|
|
41
|
+
error: `The ${label} uses ${found}, which is not allowed: ${MongoOperatorGuard.FORBIDDEN.get(found)}.`,
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
return { valid: true };
|
|
45
|
+
}
|
|
46
|
+
findForbidden(value, depth) {
|
|
47
|
+
if (depth > MongoOperatorGuard.MAX_DEPTH) {
|
|
48
|
+
return "too-deep";
|
|
49
|
+
}
|
|
50
|
+
if (Array.isArray(value)) {
|
|
51
|
+
for (const item of value) {
|
|
52
|
+
const found = this.findForbidden(item, depth + 1);
|
|
53
|
+
if (found) {
|
|
54
|
+
return found;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
return null;
|
|
58
|
+
}
|
|
59
|
+
if (value && typeof value === "object") {
|
|
60
|
+
for (const [key, child] of Object.entries(value)) {
|
|
61
|
+
if (MongoOperatorGuard.FORBIDDEN.has(key)) {
|
|
62
|
+
return key;
|
|
63
|
+
}
|
|
64
|
+
const found = this.findForbidden(child, depth + 1);
|
|
65
|
+
if (found) {
|
|
66
|
+
return found;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
return null;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Decides whether a Redis command may run: an allowlist of read commands.
|
|
3
|
+
*
|
|
4
|
+
* Layer one for Redis, run before any connection is used. An allowlist rather
|
|
5
|
+
* than a denylist because Redis has hundreds of commands, modules add more,
|
|
6
|
+
* and an unrecognised one must be refused rather than assumed harmless.
|
|
7
|
+
*
|
|
8
|
+
* Layer two, in the driver, asks the server itself: COMMAND INFO reports
|
|
9
|
+
* whether a command is flagged `write`, and the driver refuses any that is or
|
|
10
|
+
* that is not flagged `readonly`. The two are independent, since one is a list
|
|
11
|
+
* written here and the other is the server's own knowledge of its commands.
|
|
12
|
+
*
|
|
13
|
+
* Some read commands are left out on purpose:
|
|
14
|
+
*
|
|
15
|
+
* - KEYS blocks the server while it walks every key. SCAN is the answer.
|
|
16
|
+
* - CONFIG GET returns `requirepass` and other secrets.
|
|
17
|
+
* - PFCOUNT is flagged as a write, since it may update a cached cardinality.
|
|
18
|
+
* - SORT can STORE its result; SORT_RO is the read form.
|
|
19
|
+
* - Blocking reads (BLPOP, XREAD BLOCK, SUBSCRIBE) hold the connection.
|
|
20
|
+
*/
|
|
21
|
+
export class RedisCommandValidator {
|
|
22
|
+
static ALLOWED = new Set([
|
|
23
|
+
// Strings and generic key inspection
|
|
24
|
+
"GET",
|
|
25
|
+
"MGET",
|
|
26
|
+
"STRLEN",
|
|
27
|
+
"GETRANGE",
|
|
28
|
+
"EXISTS",
|
|
29
|
+
"TYPE",
|
|
30
|
+
"TTL",
|
|
31
|
+
"PTTL",
|
|
32
|
+
"EXPIRETIME",
|
|
33
|
+
"PEXPIRETIME",
|
|
34
|
+
"SCAN",
|
|
35
|
+
"RANDOMKEY",
|
|
36
|
+
"DBSIZE",
|
|
37
|
+
"SORT_RO",
|
|
38
|
+
"LCS",
|
|
39
|
+
// Hashes
|
|
40
|
+
"HGET",
|
|
41
|
+
"HMGET",
|
|
42
|
+
"HGETALL",
|
|
43
|
+
"HKEYS",
|
|
44
|
+
"HVALS",
|
|
45
|
+
"HLEN",
|
|
46
|
+
"HEXISTS",
|
|
47
|
+
"HSTRLEN",
|
|
48
|
+
"HSCAN",
|
|
49
|
+
"HRANDFIELD",
|
|
50
|
+
"HTTL",
|
|
51
|
+
"HPTTL",
|
|
52
|
+
// Lists
|
|
53
|
+
"LRANGE",
|
|
54
|
+
"LLEN",
|
|
55
|
+
"LINDEX",
|
|
56
|
+
"LPOS",
|
|
57
|
+
// Sets
|
|
58
|
+
"SMEMBERS",
|
|
59
|
+
"SCARD",
|
|
60
|
+
"SISMEMBER",
|
|
61
|
+
"SMISMEMBER",
|
|
62
|
+
"SRANDMEMBER",
|
|
63
|
+
"SSCAN",
|
|
64
|
+
"SINTER",
|
|
65
|
+
"SUNION",
|
|
66
|
+
"SDIFF",
|
|
67
|
+
"SINTERCARD",
|
|
68
|
+
// Sorted sets
|
|
69
|
+
"ZRANGE",
|
|
70
|
+
"ZRANGEBYSCORE",
|
|
71
|
+
"ZRANGEBYLEX",
|
|
72
|
+
"ZREVRANGE",
|
|
73
|
+
"ZREVRANGEBYSCORE",
|
|
74
|
+
"ZREVRANGEBYLEX",
|
|
75
|
+
"ZCARD",
|
|
76
|
+
"ZSCORE",
|
|
77
|
+
"ZMSCORE",
|
|
78
|
+
"ZRANK",
|
|
79
|
+
"ZREVRANK",
|
|
80
|
+
"ZCOUNT",
|
|
81
|
+
"ZLEXCOUNT",
|
|
82
|
+
"ZSCAN",
|
|
83
|
+
"ZRANDMEMBER",
|
|
84
|
+
"ZINTER",
|
|
85
|
+
"ZUNION",
|
|
86
|
+
"ZDIFF",
|
|
87
|
+
"ZINTERCARD",
|
|
88
|
+
// Streams
|
|
89
|
+
"XRANGE",
|
|
90
|
+
"XREVRANGE",
|
|
91
|
+
"XLEN",
|
|
92
|
+
"XPENDING",
|
|
93
|
+
// Bitmaps and geo
|
|
94
|
+
"GETBIT",
|
|
95
|
+
"BITCOUNT",
|
|
96
|
+
"BITPOS",
|
|
97
|
+
"BITFIELD_RO",
|
|
98
|
+
"GEOPOS",
|
|
99
|
+
"GEODIST",
|
|
100
|
+
"GEOHASH",
|
|
101
|
+
"GEOSEARCH",
|
|
102
|
+
"GEORADIUS_RO",
|
|
103
|
+
"GEORADIUSBYMEMBER_RO",
|
|
104
|
+
// Server
|
|
105
|
+
"INFO",
|
|
106
|
+
"TIME",
|
|
107
|
+
"PING",
|
|
108
|
+
"ECHO",
|
|
109
|
+
// RedisJSON, RediSearch and RedisTimeSeries read commands
|
|
110
|
+
"JSON.GET",
|
|
111
|
+
"JSON.MGET",
|
|
112
|
+
"JSON.TYPE",
|
|
113
|
+
"JSON.STRLEN",
|
|
114
|
+
"JSON.OBJKEYS",
|
|
115
|
+
"JSON.OBJLEN",
|
|
116
|
+
"JSON.ARRLEN",
|
|
117
|
+
"JSON.ARRINDEX",
|
|
118
|
+
"FT.SEARCH",
|
|
119
|
+
"FT.AGGREGATE",
|
|
120
|
+
"FT.INFO",
|
|
121
|
+
"FT._LIST",
|
|
122
|
+
"TS.GET",
|
|
123
|
+
"TS.MGET",
|
|
124
|
+
"TS.RANGE",
|
|
125
|
+
"TS.REVRANGE",
|
|
126
|
+
"TS.MRANGE",
|
|
127
|
+
"TS.MREVRANGE",
|
|
128
|
+
"TS.INFO",
|
|
129
|
+
]);
|
|
130
|
+
/**
|
|
131
|
+
* Container commands whose subcommands differ in kind: `OBJECT ENCODING`
|
|
132
|
+
* reads, while other subcommands of the same container may not. Only the
|
|
133
|
+
* listed pairs are allowed.
|
|
134
|
+
*/
|
|
135
|
+
static ALLOWED_SUBCOMMANDS = new Map([
|
|
136
|
+
["OBJECT", new Set(["ENCODING", "FREQ", "IDLETIME", "REFCOUNT"])],
|
|
137
|
+
["MEMORY", new Set(["USAGE"])],
|
|
138
|
+
["XINFO", new Set(["STREAM", "GROUPS", "CONSUMERS"])],
|
|
139
|
+
]);
|
|
140
|
+
/** @returns the canonical upper-case name, or the reason it was refused. */
|
|
141
|
+
validate(command, args) {
|
|
142
|
+
const name = command.trim().toUpperCase();
|
|
143
|
+
if (!name) {
|
|
144
|
+
return { valid: false, error: "Empty command." };
|
|
145
|
+
}
|
|
146
|
+
if (/\s/.test(name)) {
|
|
147
|
+
return {
|
|
148
|
+
valid: false,
|
|
149
|
+
error: "Pass the command name alone and its arguments in args, e.g. command=HGET args=[\"user:1\", \"email\"].",
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
const subcommands = RedisCommandValidator.ALLOWED_SUBCOMMANDS.get(name);
|
|
153
|
+
if (subcommands) {
|
|
154
|
+
const sub = (args[0] ?? "").toUpperCase();
|
|
155
|
+
if (subcommands.has(sub)) {
|
|
156
|
+
return { valid: true, name };
|
|
157
|
+
}
|
|
158
|
+
return {
|
|
159
|
+
valid: false,
|
|
160
|
+
error: `${name} ${sub || "(no subcommand)"} is not allowed. Allowed: ${Array.from(subcommands)
|
|
161
|
+
.map((entry) => `${name} ${entry}`)
|
|
162
|
+
.join(", ")}.`,
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
if (name === "KEYS") {
|
|
166
|
+
return {
|
|
167
|
+
valid: false,
|
|
168
|
+
error: "KEYS blocks the server while it scans every key. Use SCAN, or list_tables with a pattern.",
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
if (!RedisCommandValidator.ALLOWED.has(name)) {
|
|
172
|
+
return { valid: false, error: `${name} is not an allowed read-only command.` };
|
|
173
|
+
}
|
|
174
|
+
return { valid: true, name };
|
|
175
|
+
}
|
|
176
|
+
}
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
function invalid(error) {
|
|
2
|
+
return { valid: false, error };
|
|
3
|
+
}
|
|
4
|
+
const VALID = { valid: true };
|
|
5
|
+
/**
|
|
6
|
+
* Where the pattern is only ever matched in process or sent as a command
|
|
7
|
+
* argument, any glob is safe; the length cap just keeps a runaway argument
|
|
8
|
+
* from becoming a runaway regular expression.
|
|
9
|
+
*/
|
|
10
|
+
function validateLocalPattern(value) {
|
|
11
|
+
if (value.length > 256) {
|
|
12
|
+
return invalid("The pattern is too long.");
|
|
13
|
+
}
|
|
14
|
+
return VALID;
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* SQL engines: an allowlist, optionally schema-qualified.
|
|
18
|
+
*
|
|
19
|
+
* Several SQL engines cannot parameterise an identifier, so a table name can
|
|
20
|
+
* end up interpolated into the statement. The allowlist is what makes that
|
|
21
|
+
* safe: nothing matching it can close a quote. Drivers additionally escape
|
|
22
|
+
* every identifier they quote, so the two protections are independent.
|
|
23
|
+
*
|
|
24
|
+
* Hyphens are allowed where the MySQL-only predecessor refused them, because
|
|
25
|
+
* every identifier is now quoted and escaped, and database names such as
|
|
26
|
+
* `my-app` are common on PostgreSQL and SQL Server. For anything needing
|
|
27
|
+
* spaces or other characters, run_query with a hand-quoted statement remains
|
|
28
|
+
* the escape hatch, and it goes through the full SQL validator instead.
|
|
29
|
+
*/
|
|
30
|
+
export class SqlNamePolicy {
|
|
31
|
+
static PART = "[A-Za-z0-9_$-]+";
|
|
32
|
+
static DATABASE = new RegExp(`^${SqlNamePolicy.PART}$`);
|
|
33
|
+
static OBJECT = new RegExp(`^${SqlNamePolicy.PART}(\\.${SqlNamePolicy.PART})?$`);
|
|
34
|
+
validateDatabase(value) {
|
|
35
|
+
if (!value || !SqlNamePolicy.DATABASE.test(value)) {
|
|
36
|
+
return invalid(`Invalid database name: "${value}". Only letters, digits, underscore, hyphen and $ are allowed.`);
|
|
37
|
+
}
|
|
38
|
+
return VALID;
|
|
39
|
+
}
|
|
40
|
+
validateObject(value, label) {
|
|
41
|
+
if (!value || !SqlNamePolicy.OBJECT.test(value)) {
|
|
42
|
+
return invalid(`Invalid ${label}: "${value}". Use letters, digits, underscore, hyphen and $, optionally qualified as schema.table.`);
|
|
43
|
+
}
|
|
44
|
+
return VALID;
|
|
45
|
+
}
|
|
46
|
+
validatePattern(value) {
|
|
47
|
+
return validateLocalPattern(value);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* MongoDB: the server's own naming rules, since names never reach a query
|
|
52
|
+
* string. Collection names may contain dots (`system.profile`) and hyphens.
|
|
53
|
+
*/
|
|
54
|
+
export class MongoNamePolicy {
|
|
55
|
+
static DATABASE = /^[^/\\. "$*<>:|?\0]{1,63}$/;
|
|
56
|
+
validateDatabase(value) {
|
|
57
|
+
if (!MongoNamePolicy.DATABASE.test(value)) {
|
|
58
|
+
return invalid(`Invalid database name: "${value}". MongoDB database names cannot contain / \\ . space " $ * < > : | ?`);
|
|
59
|
+
}
|
|
60
|
+
return VALID;
|
|
61
|
+
}
|
|
62
|
+
validateObject(value, label) {
|
|
63
|
+
if (!value || value.length > 255 || value.includes("$") || value.includes("\0")) {
|
|
64
|
+
return invalid(`Invalid ${label}: "${value}". Collection names cannot be empty or contain $.`);
|
|
65
|
+
}
|
|
66
|
+
return VALID;
|
|
67
|
+
}
|
|
68
|
+
validatePattern(value) {
|
|
69
|
+
return validateLocalPattern(value);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* Redis: databases are numbered, and a key is any byte string. Keys are sent
|
|
74
|
+
* as command arguments, never interpolated, so there is nothing to escape.
|
|
75
|
+
*/
|
|
76
|
+
export class RedisNamePolicy {
|
|
77
|
+
validateDatabase(value) {
|
|
78
|
+
if (!/^\d{1,5}$/.test(value)) {
|
|
79
|
+
return invalid(`Invalid database: "${value}". Redis databases are numbered, e.g. 0 or 3.`);
|
|
80
|
+
}
|
|
81
|
+
return VALID;
|
|
82
|
+
}
|
|
83
|
+
validateObject(value, label) {
|
|
84
|
+
if (!value) {
|
|
85
|
+
return invalid(`Invalid ${label}: a key cannot be empty.`);
|
|
86
|
+
}
|
|
87
|
+
return VALID;
|
|
88
|
+
}
|
|
89
|
+
validatePattern(value) {
|
|
90
|
+
return validateLocalPattern(value);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* Elasticsearch and OpenSearch: an index name, pattern or comma list, which
|
|
95
|
+
* becomes a URL path segment.
|
|
96
|
+
*
|
|
97
|
+
* Percent-encoding the segment is not enough on its own. A name of `.` or one
|
|
98
|
+
* containing `..` is normalised away by the URL parser, repointing the request
|
|
99
|
+
* at a different endpoint, and a leading `_` names an API rather than an
|
|
100
|
+
* index. Those are refused before a path is ever built.
|
|
101
|
+
*/
|
|
102
|
+
export class ElasticNamePolicy {
|
|
103
|
+
static OBJECT = /^[A-Za-z0-9_.*,+:\-]+$/;
|
|
104
|
+
validateDatabase() {
|
|
105
|
+
return invalid("Elasticsearch has no databases. Name an index or pattern with the table argument.");
|
|
106
|
+
}
|
|
107
|
+
validateObject(value, label) {
|
|
108
|
+
const parts = value.split(",");
|
|
109
|
+
const bad = !value ||
|
|
110
|
+
!ElasticNamePolicy.OBJECT.test(value) ||
|
|
111
|
+
value.includes("..") ||
|
|
112
|
+
parts.some((part) => part === "" || part === "." || part.startsWith("_"));
|
|
113
|
+
if (bad) {
|
|
114
|
+
return invalid(`Invalid ${label}: "${value}". Use an index name, a pattern such as logs-*, or a comma-separated list; names cannot start with _ or contain "..".`);
|
|
115
|
+
}
|
|
116
|
+
return VALID;
|
|
117
|
+
}
|
|
118
|
+
/** The pattern becomes a path segment here, so it meets the same rules as a name. */
|
|
119
|
+
validatePattern(value) {
|
|
120
|
+
return this.validateObject(value, "pattern");
|
|
121
|
+
}
|
|
122
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { EngineCatalog } from "../../domain/Engine.js";
|
|
2
|
+
import { ElasticNamePolicy, MongoNamePolicy, RedisNamePolicy, SqlNamePolicy, } from "./NamePolicy.js";
|
|
3
|
+
/**
|
|
4
|
+
* Picks the naming rules for an engine.
|
|
5
|
+
*
|
|
6
|
+
* Tools validate names against the active engine before any driver is
|
|
7
|
+
* touched, so a malformed name costs no connection, and they do it through
|
|
8
|
+
* this one lookup so no tool can quietly apply SQL rules to a Redis key.
|
|
9
|
+
*/
|
|
10
|
+
export class NamePolicyRegistry {
|
|
11
|
+
sql;
|
|
12
|
+
mongo;
|
|
13
|
+
redis;
|
|
14
|
+
elastic;
|
|
15
|
+
constructor(sql = new SqlNamePolicy(), mongo = new MongoNamePolicy(), redis = new RedisNamePolicy(), elastic = new ElasticNamePolicy()) {
|
|
16
|
+
this.sql = sql;
|
|
17
|
+
this.mongo = mongo;
|
|
18
|
+
this.redis = redis;
|
|
19
|
+
this.elastic = elastic;
|
|
20
|
+
}
|
|
21
|
+
for(engine) {
|
|
22
|
+
switch (EngineCatalog.describe(engine).family) {
|
|
23
|
+
case "sql":
|
|
24
|
+
return this.sql;
|
|
25
|
+
case "document":
|
|
26
|
+
return this.mongo;
|
|
27
|
+
case "keyvalue":
|
|
28
|
+
return this.redis;
|
|
29
|
+
case "search":
|
|
30
|
+
return this.elastic;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Checks an Elasticsearch or OpenSearch `_search` body before it is sent.
|
|
3
|
+
*
|
|
4
|
+
* Layer one for search engines: an allowlist of top-level keys. The search
|
|
5
|
+
* API cannot write whatever the body says, so this is not what keeps the data
|
|
6
|
+
* safe. What it does is keep the request inside the plain search surface:
|
|
7
|
+
* `scroll` and `pit` open server-side contexts that outlive the call, and an
|
|
8
|
+
* unknown key is far more likely a mistake than a feature worth passing
|
|
9
|
+
* through blind.
|
|
10
|
+
*
|
|
11
|
+
* Layer two is structural, in the driver: it can only send GET requests to a
|
|
12
|
+
* fixed set of read endpoints and POST requests to `_search`, with paths it
|
|
13
|
+
* builds itself from a validated index name. There is no generic request
|
|
14
|
+
* method to misuse.
|
|
15
|
+
*/
|
|
16
|
+
export class SearchBodyValidator {
|
|
17
|
+
static ALLOWED_KEYS = new Set([
|
|
18
|
+
"query",
|
|
19
|
+
"size",
|
|
20
|
+
"from",
|
|
21
|
+
"sort",
|
|
22
|
+
"_source",
|
|
23
|
+
"fields",
|
|
24
|
+
"docvalue_fields",
|
|
25
|
+
"stored_fields",
|
|
26
|
+
"script_fields",
|
|
27
|
+
"runtime_mappings",
|
|
28
|
+
"aggs",
|
|
29
|
+
"aggregations",
|
|
30
|
+
"post_filter",
|
|
31
|
+
"highlight",
|
|
32
|
+
"collapse",
|
|
33
|
+
"search_after",
|
|
34
|
+
"track_total_hits",
|
|
35
|
+
"min_score",
|
|
36
|
+
"knn",
|
|
37
|
+
"rescore",
|
|
38
|
+
"suggest",
|
|
39
|
+
"explain",
|
|
40
|
+
"version",
|
|
41
|
+
"seq_no_primary_term",
|
|
42
|
+
"indices_boost",
|
|
43
|
+
"timeout",
|
|
44
|
+
"terminate_after",
|
|
45
|
+
]);
|
|
46
|
+
validate(body) {
|
|
47
|
+
const unknown = Object.keys(body).filter((key) => !SearchBodyValidator.ALLOWED_KEYS.has(key));
|
|
48
|
+
if (unknown.length > 0) {
|
|
49
|
+
return {
|
|
50
|
+
valid: false,
|
|
51
|
+
error: `Unsupported search body key${unknown.length > 1 ? "s" : ""}: ${unknown.join(", ")}.`,
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
return { valid: true };
|
|
55
|
+
}
|
|
56
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { SqlDialects } from "./SqlDialect.js";
|
|
2
|
+
import { SqlSkeletonizer } from "./SqlSkeletonizer.js";
|
|
3
|
+
import { AmbiguousSyntaxRule, EmptyQueryRule, ForbiddenPatternRule, LeadingKeywordRule, SingleStatementRule, SmuggledWriteRule, } from "./rules/index.js";
|
|
4
|
+
/**
|
|
5
|
+
* Decides whether a SQL statement may run, by walking a chain of rules.
|
|
6
|
+
*
|
|
7
|
+
* Chain of Responsibility: each rule inspects the statement and either objects
|
|
8
|
+
* or passes. The validator knows nothing about what any rule checks, and a
|
|
9
|
+
* rule knows nothing about the others, so adding or reordering a check is a
|
|
10
|
+
* local change.
|
|
11
|
+
*
|
|
12
|
+
* One validator per dialect. The rules are shared; the dialect decides how the
|
|
13
|
+
* statement is lexed and which word lists the rules consult.
|
|
14
|
+
*
|
|
15
|
+
* This is layer one of two. Every SQL driver also enforces read-only on the
|
|
16
|
+
* server side, independently, so a bug here alone cannot become a write.
|
|
17
|
+
*/
|
|
18
|
+
export class ReadOnlyQueryValidator {
|
|
19
|
+
dialect;
|
|
20
|
+
rules;
|
|
21
|
+
skeletonizer;
|
|
22
|
+
/**
|
|
23
|
+
* Order matters. Empty first so later rules can assume content; ambiguity
|
|
24
|
+
* before anything that trusts the skeleton; leading keyword before the
|
|
25
|
+
* smuggled-write scan, which is conditional on it.
|
|
26
|
+
*/
|
|
27
|
+
static defaultRules() {
|
|
28
|
+
return [
|
|
29
|
+
new EmptyQueryRule(),
|
|
30
|
+
new AmbiguousSyntaxRule(),
|
|
31
|
+
new SingleStatementRule(),
|
|
32
|
+
new LeadingKeywordRule(),
|
|
33
|
+
new SmuggledWriteRule(),
|
|
34
|
+
new ForbiddenPatternRule(),
|
|
35
|
+
];
|
|
36
|
+
}
|
|
37
|
+
static forDialect(name) {
|
|
38
|
+
return new ReadOnlyQueryValidator(SqlDialects.get(name));
|
|
39
|
+
}
|
|
40
|
+
constructor(dialect, rules = ReadOnlyQueryValidator.defaultRules(), skeletonizer = new SqlSkeletonizer()) {
|
|
41
|
+
this.dialect = dialect;
|
|
42
|
+
this.rules = rules;
|
|
43
|
+
this.skeletonizer = skeletonizer;
|
|
44
|
+
}
|
|
45
|
+
validate(sql) {
|
|
46
|
+
const inspection = this.inspect(sql);
|
|
47
|
+
for (const rule of this.rules) {
|
|
48
|
+
const objection = rule.evaluate(inspection);
|
|
49
|
+
if (objection) {
|
|
50
|
+
return objection;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
return { valid: true };
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Builds the view of the statement every rule shares.
|
|
57
|
+
*
|
|
58
|
+
* The original SQL is what eventually reaches the server; the skeleton is
|
|
59
|
+
* only ever used to decide whether it may.
|
|
60
|
+
*/
|
|
61
|
+
inspect(sql) {
|
|
62
|
+
const skeleton = this.skeletonizer.skeletonize(sql, this.dialect.lexical);
|
|
63
|
+
const text = skeleton.text
|
|
64
|
+
.trim()
|
|
65
|
+
// A single trailing semicolon is idiomatic when pasting a query and is
|
|
66
|
+
// not statement stacking.
|
|
67
|
+
.replace(/;+\s*$/, "")
|
|
68
|
+
.trim();
|
|
69
|
+
return {
|
|
70
|
+
raw: sql,
|
|
71
|
+
skeleton: text,
|
|
72
|
+
leadingKeyword: this.leadingKeyword(text),
|
|
73
|
+
ambiguities: skeleton.ambiguities,
|
|
74
|
+
dialect: this.dialect,
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
/** Leading parentheses are stripped so `(SELECT 1) UNION (SELECT 2)` works. */
|
|
78
|
+
leadingKeyword(skeleton) {
|
|
79
|
+
const firstWord = skeleton.replace(/^[\s(]+/, "").split(/[\s(]+/)[0] ?? "";
|
|
80
|
+
return firstWord.toUpperCase();
|
|
81
|
+
}
|
|
82
|
+
}
|