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.1 | **Tool Count:** 77 (verified LibreChat-compatible)
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.split(',');
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
- const fieldMatches = whereClause.matchAll(/(\w+)\.(\w+)|(?:^|\s)(\w+)\s*[=<>!]/g);
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)) {
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "actual-mcp-server",
3
3
  "displayName": "Actual MCP Server",
4
- "version": "0.19.1",
4
+ "version": "0.19.2",
5
5
  "engines": {
6
6
  "node": ">=22.0.0",
7
7
  "npm": ">=10.0.0"