actual-mcp-server 0.19.1 → 0.19.2
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
CHANGED
|
@@ -778,7 +778,7 @@ The software is provided **as-is**, without warranty of any kind. The author acc
|
|
|
778
778
|
|
|
779
779
|
---
|
|
780
780
|
|
|
781
|
-
**Version:** 0.19.
|
|
781
|
+
**Version:** 0.19.2 | **Tool Count:** 77 (verified LibreChat-compatible)
|
|
782
782
|
|
|
783
783
|
## Sponsor
|
|
784
784
|
|
|
@@ -8,6 +8,50 @@ import { getFieldType } from '../actual-schema.js';
|
|
|
8
8
|
function _stripWhereQuotes(s) {
|
|
9
9
|
return s.trim().replace(/^['"]|['"]$/g, '');
|
|
10
10
|
}
|
|
11
|
+
// #421: split an IN-list body on commas that are OUTSIDE quoted literals, so a legitimate value
|
|
12
|
+
// containing a comma (e.g. 'Smith, John') stays ONE element. A naive split(',') both broke such
|
|
13
|
+
// values and, with the element check below, turned them into a confusing rejection.
|
|
14
|
+
function _splitInList(valuesStr) {
|
|
15
|
+
const out = [];
|
|
16
|
+
let cur = '';
|
|
17
|
+
let quote = null;
|
|
18
|
+
for (const ch of valuesStr) {
|
|
19
|
+
if (quote) {
|
|
20
|
+
cur += ch;
|
|
21
|
+
if (ch === quote)
|
|
22
|
+
quote = null;
|
|
23
|
+
}
|
|
24
|
+
else if (ch === "'" || ch === '"') {
|
|
25
|
+
quote = ch;
|
|
26
|
+
cur += ch;
|
|
27
|
+
}
|
|
28
|
+
else if (ch === ',') {
|
|
29
|
+
out.push(cur);
|
|
30
|
+
cur = '';
|
|
31
|
+
}
|
|
32
|
+
else {
|
|
33
|
+
cur += ch;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
out.push(cur);
|
|
37
|
+
return out;
|
|
38
|
+
}
|
|
39
|
+
// #421: whether an IN-list element is safe to feed into upstream's `$oneof`, which stringifies each
|
|
40
|
+
// element as `'${id}'` with NO escaping. Two conditions, and both matter:
|
|
41
|
+
// 1. It must be WELL FORMED: a number, or a single-/double-quoted string with no embedded quote of
|
|
42
|
+
// the same kind, so a crafted element like `'b') UNION SELECT 1 --'` (unbalanced) is rejected.
|
|
43
|
+
// 2. The value that ACTUALLY REACHES `$oneof` must contain no single quote. `_stripWhereQuotes`
|
|
44
|
+
// removes the outer pair of EITHER quote kind and upstream always re-wraps in SINGLE quotes, so
|
|
45
|
+
// a double-quoted element hiding a single quote (`"x' UNION SELECT 1 --"`) is well formed by (1)
|
|
46
|
+
// yet its inner `'` would terminate upstream's wrapper. Checking the stripped value closes that.
|
|
47
|
+
// A legitimate `'groceries'`, `'Smith, John'`, `"cash"`, `100` or `-5.5` passes both.
|
|
48
|
+
function _isSafeInListElement(raw) {
|
|
49
|
+
const t = raw.trim();
|
|
50
|
+
const wellFormed = /^'[^']*'$/.test(t) || /^"[^"]*"$/.test(t) || /^-?\d+(?:\.\d+)?$/.test(t);
|
|
51
|
+
if (!wellFormed)
|
|
52
|
+
return false;
|
|
53
|
+
return !_stripWhereQuotes(t).includes("'");
|
|
54
|
+
}
|
|
11
55
|
// Coerce a SQL value literal to a number when it looks numeric, else keep the
|
|
12
56
|
// (unquoted) string. Used for IN lists and comparison operands. Empty stays a
|
|
13
57
|
// string so an empty literal is not silently turned into 0.
|
|
@@ -100,7 +144,7 @@ export function parseWhereClause(query, whereClause, tableName) {
|
|
|
100
144
|
const inMatch = trimmedCondition.match(/^([\w.]+)\s+IN\s+\((.+)\)$/i);
|
|
101
145
|
if (inMatch) {
|
|
102
146
|
const [, field, valuesStr] = inMatch;
|
|
103
|
-
const rawValues = valuesStr
|
|
147
|
+
const rawValues = _splitInList(valuesStr);
|
|
104
148
|
if (_isBooleanField(tableName, field)) {
|
|
105
149
|
// #420: `$oneof` STRINGIFIES every element (upstream `compiler.ts` emits
|
|
106
150
|
// `'${String(id)}'`), so `is_parent IN (true, false)` would compile to
|
|
@@ -112,6 +156,15 @@ export function parseWhereClause(query, whereClause, tableName) {
|
|
|
112
156
|
query = query.filter({ $or: orConditions });
|
|
113
157
|
}
|
|
114
158
|
else {
|
|
159
|
+
// #421: every element must be safe before it reaches upstream's unescaped `$oneof`. Reject a
|
|
160
|
+
// value that could terminate its own quote (single- OR double-quote wrapped) and smuggle
|
|
161
|
+
// trailing SQL.
|
|
162
|
+
for (const raw of rawValues) {
|
|
163
|
+
if (!_isSafeInListElement(raw)) {
|
|
164
|
+
throw new Error(`Invalid value in IN list for column "${field}": ${JSON.stringify(raw.trim())}. ` +
|
|
165
|
+
`Each value must be a number or a quoted string with no embedded quote.`);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
115
168
|
query = query.filter({ [field]: { $oneof: rawValues.map(_coerceWhereValue) } });
|
|
116
169
|
}
|
|
117
170
|
continue;
|
|
@@ -67,17 +67,29 @@ function extractWhereFields(sql) {
|
|
|
67
67
|
if (!whereMatch)
|
|
68
68
|
return fields;
|
|
69
69
|
const whereClause = whereMatch[1];
|
|
70
|
-
// Find field references (table.field or field)
|
|
71
|
-
|
|
70
|
+
// Find field references (table.field or field).
|
|
71
|
+
// #421: a bare column must be extracted (and therefore schema-validated) not only before a
|
|
72
|
+
// comparison operator, but also before IN / LIKE / IS / BETWEEN (with their NOT forms). Before
|
|
73
|
+
// this, `bogus_col IN ('a')`, `bogus_col LIKE '%a%'` and `bogus_col IS NULL` extracted no field
|
|
74
|
+
// and so skipped the allowlist check entirely, while `bogus_col = 'a'` was correctly rejected.
|
|
75
|
+
const fieldMatches = whereClause.matchAll(/(\w+)\.(\w+)|(?:^|\s)(\w+)\s*[=<>!]|(?:^|\s)(\w+)\s+(?:NOT\s+)?(?:IN|LIKE|BETWEEN)\b|(?:^|\s)(\w+)\s+IS\b/gi);
|
|
72
76
|
for (const match of fieldMatches) {
|
|
73
77
|
if (match[1] && match[2]) {
|
|
74
78
|
// table.field
|
|
75
79
|
fields.push({ table: match[1], field: match[2] });
|
|
76
80
|
}
|
|
77
81
|
else if (match[3]) {
|
|
78
|
-
// simple field
|
|
82
|
+
// simple field before a comparison operator
|
|
79
83
|
fields.push({ field: match[3] });
|
|
80
84
|
}
|
|
85
|
+
else if (match[4]) {
|
|
86
|
+
// simple field before IN / LIKE / BETWEEN (optionally negated)
|
|
87
|
+
fields.push({ field: match[4] });
|
|
88
|
+
}
|
|
89
|
+
else if (match[5]) {
|
|
90
|
+
// simple field before IS (NULL / NOT NULL)
|
|
91
|
+
fields.push({ field: match[5] });
|
|
92
|
+
}
|
|
81
93
|
}
|
|
82
94
|
return fields;
|
|
83
95
|
}
|
|
@@ -243,6 +255,21 @@ export function validateQueryShape(query) {
|
|
|
243
255
|
if (/;\s*\S/.test(stripped)) {
|
|
244
256
|
throw new Error('actual_query_run does not allow stacked statements (multiple statements separated by ";").');
|
|
245
257
|
}
|
|
258
|
+
// #421: SQL comments. Checked on the literal-blanked `stripped`, so a `--` or `/*` INSIDE a
|
|
259
|
+
// string literal is already gone and cannot false-trigger. A comment left in the query is a
|
|
260
|
+
// trailing-SQL smuggling vector (it comments out the tombstone filter this server relies on),
|
|
261
|
+
// and a read tool has no legitimate use for one, so it is rejected outright.
|
|
262
|
+
if (/--/.test(stripped) || /\/\*/.test(stripped)) {
|
|
263
|
+
throw new Error('actual_query_run does not allow SQL comments ("--" or "/* */").');
|
|
264
|
+
}
|
|
265
|
+
// #421: compound-query set operators. parseWhereClause translates a SINGLE WHERE clause, so a
|
|
266
|
+
// UNION/EXCEPT/INTERSECT can never be handled correctly, and left unrejected it lets a crafted
|
|
267
|
+
// IN-list value append a second SELECT past the validated clause. Rejected on the blanked string
|
|
268
|
+
// so the word inside a literal (e.g. notes = 'union dues') stays allowed.
|
|
269
|
+
const COMPOUND = /\b(UNION|EXCEPT|INTERSECT)\b/i;
|
|
270
|
+
if (COMPOUND.test(stripped)) {
|
|
271
|
+
throw new Error('actual_query_run does not allow compound queries (UNION, EXCEPT, INTERSECT).');
|
|
272
|
+
}
|
|
246
273
|
// Data- and schema-modification keywords.
|
|
247
274
|
const FORBIDDEN = /\b(INSERT|UPDATE|DELETE|DROP|ALTER|ATTACH|DETACH|PRAGMA|CREATE|REPLACE|EXEC|EXECUTE|VACUUM|TRUNCATE|GRANT|REVOKE|MERGE|INTO)\b/i;
|
|
248
275
|
if (FORBIDDEN.test(stripped)) {
|