@nifrajs/mcp-db 5.5.0 → 5.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/CHANGELOG.md +25 -0
- package/README.md +10 -5
- package/dist/index.d.ts +3 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +51 -37
- package/dist/index.js.map +1 -1
- package/dist/internal/pg-exec.d.ts +4 -0
- package/dist/internal/pg-exec.d.ts.map +1 -1
- package/dist/internal/pg-exec.js +9 -3
- package/dist/internal/pg-exec.js.map +1 -1
- package/dist/internal/pg-lexer.d.ts +10 -0
- package/dist/internal/pg-lexer.d.ts.map +1 -1
- package/dist/internal/pg-lexer.js +103 -0
- package/dist/internal/pg-lexer.js.map +1 -1
- package/dist/internal/shared.d.ts +5 -4
- package/dist/internal/shared.d.ts.map +1 -1
- package/dist/internal/shared.js +91 -113
- package/dist/internal/shared.js.map +1 -1
- package/dist/postgres.d.ts.map +1 -1
- package/dist/postgres.js +14 -5
- package/dist/postgres.js.map +1 -1
- package/package.json +2 -2
- package/src/index.ts +57 -49
- package/src/internal/pg-exec.ts +9 -3
- package/src/internal/pg-lexer.ts +104 -0
- package/src/internal/shared.ts +88 -107
- package/src/postgres.ts +25 -5
package/src/index.ts
CHANGED
|
@@ -31,14 +31,9 @@ import {
|
|
|
31
31
|
fitToBytes,
|
|
32
32
|
gateSqliteStatement,
|
|
33
33
|
type SqliteDatabaseLike,
|
|
34
|
-
unexposedPlanRelation,
|
|
35
34
|
unexposedProgramRelation,
|
|
36
35
|
} from "./internal/shared.ts"
|
|
37
|
-
import {
|
|
38
|
-
openQuerySession,
|
|
39
|
-
type QuerySession,
|
|
40
|
-
QueryTimeoutError,
|
|
41
|
-
} from "./internal/sqlite-session.ts"
|
|
36
|
+
import { openQuerySession, QueryTimeoutError } from "./internal/sqlite-session.ts"
|
|
42
37
|
|
|
43
38
|
export type { SqliteDatabaseLike } from "./internal/shared.ts"
|
|
44
39
|
|
|
@@ -65,7 +60,8 @@ export interface RunQueryOptions {
|
|
|
65
60
|
*/
|
|
66
61
|
readonly maxResultBytes?: number
|
|
67
62
|
/**
|
|
68
|
-
* Maximum wall-clock time for planning, execution, and optional counting (default 5 seconds).
|
|
63
|
+
* Maximum wall-clock time for planning, execution, and optional counting (default 5 seconds). It
|
|
64
|
+
* also bounds `list_tables`' row counts, at the same 5-second default when `runQuery` is omitted.
|
|
69
65
|
* The deadline always bounds the RESPONSE. Whether it also stops the WORK depends on where the
|
|
70
66
|
* query runs: a file-backed database runs it in a reusable read-only worker that is terminated
|
|
71
67
|
* on expiry, while an in-memory database (or a shim with no `filename`) runs it on this thread,
|
|
@@ -94,7 +90,7 @@ export interface ServeDatabaseAsMcpOptions {
|
|
|
94
90
|
/**
|
|
95
91
|
* Set `PRAGMA query_only = ON` on the connection at construction (default true). Disable ONLY
|
|
96
92
|
* when the same connection must also serve writes elsewhere in the app - with it disabled,
|
|
97
|
-
* `run_query` still gates on SELECT/WITH +
|
|
93
|
+
* `run_query` still gates on SELECT/WITH + compiled-statement verification, but the engine-level guarantee is
|
|
98
94
|
* yours to provide (e.g. open a second, read-only connection for the MCP mount - preferred).
|
|
99
95
|
*/
|
|
100
96
|
readonly enforceQueryOnly?: boolean
|
|
@@ -113,6 +109,10 @@ export class McpDbConfigError extends Error {
|
|
|
113
109
|
}
|
|
114
110
|
}
|
|
115
111
|
|
|
112
|
+
// A count reads the whole table and the schema tools answer anyone, so one count per table serves
|
|
113
|
+
// every listing for this long instead of each call scanning again.
|
|
114
|
+
const ROW_COUNT_TTL_MS = 10_000
|
|
115
|
+
|
|
116
116
|
/** A safe SQL identifier - quoting is not enough for PRAGMA args, so reject instead. */
|
|
117
117
|
const SAFE_IDENTIFIER = /^[A-Za-z_][A-Za-z0-9_]*$/
|
|
118
118
|
|
|
@@ -176,21 +176,44 @@ export function serveDatabaseAsMcp(
|
|
|
176
176
|
assertResultLimit(queryTimeoutMs, "queryTimeoutMs")
|
|
177
177
|
assertResultLimit(maxConcurrentQueries, "maxConcurrentQueries")
|
|
178
178
|
let activeQueries = 0
|
|
179
|
-
|
|
179
|
+
// One lane for the life of the server, picked once here and shared by the row counts and run_query.
|
|
180
|
+
const session = openQuerySession(db)
|
|
181
|
+
|
|
182
|
+
const countTables = async (): Promise<{ table: string; rows: unknown }[]> => {
|
|
183
|
+
const deadline = Date.now() + queryTimeoutMs
|
|
184
|
+
const tables: { table: string; rows: unknown }[] = []
|
|
185
|
+
for (const table of options.tables) {
|
|
186
|
+
let rows: unknown = null
|
|
187
|
+
try {
|
|
188
|
+
const [count] = await session.run(
|
|
189
|
+
// nifra-expect sql-dynamic: table name from the operator-configured allowlist, escaped by sqlIdentifier; SQL cannot bind an identifier as a parameter
|
|
190
|
+
sqlIdentifiers`SELECT count(*) AS n FROM "${table}"`,
|
|
191
|
+
deadline,
|
|
192
|
+
)
|
|
193
|
+
if (typeof count === "object" && count !== null && "n" in count) rows = count.n
|
|
194
|
+
} catch (error) {
|
|
195
|
+
if (!(error instanceof QueryTimeoutError)) throw error
|
|
196
|
+
}
|
|
197
|
+
tables.push({ table, rows })
|
|
198
|
+
}
|
|
199
|
+
return tables
|
|
200
|
+
}
|
|
201
|
+
let listing: { readonly at: number; readonly tables: ReturnType<typeof countTables> } | undefined
|
|
180
202
|
|
|
181
203
|
const listTables = defineMcpTool({
|
|
182
204
|
name: "list_tables",
|
|
183
|
-
description:
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
const
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
}
|
|
205
|
+
description:
|
|
206
|
+
"List the tables this server exposes, with row counts (refreshed at most every 10 seconds; null when a count runs past the query time limit).",
|
|
207
|
+
handler: async () => {
|
|
208
|
+
if (listing === undefined || Date.now() - listing.at >= ROW_COUNT_TTL_MS) {
|
|
209
|
+
const tables = countTables()
|
|
210
|
+
const current = { at: Date.now(), tables }
|
|
211
|
+
listing = current
|
|
212
|
+
tables.catch(() => {
|
|
213
|
+
if (listing === current) listing = undefined
|
|
214
|
+
})
|
|
215
|
+
}
|
|
216
|
+
const rows = await listing.tables
|
|
194
217
|
return { text: JSON.stringify(rows), structuredContent: { tables: rows } }
|
|
195
218
|
},
|
|
196
219
|
})
|
|
@@ -218,9 +241,6 @@ export function serveDatabaseAsMcp(
|
|
|
218
241
|
const tools = [listTables, describeTable]
|
|
219
242
|
|
|
220
243
|
if (options.runQuery !== undefined) {
|
|
221
|
-
// One lane for the life of the server: picked once here, so the handler below always has it.
|
|
222
|
-
const querySession = openQuerySession(db)
|
|
223
|
-
session = querySession
|
|
224
244
|
const runQuery = defineMcpTool({
|
|
225
245
|
name: "run_query",
|
|
226
246
|
description:
|
|
@@ -245,35 +265,24 @@ export function serveDatabaseAsMcp(
|
|
|
245
265
|
if (!gate.ok) return { isError: true, text: gate.text }
|
|
246
266
|
|
|
247
267
|
const deadline = Date.now() + queryTimeoutMs
|
|
248
|
-
|
|
249
|
-
// Verify via the query plan that only allowlisted tables are touched. SQLite names every
|
|
250
|
-
// scanned/searched relation in EXPLAIN QUERY PLAN detail rows.
|
|
251
268
|
const query = gate.query
|
|
252
|
-
let planRows: Array<{ detail?: unknown }>
|
|
253
|
-
try {
|
|
254
|
-
planRows = (await querySession.run(`EXPLAIN QUERY PLAN ${query}`, deadline)) as Array<{
|
|
255
|
-
detail?: unknown
|
|
256
|
-
}>
|
|
257
|
-
} catch (error) {
|
|
258
|
-
if (error instanceof QueryTimeoutError) return { isError: true, text: error.message }
|
|
259
|
-
return { isError: true, text: `query failed to plan: ${String(error)}` }
|
|
260
|
-
}
|
|
261
269
|
let catalog: unknown[]
|
|
262
270
|
let program: unknown[]
|
|
263
271
|
try {
|
|
264
|
-
catalog = await
|
|
272
|
+
catalog = await session.run(
|
|
265
273
|
"SELECT tbl_name, rootpage FROM sqlite_master WHERE rootpage > 0",
|
|
266
274
|
deadline,
|
|
267
275
|
)
|
|
268
|
-
program = await
|
|
276
|
+
program = await session.run(`EXPLAIN ${query}`, deadline)
|
|
269
277
|
} catch (error) {
|
|
270
278
|
if (error instanceof QueryTimeoutError) return { isError: true, text: error.message }
|
|
271
279
|
return { isError: true, text: `query failed to plan: ${String(error)}` }
|
|
272
280
|
}
|
|
273
|
-
// The
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
281
|
+
// The program names each table it opens by root page. EXPLAIN QUERY PLAN cannot stand in:
|
|
282
|
+
// it names a scan by its alias (`FROM habits h` plans as `SCAN h`).
|
|
283
|
+
const unexposed = unexposedProgramRelation(program, catalog, (relation) =>
|
|
284
|
+
allowlist.has(relation),
|
|
285
|
+
)
|
|
277
286
|
if (unexposed !== undefined) {
|
|
278
287
|
return {
|
|
279
288
|
isError: true,
|
|
@@ -288,11 +297,11 @@ export function serveDatabaseAsMcp(
|
|
|
288
297
|
// cannot make the driver materialize every row. Where the driver names the columns,
|
|
289
298
|
// oversized cells and rows past the byte budget are never copied out either.
|
|
290
299
|
const bounded = boundedSqliteQuery(query, maxRows)
|
|
291
|
-
const columns = await
|
|
300
|
+
const columns = await session.columns(bounded, deadline)
|
|
292
301
|
if (columns.length === 0) {
|
|
293
|
-
limitedRows = await
|
|
302
|
+
limitedRows = await session.run(bounded, deadline)
|
|
294
303
|
} else {
|
|
295
|
-
const read = await
|
|
304
|
+
const read = await session.read(
|
|
296
305
|
cellBoundedSqliteQuery(query, columns, maxRows, maxResultBytes),
|
|
297
306
|
deadline,
|
|
298
307
|
maxResultBytes,
|
|
@@ -309,10 +318,9 @@ export function serveDatabaseAsMcp(
|
|
|
309
318
|
let total: number | null = limitedRows.length
|
|
310
319
|
if (wasLimited && options.runQuery?.exactTotal === true) {
|
|
311
320
|
try {
|
|
312
|
-
const countRows = (await
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
)) as Array<{ __nifra_total?: unknown }>
|
|
321
|
+
const countRows = (await session.run(countSqliteQuery(query), deadline)) as Array<{
|
|
322
|
+
__nifra_total?: unknown
|
|
323
|
+
}>
|
|
316
324
|
const count = countRows[0]?.__nifra_total
|
|
317
325
|
if (
|
|
318
326
|
typeof count !== "number" ||
|
|
@@ -357,7 +365,7 @@ export function serveDatabaseAsMcp(
|
|
|
357
365
|
})
|
|
358
366
|
|
|
359
367
|
const runQueryOptions = options.runQuery
|
|
360
|
-
if (
|
|
368
|
+
if (runQueryOptions === undefined) return { ...server, close: () => session.close() }
|
|
361
369
|
|
|
362
370
|
// `run_query` is authorized at the TRANSPORT boundary, where the inbound Request's credentials
|
|
363
371
|
// are visible - the handler itself performs no auth, so every path to it must pass this gate:
|
package/src/internal/pg-exec.ts
CHANGED
|
@@ -42,8 +42,10 @@ export interface PostgresClient {
|
|
|
42
42
|
|
|
43
43
|
/** Run caller text as ONE extended-protocol statement with no parameters. */
|
|
44
44
|
export function extended(connection: PgConnection, text: string): PgQuery {
|
|
45
|
-
const strings
|
|
46
|
-
|
|
45
|
+
const strings = Object.assign([text], { raw: Object.freeze([text]) })
|
|
46
|
+
// Shaped like a real template object: Bun.SQL (1.4.4+) refuses one whose `raw` is enumerable.
|
|
47
|
+
Object.defineProperty(strings, "raw", { enumerable: false })
|
|
48
|
+
return connection(Object.freeze(strings))
|
|
47
49
|
}
|
|
48
50
|
|
|
49
51
|
const WRITE_KEYWORD =
|
|
@@ -98,6 +100,10 @@ function millis(timeoutMs: number): number {
|
|
|
98
100
|
* Run `work` inside `BEGIN READ ONLY` on one reserved connection, with the statement, lock and
|
|
99
101
|
* idle-in-transaction timeouts set for this transaction only. Always rolls back, so nothing a call
|
|
100
102
|
* does (a `set_config`, a session setting) outlives it.
|
|
103
|
+
*
|
|
104
|
+
* JIT is off too: the catalog reads' recursive subplans cross `jit_above_cost`, and compiling them
|
|
105
|
+
* took past the timeout on a stock Ubuntu server where running them takes under a millisecond.
|
|
106
|
+
* PostgreSQL 10 has no `jit` setting, so the version is checked first.
|
|
101
107
|
*/
|
|
102
108
|
export async function inReadOnlyTransaction<T>(
|
|
103
109
|
client: PostgresClient,
|
|
@@ -114,7 +120,7 @@ export async function inReadOnlyTransaction<T>(
|
|
|
114
120
|
try {
|
|
115
121
|
// nifra-expect sql-dynamic: fixed statements; the one interpolation is a validated integer of milliseconds
|
|
116
122
|
await connection.unsafe(
|
|
117
|
-
`BEGIN READ ONLY; SET LOCAL statement_timeout = ${ms}; SET LOCAL lock_timeout = ${ms}; SET LOCAL idle_in_transaction_session_timeout = ${ms}`,
|
|
123
|
+
`BEGIN READ ONLY; SET LOCAL statement_timeout = ${ms}; SET LOCAL lock_timeout = ${ms}; SET LOCAL idle_in_transaction_session_timeout = ${ms}; SELECT pg_catalog.set_config('jit', 'off', true) WHERE pg_catalog.current_setting('server_version_num')::int >= 110000`,
|
|
118
124
|
)
|
|
119
125
|
return await work(connection)
|
|
120
126
|
} catch (error) {
|
package/src/internal/pg-lexer.ts
CHANGED
|
@@ -184,6 +184,10 @@ export const DENIED_FUNCTION_PREFIXES: readonly string[] = [
|
|
|
184
184
|
"pg_try_advisory",
|
|
185
185
|
"pg_notify",
|
|
186
186
|
"query_to_xml",
|
|
187
|
+
"ts_stat",
|
|
188
|
+
"ts_rewrite",
|
|
189
|
+
"crosstab",
|
|
190
|
+
"connectby",
|
|
187
191
|
"table_to_xml",
|
|
188
192
|
"cursor_to_xml",
|
|
189
193
|
"schema_to_xml",
|
|
@@ -272,6 +276,106 @@ export function lintPostgres(sql: string): PgLintRefusal | undefined {
|
|
|
272
276
|
return undefined
|
|
273
277
|
}
|
|
274
278
|
|
|
279
|
+
// Postgres' reserved and type/function-name keywords: none of them can be a bare table name or alias.
|
|
280
|
+
const NOT_A_NAME = new Set(
|
|
281
|
+
`
|
|
282
|
+
all analyse analyze and any array as asc asymmetric both case cast check collate column
|
|
283
|
+
constraint create current_catalog current_date current_role current_time current_timestamp
|
|
284
|
+
current_user default deferrable desc distinct do else end except false fetch for foreign from
|
|
285
|
+
grant group having in initially intersect into lateral leading limit localtime localtimestamp
|
|
286
|
+
not null offset on only or order placing primary references returning select session_user some
|
|
287
|
+
symmetric system_user table then to trailing true union unique user using variadic when where
|
|
288
|
+
window with authorization binary collation concurrently cross current_schema freeze full ilike
|
|
289
|
+
inner is isnull join left like natural notnull outer overlaps right similar tablesample verbose
|
|
290
|
+
`
|
|
291
|
+
.trim()
|
|
292
|
+
.split(/\s+/),
|
|
293
|
+
)
|
|
294
|
+
|
|
295
|
+
// Words that close a FROM list at their own parenthesis depth.
|
|
296
|
+
const FROM_LIST_ENDS = new Set(
|
|
297
|
+
`
|
|
298
|
+
where group having window order limit offset fetch for union intersect except returning select
|
|
299
|
+
`
|
|
300
|
+
.trim()
|
|
301
|
+
.split(/\s+/),
|
|
302
|
+
)
|
|
303
|
+
|
|
304
|
+
const isPunct = (token: PgToken | undefined, value: string): boolean =>
|
|
305
|
+
token?.kind === "punct" && token.value === value
|
|
306
|
+
const isKeyword = (token: PgToken | undefined, word: string): boolean =>
|
|
307
|
+
token?.kind === "word" && token.bare && token.value.toLowerCase() === word
|
|
308
|
+
const isName = (token: PgToken | undefined): token is PgToken =>
|
|
309
|
+
token?.kind === "word" && !(token.bare && NOT_A_NAME.has(token.value.toLowerCase()))
|
|
310
|
+
|
|
311
|
+
/** A table reference starting at `start` that carries a column alias list: `[ONLY] name [*] [AS] alias (`. */
|
|
312
|
+
function aliasListAt(
|
|
313
|
+
tokens: readonly PgToken[],
|
|
314
|
+
start: number,
|
|
315
|
+
): { readonly table: string; readonly alias: string } | undefined {
|
|
316
|
+
let at = start
|
|
317
|
+
const only = isKeyword(tokens[at], "only")
|
|
318
|
+
if (only) at++
|
|
319
|
+
const parenthesized = only && isPunct(tokens[at], "(")
|
|
320
|
+
if (parenthesized) at++
|
|
321
|
+
let name = tokens[at]
|
|
322
|
+
if (!isName(name)) return undefined
|
|
323
|
+
at++
|
|
324
|
+
for (let part = tokens[at + 1]; isPunct(tokens[at], ".") && isName(part); part = tokens[at + 1]) {
|
|
325
|
+
name = part
|
|
326
|
+
at += 2
|
|
327
|
+
}
|
|
328
|
+
if (parenthesized) {
|
|
329
|
+
if (!isPunct(tokens[at], ")")) return undefined
|
|
330
|
+
at++
|
|
331
|
+
} else if (isPunct(tokens[at], "(")) return undefined // a function in FROM, whose alias list is safe
|
|
332
|
+
if (isPunct(tokens[at], "*")) at++
|
|
333
|
+
if (isKeyword(tokens[at], "as")) at++
|
|
334
|
+
const alias = tokens[at]
|
|
335
|
+
if (!isName(alias) || !isPunct(tokens[at + 1], "(")) return undefined
|
|
336
|
+
return { table: name.value, alias: alias.value }
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
/**
|
|
340
|
+
* Every table the statement gives a column alias list, as in `FROM users AS t(a, b)`. A verbose plan
|
|
341
|
+
* prints such a scan's columns by the alias names (its partitions' scans too), and the list can hand a
|
|
342
|
+
* real column's name to another one, so no check on the plan's column names can see what is read.
|
|
343
|
+
* A function's or a subquery's alias list is not reported: the plan prints the base columns under them.
|
|
344
|
+
*/
|
|
345
|
+
export function tableAliasLists(
|
|
346
|
+
sql: string,
|
|
347
|
+
): readonly { readonly table: string; readonly alias: string }[] {
|
|
348
|
+
const { tokens } = lexPostgres(sql)
|
|
349
|
+
const found: { readonly table: string; readonly alias: string }[] = []
|
|
350
|
+
// Per parenthesis depth: is a FROM list open there.
|
|
351
|
+
const inFromList: boolean[] = [false]
|
|
352
|
+
let itemStart = false
|
|
353
|
+
for (const [index, token] of tokens.entries()) {
|
|
354
|
+
const starts: boolean = itemStart
|
|
355
|
+
itemStart = false
|
|
356
|
+
if (starts) {
|
|
357
|
+
const listed = aliasListAt(tokens, index)
|
|
358
|
+
if (listed !== undefined) found.push(listed)
|
|
359
|
+
}
|
|
360
|
+
if (isPunct(token, "(")) {
|
|
361
|
+
// A parenthesized join holds a FROM list of its own; a subquery's SELECT closes it.
|
|
362
|
+
inFromList.push(starts)
|
|
363
|
+
itemStart = starts
|
|
364
|
+
} else if (isPunct(token, ")")) {
|
|
365
|
+
if (inFromList.length > 1) inFromList.pop()
|
|
366
|
+
} else if (isPunct(token, ",")) {
|
|
367
|
+
itemStart = inFromList.at(-1) === true
|
|
368
|
+
} else if (token.kind === "word" && token.bare) {
|
|
369
|
+
const word = token.value.toLowerCase()
|
|
370
|
+
if (word === "from" || word === "join") {
|
|
371
|
+
inFromList[inFromList.length - 1] = true
|
|
372
|
+
itemStart = true
|
|
373
|
+
} else if (FROM_LIST_ENDS.has(word)) inFromList[inFromList.length - 1] = false
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
return found
|
|
377
|
+
}
|
|
378
|
+
|
|
275
379
|
/** The statement without a final `;` (comments after it included), ready to wrap in a subquery. */
|
|
276
380
|
export function withoutTerminator(sql: string): string {
|
|
277
381
|
const last = lexPostgres(sql).tokens.at(-1)
|
package/src/internal/shared.ts
CHANGED
|
@@ -110,13 +110,83 @@ export function isDbRefusal(value: unknown): value is DbRefusal {
|
|
|
110
110
|
// Statement gates
|
|
111
111
|
// ---------------------------------------------------------------------------------------------------
|
|
112
112
|
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
113
|
+
// SQLite's four quotings, by opening delimiter: a 'literal' and the "x", `x` and [x] identifiers. Only
|
|
114
|
+
// the bracket form has no doubled-delimiter escape.
|
|
115
|
+
const QUOTE_CLOSE = new Map([
|
|
116
|
+
["'", "'"],
|
|
117
|
+
['"', '"'],
|
|
118
|
+
["`", "`"],
|
|
119
|
+
["[", "]"],
|
|
120
|
+
])
|
|
121
|
+
|
|
122
|
+
/** The index just past the quoted run opening at `start`, or the text's length when it never closes. */
|
|
123
|
+
function quotedEnd(sql: string, start: number, open: string, close: string): number {
|
|
124
|
+
for (let i = start + 1; i < sql.length; i++) {
|
|
125
|
+
if (sql[i] !== close) continue
|
|
126
|
+
if (open !== "[" && sql[i + 1] === close) i++
|
|
127
|
+
else return i + 1
|
|
128
|
+
}
|
|
129
|
+
return sql.length
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** The index just past a comment opening at `start`, or `undefined` when none opens there. An open
|
|
133
|
+
* block comment runs to the end of the text, as SQLite reads it. */
|
|
134
|
+
function commentEnd(sql: string, start: number): number | undefined {
|
|
135
|
+
if (sql[start] === "-" && sql[start + 1] === "-") {
|
|
136
|
+
const end = sql.indexOf("\n", start + 2)
|
|
137
|
+
return end === -1 ? sql.length : end + 1
|
|
138
|
+
}
|
|
139
|
+
if (sql[start] === "/" && sql[start + 1] === "*") {
|
|
140
|
+
const end = sql.indexOf("*/", start + 2)
|
|
141
|
+
return end === -1 ? sql.length : end + 2
|
|
142
|
+
}
|
|
143
|
+
return undefined
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** `sql` with each literal and quoted identifier emptied and each comment turned into a space. */
|
|
147
|
+
function stripSqlNoise(sql: string): string {
|
|
148
|
+
let out = ""
|
|
149
|
+
let from = 0
|
|
150
|
+
let i = 0
|
|
151
|
+
while (i < sql.length) {
|
|
152
|
+
const char = sql.charAt(i)
|
|
153
|
+
const comment = commentEnd(sql, i)
|
|
154
|
+
const close = QUOTE_CLOSE.get(char)
|
|
155
|
+
if (comment !== undefined) {
|
|
156
|
+
out += `${sql.slice(from, i)} `
|
|
157
|
+
i = from = comment
|
|
158
|
+
} else if (close !== undefined) {
|
|
159
|
+
out += `${sql.slice(from, i)}${char}${close}`
|
|
160
|
+
i = from = quotedEnd(sql, i, char, close)
|
|
161
|
+
} else {
|
|
162
|
+
i++
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
return out + sql.slice(from)
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Where the statement in `sql` ends: `end` is just past its last character of code (a trailing comment
|
|
170
|
+
* is not code, so the bounded wrapper's closing parenthesis can never land in one), and `terminator` is
|
|
171
|
+
* the index of its first `;` outside a literal, quoted identifier or comment, or -1.
|
|
172
|
+
*/
|
|
173
|
+
function statementEnd(sql: string): { readonly end: number; readonly terminator: number } {
|
|
174
|
+
let end = 0
|
|
175
|
+
let i = 0
|
|
176
|
+
while (i < sql.length) {
|
|
177
|
+
const char = sql.charAt(i)
|
|
178
|
+
if (char === ";") return { end, terminator: i }
|
|
179
|
+
const comment = commentEnd(sql, i)
|
|
180
|
+
if (comment !== undefined) {
|
|
181
|
+
i = comment
|
|
182
|
+
continue
|
|
183
|
+
}
|
|
184
|
+
const close = QUOTE_CLOSE.get(char)
|
|
185
|
+
i = close === undefined ? i + 1 : quotedEnd(sql, i, char, close)
|
|
186
|
+
if (char !== " " && char !== "\t" && char !== "\n" && char !== "\r" && char !== "\f") end = i
|
|
187
|
+
}
|
|
188
|
+
return { end, terminator: -1 }
|
|
189
|
+
}
|
|
120
190
|
|
|
121
191
|
// SQLite's bare-identifier alphabet. Non-ASCII is included because SQLite treats any byte >= 0x80 as
|
|
122
192
|
// an identifier character; leaving it out would end a word early and split one table name into two.
|
|
@@ -167,7 +237,7 @@ function tokenizeSql(sql: string): SqlToken[] {
|
|
|
167
237
|
tokens.push({ kind: "word", value, bare: false })
|
|
168
238
|
}
|
|
169
239
|
while (i < sql.length) {
|
|
170
|
-
const char = sql
|
|
240
|
+
const char = sql.charAt(i)
|
|
171
241
|
if (char === " " || char === "\t" || char === "\n" || char === "\r" || char === "\f") {
|
|
172
242
|
i++
|
|
173
243
|
} else if (char === "-" && sql[i + 1] === "-") {
|
|
@@ -303,7 +373,6 @@ function relationNames(sql: string): string[] {
|
|
|
303
373
|
return [...new Set(names)]
|
|
304
374
|
}
|
|
305
375
|
|
|
306
|
-
/** Reject multi-statement input: allow one terminator only when it is the final character. */
|
|
307
376
|
/**
|
|
308
377
|
* Names a statement defines as CTEs, column lists included (`WITH RECURSIVE c(x) AS (...)`), which
|
|
309
378
|
* {@link gateSqliteStatement}'s relation scan does not see past. Only for callers whose own check of
|
|
@@ -334,50 +403,10 @@ export function sqliteCteNames(sql: string): string[] {
|
|
|
334
403
|
return [...names]
|
|
335
404
|
}
|
|
336
405
|
|
|
406
|
+
/** Reject multi-statement input: allow one terminator only when it is the final character. */
|
|
337
407
|
const isSingleStatement = (sql: string): boolean => {
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
let blockComment = false
|
|
341
|
-
for (let i = 0; i < sql.length; i++) {
|
|
342
|
-
const char = sql[i]
|
|
343
|
-
const next = sql[i + 1]
|
|
344
|
-
if (lineComment) {
|
|
345
|
-
if (char === "\n") lineComment = false
|
|
346
|
-
continue
|
|
347
|
-
}
|
|
348
|
-
if (blockComment) {
|
|
349
|
-
if (char === "*" && next === "/") {
|
|
350
|
-
blockComment = false
|
|
351
|
-
i++
|
|
352
|
-
}
|
|
353
|
-
continue
|
|
354
|
-
}
|
|
355
|
-
if (quote !== null) {
|
|
356
|
-
if (char === quote) {
|
|
357
|
-
if (next === quote) i++
|
|
358
|
-
else quote = null
|
|
359
|
-
}
|
|
360
|
-
continue
|
|
361
|
-
}
|
|
362
|
-
if (char === "'" || char === '"') {
|
|
363
|
-
quote = char
|
|
364
|
-
continue
|
|
365
|
-
}
|
|
366
|
-
if (char === "-" && next === "-") {
|
|
367
|
-
lineComment = true
|
|
368
|
-
i++
|
|
369
|
-
continue
|
|
370
|
-
}
|
|
371
|
-
if (char === "/" && next === "*") {
|
|
372
|
-
blockComment = true
|
|
373
|
-
i++
|
|
374
|
-
continue
|
|
375
|
-
}
|
|
376
|
-
if (char === ";") {
|
|
377
|
-
return sql.slice(i + 1).trim() === ""
|
|
378
|
-
}
|
|
379
|
-
}
|
|
380
|
-
return true
|
|
408
|
+
const { terminator } = statementEnd(sql)
|
|
409
|
+
return terminator === -1 || sql.slice(terminator + 1).trim() === ""
|
|
381
410
|
}
|
|
382
411
|
|
|
383
412
|
const isReadStatement = (sql: string): boolean => {
|
|
@@ -385,58 +414,8 @@ const isReadStatement = (sql: string): boolean => {
|
|
|
385
414
|
return head.startsWith("select") || head.startsWith("with")
|
|
386
415
|
}
|
|
387
416
|
|
|
388
|
-
/**
|
|
389
|
-
|
|
390
|
-
* wrapper's closing parenthesis. */
|
|
391
|
-
function executableSql(sql: string): string {
|
|
392
|
-
let quote: "'" | '"' | null = null
|
|
393
|
-
let lineComment = false
|
|
394
|
-
let lineCommentStart = -1
|
|
395
|
-
let blockComment = false
|
|
396
|
-
for (let i = 0; i < sql.length; i++) {
|
|
397
|
-
const char = sql[i]
|
|
398
|
-
const next = sql[i + 1]
|
|
399
|
-
if (lineComment) {
|
|
400
|
-
if (char === "\n") {
|
|
401
|
-
lineComment = false
|
|
402
|
-
lineCommentStart = -1
|
|
403
|
-
}
|
|
404
|
-
continue
|
|
405
|
-
}
|
|
406
|
-
if (blockComment) {
|
|
407
|
-
if (char === "*" && next === "/") {
|
|
408
|
-
blockComment = false
|
|
409
|
-
i++
|
|
410
|
-
}
|
|
411
|
-
continue
|
|
412
|
-
}
|
|
413
|
-
if (quote !== null) {
|
|
414
|
-
if (char === quote) {
|
|
415
|
-
if (next === quote) i++
|
|
416
|
-
else quote = null
|
|
417
|
-
}
|
|
418
|
-
continue
|
|
419
|
-
}
|
|
420
|
-
if (char === "'" || char === '"') {
|
|
421
|
-
quote = char
|
|
422
|
-
continue
|
|
423
|
-
}
|
|
424
|
-
if (char === "-" && next === "-") {
|
|
425
|
-
lineComment = true
|
|
426
|
-
lineCommentStart = i
|
|
427
|
-
i++
|
|
428
|
-
continue
|
|
429
|
-
}
|
|
430
|
-
if (char === "/" && next === "*") {
|
|
431
|
-
blockComment = true
|
|
432
|
-
i++
|
|
433
|
-
continue
|
|
434
|
-
}
|
|
435
|
-
if (char === ";") return sql.slice(0, i).trim()
|
|
436
|
-
}
|
|
437
|
-
if (lineComment && lineCommentStart >= 0) return sql.slice(0, lineCommentStart).trim()
|
|
438
|
-
return sql.trim()
|
|
439
|
-
}
|
|
417
|
+
/** The statement without its terminator or a trailing comment, literals and identifiers intact. */
|
|
418
|
+
const executableSql = (sql: string): string => sql.slice(0, statementEnd(sql).end)
|
|
440
419
|
|
|
441
420
|
/** The outcome of {@link gateSqliteStatement}. A refusal's `text` is the message `run_query` returns. */
|
|
442
421
|
export type SqliteGate =
|
|
@@ -451,8 +430,8 @@ export type SqliteGate =
|
|
|
451
430
|
/**
|
|
452
431
|
* Gate one statement before SQLite sees it: non-empty, a single statement, SELECT/WITH only, and every
|
|
453
432
|
* relation it names after `FROM`/`JOIN`/`IN` exposed. Returns the statement without its terminator, ready
|
|
454
|
-
* to wrap.
|
|
455
|
-
*
|
|
433
|
+
* to wrap. The compiled program ({@link unexposedProgramRelation}) remains the authority on the
|
|
434
|
+
* tables a statement reads; this check refuses a named relation before SQLite compiles anything.
|
|
456
435
|
*/
|
|
457
436
|
export function gateSqliteStatement(
|
|
458
437
|
input: string,
|
|
@@ -486,7 +465,9 @@ export function gateSqliteStatement(
|
|
|
486
465
|
|
|
487
466
|
/**
|
|
488
467
|
* The first relation an `EXPLAIN QUERY PLAN` result scans or searches that `exposed` rejects, as the
|
|
489
|
-
* plan spells it, or `undefined` when every scanned relation is exposed.
|
|
468
|
+
* plan spells it, or `undefined` when every scanned relation is exposed. The plan names a scan by its
|
|
469
|
+
* alias (`FROM habits h` plans as `SCAN h`, `users AS habits` as `SCAN habits`), so it cannot decide
|
|
470
|
+
* on its own which tables a statement reads: the compiled program names them by root page.
|
|
490
471
|
*/
|
|
491
472
|
export function unexposedPlanRelation(
|
|
492
473
|
planRows: readonly { readonly detail?: unknown }[],
|
package/src/postgres.ts
CHANGED
|
@@ -39,7 +39,12 @@ import {
|
|
|
39
39
|
type PostgresClient,
|
|
40
40
|
records,
|
|
41
41
|
} from "./internal/pg-exec.ts"
|
|
42
|
-
import {
|
|
42
|
+
import {
|
|
43
|
+
DENIED_FUNCTION_PREFIXES,
|
|
44
|
+
lintPostgres,
|
|
45
|
+
tableAliasLists,
|
|
46
|
+
withoutTerminator,
|
|
47
|
+
} from "./internal/pg-lexer.ts"
|
|
43
48
|
import {
|
|
44
49
|
type DbPlan,
|
|
45
50
|
type DbRedaction,
|
|
@@ -342,9 +347,14 @@ function scopeMatcher(scope: PostgresScope): (relation: PlanRelation) => string
|
|
|
342
347
|
* The plan-level table check: each relation, and every table it inherits from (partitions too). A
|
|
343
348
|
* `pg_*` function in FROM reads server state the way a catalog does - a system view such as
|
|
344
349
|
* `pg_stat_activity` plans as one once the planner drops its catalog joins - so it is out of scope too.
|
|
345
|
-
* With `masked`, a plan expression that uses a masked column, or a whole row holding one, is refused
|
|
350
|
+
* With `masked`, a plan expression that uses a masked column, or a whole row holding one, is refused,
|
|
351
|
+
* and so is any masked column of a relation `statement` reads when it gives a table a column alias list.
|
|
346
352
|
*/
|
|
347
|
-
function checkScope(
|
|
353
|
+
function checkScope(
|
|
354
|
+
scope: PostgresScope,
|
|
355
|
+
masked: ((column: string) => boolean) | undefined,
|
|
356
|
+
statement: string,
|
|
357
|
+
) {
|
|
348
358
|
const outOfScope = scopeMatcher(scope)
|
|
349
359
|
return async (connection: PgConnection, reads: PlanReads): Promise<DbRefusal | undefined> => {
|
|
350
360
|
for (const fn of reads.functions) {
|
|
@@ -393,6 +403,9 @@ function checkScope(scope: PostgresScope, masked?: (column: string) => boolean)
|
|
|
393
403
|
if (column === "*") wholeRows.push(relation)
|
|
394
404
|
else if (masked(column)) hits.add(`${relation.name}.${column}`)
|
|
395
405
|
}
|
|
406
|
+
// The plan names an alias-listed scan's columns by the list, which can reuse a real column's name.
|
|
407
|
+
const renamed = tableAliasLists(statement)
|
|
408
|
+
if (renamed.length > 0) wholeRows.push(...reads.relations)
|
|
396
409
|
const listed = [...wholeRows, ...reads.outputs.map((output) => output.relation)]
|
|
397
410
|
if (listed.length > 0) {
|
|
398
411
|
const live = new Map<string, string[]>()
|
|
@@ -423,6 +436,13 @@ function checkScope(scope: PostgresScope, masked?: (column: string) => boolean)
|
|
|
423
436
|
}
|
|
424
437
|
}
|
|
425
438
|
if (hits.size === 0) return undefined
|
|
439
|
+
const [aliased] = renamed
|
|
440
|
+
if (aliased !== undefined) {
|
|
441
|
+
return dbRefusal(
|
|
442
|
+
"NIFRA_DB_COLUMN_REFUSED",
|
|
443
|
+
`the query renames the columns of ${aliased.table} with the alias list ${aliased.alias}(...), so the plan cannot show which columns it reads, and it reads a relation holding ${[...hits].join(", ")}, masked as ${hits.size === 1 ? "a credential" : "credentials"}; alias columns in the select list instead`,
|
|
444
|
+
)
|
|
445
|
+
}
|
|
426
446
|
return dbRefusal(
|
|
427
447
|
"NIFRA_DB_COLUMN_REFUSED",
|
|
428
448
|
`the query reads ${[...hits].join(", ")}, masked as ${hits.size === 1 ? "a credential" : "credentials"}`,
|
|
@@ -465,7 +485,7 @@ export async function queryPostgres(
|
|
|
465
485
|
const fetched = await gated(client, sql, options, (connection, statement) =>
|
|
466
486
|
fetchThroughCursor(connection, statement, {
|
|
467
487
|
maxRows: options.maxRows,
|
|
468
|
-
scope: checkScope(options, options.redaction?.column),
|
|
488
|
+
scope: checkScope(options, options.redaction?.column, statement),
|
|
469
489
|
}),
|
|
470
490
|
)
|
|
471
491
|
if (isDbRefusal(fetched)) return fetched
|
|
@@ -494,7 +514,7 @@ export async function explainPostgres(
|
|
|
494
514
|
const explained = await gated(client, sql, options, (connection, statement) =>
|
|
495
515
|
explainThroughServer(connection, statement, {
|
|
496
516
|
analyze: options.analyze === true,
|
|
497
|
-
scope: checkScope(options, options.redaction?.column),
|
|
517
|
+
scope: checkScope(options, options.redaction?.column, statement),
|
|
498
518
|
}),
|
|
499
519
|
)
|
|
500
520
|
if (isDbRefusal(explained)) return explained
|