graphile-sql-expression-validator 0.2.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/LICENSE ADDED
@@ -0,0 +1,23 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2025 Dan Lynch <pyramation@gmail.com>
4
+ Copyright (c) 2025 Constructive <developers@constructive.io>
5
+ Copyright (c) 2020-present, Interweb, Inc.
6
+
7
+ Permission is hereby granted, free of charge, to any person obtaining a copy
8
+ of this software and associated documentation files (the "Software"), to deal
9
+ in the Software without restriction, including without limitation the rights
10
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
11
+ copies of the Software, and to permit persons to whom the Software is
12
+ furnished to do so, subject to the following conditions:
13
+
14
+ The above copyright notice and this permission notice shall be included in all
15
+ copies or substantial portions of the Software.
16
+
17
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
18
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
19
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
20
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
21
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
22
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
23
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,181 @@
1
+ # graphile-sql-expression-validator
2
+
3
+ A Graphile plugin for SQL expression validation and AST normalization. This plugin validates SQL expressions at the GraphQL layer before they reach the database, preventing SQL injection and ensuring only safe expressions are executed.
4
+
5
+ ## Installation
6
+
7
+ ```sh
8
+ npm install graphile-sql-expression-validator
9
+ ```
10
+
11
+ ## Usage
12
+
13
+ ### Smart Comments
14
+
15
+ Tag columns that contain SQL expressions with `@sqlExpression`:
16
+
17
+ ```sql
18
+ COMMENT ON COLUMN collections_public.field.default_value IS E'@sqlExpression';
19
+ ```
20
+
21
+ The plugin will automatically look for a companion `*_ast` column (e.g., `default_value_ast`) to store the parsed AST.
22
+
23
+ #### Custom AST Field Name
24
+
25
+ By default, the plugin looks for a companion column named `<column>_ast`. You can override this with `@rawSqlAstField`:
26
+
27
+ ```sql
28
+ -- Use a custom AST column name
29
+ COMMENT ON COLUMN collections_public.field.default_value IS E'@sqlExpression\n@rawSqlAstField my_custom_ast_column';
30
+ ```
31
+
32
+ If `@rawSqlAstField` points to a non-existent column, the plugin will throw an error. If not specified, it falls back to the `<column>_ast` convention (and silently skips AST storage if that column doesn't exist).
33
+
34
+ ### Plugin Configuration
35
+
36
+ ```typescript
37
+ import SqlExpressionValidatorPlugin from 'graphile-sql-expression-validator';
38
+
39
+ const postgraphileOptions = {
40
+ appendPlugins: [SqlExpressionValidatorPlugin],
41
+ graphileBuildOptions: {
42
+ sqlExpressionValidator: {
43
+ // Optional: Additional allowed functions beyond defaults
44
+ allowedFunctions: ['my_custom_function'],
45
+ // Optional: Allowed schema names for schema-qualified functions
46
+ allowedSchemas: ['my_schema'],
47
+ // Optional: Maximum expression length (default: 10000)
48
+ maxExpressionLength: 5000,
49
+ // Optional: Auto-allow schemas owned by the current database
50
+ // Queries: SELECT schema_name FROM collections_public.schema
51
+ // WHERE database_id = jwt_private.current_database_id()
52
+ allowOwnedSchemas: true,
53
+ // Optional: Custom hook for dynamic schema resolution
54
+ getAdditionalAllowedSchemas: async (context) => {
55
+ // Return additional allowed schemas based on request context
56
+ return ['dynamic_schema'];
57
+ },
58
+ },
59
+ },
60
+ };
61
+ ```
62
+
63
+ ## How It Works
64
+
65
+ 1. **On mutation input**, the plugin detects fields tagged with `@sqlExpression`
66
+ 2. **If text is provided**: Parses the SQL expression, validates the AST, and stores both the canonical text and AST
67
+ 3. **If AST is provided**: Validates the AST and deparses to canonical text
68
+ 4. **Validation includes**:
69
+ - Node type allowlist (constants, casts, operators, function calls)
70
+ - Function name allowlist for unqualified functions
71
+ - Schema allowlist for schema-qualified functions
72
+ - Rejection of dangerous constructs (subqueries, DDL, DML, column references)
73
+
74
+ ## Default Allowed Functions
75
+
76
+ - `uuid_generate_v4`
77
+ - `gen_random_uuid`
78
+ - `now`
79
+ - `current_timestamp`
80
+ - `current_date`
81
+ - `current_time`
82
+ - `localtime`
83
+ - `localtimestamp`
84
+ - `clock_timestamp`
85
+ - `statement_timestamp`
86
+ - `transaction_timestamp`
87
+ - `timeofday`
88
+ - `random`
89
+ - `setseed`
90
+
91
+ ## API
92
+
93
+ ### `parseAndValidateSqlExpression(expression, options)`
94
+
95
+ Parse and validate a SQL expression string.
96
+
97
+ ```typescript
98
+ import { parseAndValidateSqlExpression } from 'graphile-sql-expression-validator';
99
+
100
+ const result = parseAndValidateSqlExpression('uuid_generate_v4()');
101
+ // { valid: true, ast: {...}, canonicalText: 'uuid_generate_v4()' }
102
+
103
+ const invalid = parseAndValidateSqlExpression('SELECT * FROM users');
104
+ // { valid: false, error: 'Forbidden node type "SelectStmt"...' }
105
+ ```
106
+
107
+ ### `validateAst(ast, options)`
108
+
109
+ Validate an existing AST and get canonical text.
110
+
111
+ ```typescript
112
+ import { validateAst } from 'graphile-sql-expression-validator';
113
+
114
+ const result = validateAst(myAst);
115
+ // { valid: true, canonicalText: 'uuid_generate_v4()' }
116
+ ```
117
+
118
+ ## Security Notes
119
+
120
+ - This plugin provides defense-in-depth at the GraphQL layer
121
+ - It does not replace database-level security measures
122
+ - Superuser/admin paths that bypass GraphQL are not protected
123
+ - Always use RLS and proper database permissions as the primary security layer
124
+
125
+ ---
126
+
127
+ ## Education and Tutorials
128
+
129
+ 1. 🚀 [Quickstart: Getting Up and Running](https://constructive.io/learn/quickstart)
130
+ Get started with modular databases in minutes. Install prerequisites and deploy your first module.
131
+
132
+ 2. 📦 [Modular PostgreSQL Development with Database Packages](https://constructive.io/learn/modular-postgres)
133
+ Learn to organize PostgreSQL projects with pgpm workspaces and reusable database modules.
134
+
135
+ 3. ✏️ [Authoring Database Changes](https://constructive.io/learn/authoring-database-changes)
136
+ Master the workflow for adding, organizing, and managing database changes with pgpm.
137
+
138
+ 4. 🧪 [End-to-End PostgreSQL Testing with TypeScript](https://constructive.io/learn/e2e-postgres-testing)
139
+ Master end-to-end PostgreSQL testing with ephemeral databases, RLS testing, and CI/CD automation.
140
+
141
+ 5. ⚡ [Supabase Testing](https://constructive.io/learn/supabase)
142
+ Use TypeScript-first tools to test Supabase projects with realistic RLS, policies, and auth contexts.
143
+
144
+ 6. 💧 [Drizzle ORM Testing](https://constructive.io/learn/drizzle-testing)
145
+ Run full-stack tests with Drizzle ORM, including database setup, teardown, and RLS enforcement.
146
+
147
+ 7. 🔧 [Troubleshooting](https://constructive.io/learn/troubleshooting)
148
+ Common issues and solutions for pgpm, PostgreSQL, and testing.
149
+
150
+ ## Related Constructive Tooling
151
+
152
+ ### 📦 Package Management
153
+
154
+ * [pgpm](https://github.com/constructive-io/constructive/tree/main/pgpm/pgpm): **🖥️ PostgreSQL Package Manager** for modular Postgres development. Works with database workspaces, scaffolding, migrations, seeding, and installing database packages.
155
+
156
+ ### 🧪 Testing
157
+
158
+ * [pgsql-test](https://github.com/constructive-io/constructive/tree/main/postgres/pgsql-test): **📊 Isolated testing environments** with per-test transaction rollbacks—ideal for integration tests, complex migrations, and RLS simulation.
159
+ * [pgsql-seed](https://github.com/constructive-io/constructive/tree/main/postgres/pgsql-seed): **🌱 PostgreSQL seeding utilities** for CSV, JSON, SQL data loading, and pgpm deployment.
160
+ * [supabase-test](https://github.com/constructive-io/constructive/tree/main/postgres/supabase-test): **🧪 Supabase-native test harness** preconfigured for the local Supabase stack—per-test rollbacks, JWT/role context helpers, and CI/GitHub Actions ready.
161
+ * [graphile-test](https://github.com/constructive-io/constructive/tree/main/graphile/graphile-test): **🔐 Authentication mocking** for Graphile-focused test helpers and emulating row-level security contexts.
162
+ * [pg-query-context](https://github.com/constructive-io/constructive/tree/main/postgres/pg-query-context): **🔒 Session context injection** to add session-local context (e.g., `SET LOCAL`) into queries—ideal for setting `role`, `jwt.claims`, and other session settings.
163
+
164
+ ### 🧠 Parsing & AST
165
+
166
+ * [pgsql-parser](https://www.npmjs.com/package/pgsql-parser): **🔄 SQL conversion engine** that interprets and converts PostgreSQL syntax.
167
+ * [libpg-query-node](https://www.npmjs.com/package/libpg-query): **🌉 Node.js bindings** for `libpg_query`, converting SQL into parse trees.
168
+ * [pg-proto-parser](https://www.npmjs.com/package/pg-proto-parser): **📦 Protobuf parser** for parsing PostgreSQL Protocol Buffers definitions to generate TypeScript interfaces, utility functions, and JSON mappings for enums.
169
+ * [@pgsql/enums](https://www.npmjs.com/package/@pgsql/enums): **🏷️ TypeScript enums** for PostgreSQL AST for safe and ergonomic parsing logic.
170
+ * [@pgsql/types](https://www.npmjs.com/package/@pgsql/types): **📝 Type definitions** for PostgreSQL AST nodes in TypeScript.
171
+ * [@pgsql/utils](https://www.npmjs.com/package/@pgsql/utils): **🛠️ AST utilities** for constructing and transforming PostgreSQL syntax trees.
172
+
173
+ ## Credits
174
+
175
+ **🛠 Built by the [Constructive](https://constructive.io) team — creators of modular Postgres tooling for secure, composable backends. If you like our work, contribute on [GitHub](https://github.com/constructive-io).**
176
+
177
+ ## Disclaimer
178
+
179
+ AS DESCRIBED IN THE LICENSES, THE SOFTWARE IS PROVIDED "AS IS", AT YOUR OWN RISK, AND WITHOUT WARRANTIES OF ANY KIND.
180
+
181
+ No developer or entity involved in creating this software will be liable for any claims or damages whatsoever associated with your use, inability to use, or your interaction with other users of the code, including any direct, indirect, incidental, special, exemplary, punitive or consequential damages, or loss of profits, cryptocurrencies, tokens, or anything else of value.
package/esm/index.d.ts ADDED
@@ -0,0 +1,21 @@
1
+ import type { Plugin } from 'graphile-build';
2
+ export interface SqlExpressionValidatorOptions {
3
+ allowedFunctions?: string[];
4
+ allowedSchemas?: string[];
5
+ maxExpressionLength?: number;
6
+ allowOwnedSchemas?: boolean;
7
+ getAdditionalAllowedSchemas?: (context: any) => Promise<string[]>;
8
+ }
9
+ export declare function parseAndValidateSqlExpression(expression: string, options?: SqlExpressionValidatorOptions): Promise<{
10
+ valid: boolean;
11
+ ast?: any;
12
+ canonicalText?: string;
13
+ error?: string;
14
+ }>;
15
+ export declare function validateAst(ast: any, options?: SqlExpressionValidatorOptions): Promise<{
16
+ valid: boolean;
17
+ canonicalText?: string;
18
+ error?: string;
19
+ }>;
20
+ declare const SqlExpressionValidatorPlugin: Plugin;
21
+ export default SqlExpressionValidatorPlugin;
package/esm/index.js ADDED
@@ -0,0 +1,341 @@
1
+ import { parse } from 'pgsql-parser';
2
+ import { deparse } from 'pgsql-deparser';
3
+ const DEFAULT_ALLOWED_FUNCTIONS = [
4
+ 'uuid_generate_v4',
5
+ 'gen_random_uuid',
6
+ 'now',
7
+ 'current_timestamp',
8
+ 'current_date',
9
+ 'current_time',
10
+ 'localtime',
11
+ 'localtimestamp',
12
+ 'clock_timestamp',
13
+ 'statement_timestamp',
14
+ 'transaction_timestamp',
15
+ 'timeofday',
16
+ 'random',
17
+ 'setseed',
18
+ ];
19
+ const ALLOWED_NODE_TYPES = new Set([
20
+ 'A_Const',
21
+ 'TypeCast',
22
+ 'A_Expr',
23
+ 'FuncCall',
24
+ 'CoalesceExpr',
25
+ 'NullTest',
26
+ 'BoolExpr',
27
+ 'CaseExpr',
28
+ 'CaseWhen',
29
+ ]);
30
+ const FORBIDDEN_NODE_TYPES = new Set([
31
+ 'SelectStmt',
32
+ 'InsertStmt',
33
+ 'UpdateStmt',
34
+ 'DeleteStmt',
35
+ 'CreateStmt',
36
+ 'AlterTableStmt',
37
+ 'DropStmt',
38
+ 'TruncateStmt',
39
+ 'ColumnRef',
40
+ 'SubLink',
41
+ 'RangeVar',
42
+ 'RangeSubselect',
43
+ 'JoinExpr',
44
+ 'FromExpr',
45
+ ]);
46
+ function getNodeType(node) {
47
+ if (!node || typeof node !== 'object')
48
+ return null;
49
+ const keys = Object.keys(node);
50
+ if (keys.length === 1)
51
+ return keys[0];
52
+ return null;
53
+ }
54
+ function validateAstNode(node, allowedFunctions, allowedSchemas, path = []) {
55
+ if (!node || typeof node !== 'object') {
56
+ return { valid: true };
57
+ }
58
+ const nodeType = getNodeType(node);
59
+ if (nodeType && FORBIDDEN_NODE_TYPES.has(nodeType)) {
60
+ return {
61
+ valid: false,
62
+ error: `Forbidden node type "${nodeType}" at path: ${path.join('.')}`,
63
+ };
64
+ }
65
+ if (nodeType === 'FuncCall') {
66
+ const funcCall = node.FuncCall;
67
+ const funcName = funcCall?.funcname;
68
+ if (Array.isArray(funcName)) {
69
+ const names = funcName.map((n) => n.String?.sval || n.str || '');
70
+ const schemaName = names.length > 1 ? names[0] : null;
71
+ const functionName = names[names.length - 1];
72
+ if (schemaName) {
73
+ if (!allowedSchemas.includes(schemaName)) {
74
+ return {
75
+ valid: false,
76
+ error: `Function schema "${schemaName}" is not in the allowed schemas list`,
77
+ };
78
+ }
79
+ }
80
+ else {
81
+ if (!allowedFunctions.includes(functionName.toLowerCase())) {
82
+ return {
83
+ valid: false,
84
+ error: `Function "${functionName}" is not in the allowed functions list`,
85
+ };
86
+ }
87
+ }
88
+ }
89
+ }
90
+ for (const [key, value] of Object.entries(node)) {
91
+ if (Array.isArray(value)) {
92
+ for (let i = 0; i < value.length; i++) {
93
+ const result = validateAstNode(value[i], allowedFunctions, allowedSchemas, [...path, key, String(i)]);
94
+ if (!result.valid)
95
+ return result;
96
+ }
97
+ }
98
+ else if (value && typeof value === 'object') {
99
+ const result = validateAstNode(value, allowedFunctions, allowedSchemas, [
100
+ ...path,
101
+ key,
102
+ ]);
103
+ if (!result.valid)
104
+ return result;
105
+ }
106
+ }
107
+ return { valid: true };
108
+ }
109
+ export async function parseAndValidateSqlExpression(expression, options = {}) {
110
+ const { allowedFunctions = DEFAULT_ALLOWED_FUNCTIONS, allowedSchemas = [], maxExpressionLength = 10000, } = options;
111
+ if (!expression || typeof expression !== 'string') {
112
+ return { valid: false, error: 'Expression must be a non-empty string' };
113
+ }
114
+ if (expression.length > maxExpressionLength) {
115
+ return {
116
+ valid: false,
117
+ error: `Expression exceeds maximum length of ${maxExpressionLength} characters`,
118
+ };
119
+ }
120
+ if (expression.includes(';')) {
121
+ return {
122
+ valid: false,
123
+ error: 'Expression cannot contain semicolons (no stacked statements)',
124
+ };
125
+ }
126
+ try {
127
+ const wrappedSql = `SELECT (${expression})`;
128
+ const parseResult = await parse(wrappedSql);
129
+ if (!parseResult ||
130
+ !parseResult.stmts ||
131
+ parseResult.stmts.length !== 1) {
132
+ return { valid: false, error: 'Failed to parse expression' };
133
+ }
134
+ const stmt = parseResult.stmts[0]?.stmt;
135
+ if (!stmt?.SelectStmt) {
136
+ return { valid: false, error: 'Unexpected parse result structure' };
137
+ }
138
+ const targetList = stmt.SelectStmt.targetList;
139
+ if (!targetList || targetList.length !== 1) {
140
+ return { valid: false, error: 'Expected single expression' };
141
+ }
142
+ const resTarget = targetList[0]?.ResTarget;
143
+ if (!resTarget || !resTarget.val) {
144
+ return { valid: false, error: 'Could not extract expression from parse result' };
145
+ }
146
+ const expressionAst = resTarget.val;
147
+ const validationResult = validateAstNode(expressionAst, allowedFunctions.map((f) => f.toLowerCase()), allowedSchemas);
148
+ if (!validationResult.valid) {
149
+ return { valid: false, error: validationResult.error };
150
+ }
151
+ let canonicalText;
152
+ try {
153
+ canonicalText = await deparse([expressionAst]);
154
+ }
155
+ catch (deparseError) {
156
+ return {
157
+ valid: false,
158
+ error: `Failed to deparse expression: ${deparseError}`,
159
+ };
160
+ }
161
+ return {
162
+ valid: true,
163
+ ast: expressionAst,
164
+ canonicalText,
165
+ };
166
+ }
167
+ catch (parseError) {
168
+ return {
169
+ valid: false,
170
+ error: `Failed to parse SQL expression: ${parseError.message || parseError}`,
171
+ };
172
+ }
173
+ }
174
+ export async function validateAst(ast, options = {}) {
175
+ const { allowedFunctions = DEFAULT_ALLOWED_FUNCTIONS, allowedSchemas = [] } = options;
176
+ if (!ast || typeof ast !== 'object') {
177
+ return { valid: false, error: 'AST must be a non-null object' };
178
+ }
179
+ const validationResult = validateAstNode(ast, allowedFunctions.map((f) => f.toLowerCase()), allowedSchemas);
180
+ if (!validationResult.valid) {
181
+ return { valid: false, error: validationResult.error };
182
+ }
183
+ try {
184
+ const canonicalText = await deparse([ast]);
185
+ return { valid: true, canonicalText };
186
+ }
187
+ catch (deparseError) {
188
+ return {
189
+ valid: false,
190
+ error: `Failed to deparse AST: ${deparseError}`,
191
+ };
192
+ }
193
+ }
194
+ const OWNED_SCHEMAS_CACHE_KEY = Symbol('sqlExpressionValidator.ownedSchemas');
195
+ async function resolveEffectiveOptions(baseOptions, gqlContext) {
196
+ const { allowedSchemas = [], allowOwnedSchemas = false, getAdditionalAllowedSchemas, ...rest } = baseOptions;
197
+ const effectiveSchemas = [...allowedSchemas];
198
+ if (allowOwnedSchemas && gqlContext?.pgClient) {
199
+ let ownedSchemas = gqlContext[OWNED_SCHEMAS_CACHE_KEY];
200
+ if (!ownedSchemas) {
201
+ try {
202
+ const result = await gqlContext.pgClient.query(`SELECT schema_name FROM collections_public.schema WHERE database_id = jwt_private.current_database_id()`);
203
+ ownedSchemas = result.rows.map((row) => row.schema_name);
204
+ gqlContext[OWNED_SCHEMAS_CACHE_KEY] = ownedSchemas;
205
+ }
206
+ catch (err) {
207
+ ownedSchemas = [];
208
+ }
209
+ }
210
+ effectiveSchemas.push(...ownedSchemas);
211
+ }
212
+ if (getAdditionalAllowedSchemas) {
213
+ try {
214
+ const additionalSchemas = await getAdditionalAllowedSchemas(gqlContext);
215
+ effectiveSchemas.push(...additionalSchemas);
216
+ }
217
+ catch (err) {
218
+ }
219
+ }
220
+ const uniqueSchemas = [...new Set(effectiveSchemas)];
221
+ return {
222
+ ...rest,
223
+ allowedSchemas: uniqueSchemas,
224
+ };
225
+ }
226
+ const SqlExpressionValidatorPlugin = (builder, options = {}) => {
227
+ const validatorOptions = options.sqlExpressionValidator || {};
228
+ builder.hook('GraphQLObjectType:fields:field', (field, build, context) => {
229
+ const { scope: { isRootMutation, fieldName, pgFieldIntrospection: table }, } = context;
230
+ if (!isRootMutation || !table) {
231
+ return field;
232
+ }
233
+ const sqlExpressionColumns = build.pgIntrospectionResultsByKind.attribute
234
+ .filter((attr) => attr.classId === table.id)
235
+ .filter((attr) => attr.tags?.sqlExpression);
236
+ if (sqlExpressionColumns.length === 0) {
237
+ return field;
238
+ }
239
+ const defaultResolver = (obj) => obj[fieldName];
240
+ const { resolve: oldResolve = defaultResolver, ...rest } = field;
241
+ return {
242
+ ...rest,
243
+ async resolve(source, args, gqlContext, info) {
244
+ const effectiveOptions = await resolveEffectiveOptions(validatorOptions, gqlContext);
245
+ for (const attr of sqlExpressionColumns) {
246
+ const textGqlName = build.inflection.column(attr);
247
+ const rawSqlAstFieldTag = attr.tags?.rawSqlAstField;
248
+ const astDbName = typeof rawSqlAstFieldTag === 'string' && rawSqlAstFieldTag.trim()
249
+ ? rawSqlAstFieldTag.trim()
250
+ : `${attr.name}_ast`;
251
+ const astAttr = build.pgIntrospectionResultsByKind.attribute.find((a) => a.classId === table.id && a.name === astDbName);
252
+ let astGqlName = null;
253
+ if (astAttr) {
254
+ astGqlName = build.inflection.column(astAttr);
255
+ }
256
+ else if (typeof rawSqlAstFieldTag === 'string' && rawSqlAstFieldTag.trim()) {
257
+ throw new Error(`@rawSqlAstField points to missing column "${astDbName}" on ${table.namespaceName}.${table.name}`);
258
+ }
259
+ const inputPath = findInputPath(args, textGqlName);
260
+ const astInputPath = astGqlName ? findInputPath(args, astGqlName) : null;
261
+ if (inputPath) {
262
+ const textValue = getNestedValue(args, inputPath);
263
+ if (textValue !== undefined && textValue !== null) {
264
+ const result = await parseAndValidateSqlExpression(textValue, effectiveOptions);
265
+ if (!result.valid) {
266
+ throw new Error(`Invalid SQL expression in ${textGqlName}: ${result.error}`);
267
+ }
268
+ setNestedValue(args, inputPath, result.canonicalText);
269
+ if (astGqlName && (astInputPath || canSetAstColumn(args, inputPath, astGqlName))) {
270
+ const astPath = astInputPath || replaceLastSegment(inputPath, astGqlName);
271
+ setNestedValue(args, astPath, result.ast);
272
+ }
273
+ }
274
+ }
275
+ if (astInputPath && !inputPath && astGqlName) {
276
+ const astValue = getNestedValue(args, astInputPath);
277
+ if (astValue !== undefined && astValue !== null) {
278
+ const result = await validateAst(astValue, effectiveOptions);
279
+ if (!result.valid) {
280
+ throw new Error(`Invalid SQL expression AST in ${astGqlName}: ${result.error}`);
281
+ }
282
+ const textPath = replaceLastSegment(astInputPath, textGqlName);
283
+ if (canSetColumn(args, astInputPath, textGqlName)) {
284
+ setNestedValue(args, textPath, result.canonicalText);
285
+ }
286
+ }
287
+ }
288
+ }
289
+ return oldResolve(source, args, gqlContext, info);
290
+ },
291
+ };
292
+ });
293
+ };
294
+ function findInputPath(obj, key, path = []) {
295
+ if (!obj || typeof obj !== 'object')
296
+ return null;
297
+ for (const [k, v] of Object.entries(obj)) {
298
+ if (k === key) {
299
+ return [...path, k];
300
+ }
301
+ if (v && typeof v === 'object') {
302
+ const result = findInputPath(v, key, [...path, k]);
303
+ if (result)
304
+ return result;
305
+ }
306
+ }
307
+ return null;
308
+ }
309
+ function getNestedValue(obj, path) {
310
+ let current = obj;
311
+ for (const key of path) {
312
+ if (current === null || current === undefined)
313
+ return undefined;
314
+ current = current[key];
315
+ }
316
+ return current;
317
+ }
318
+ function setNestedValue(obj, path, value) {
319
+ let current = obj;
320
+ for (let i = 0; i < path.length - 1; i++) {
321
+ if (current[path[i]] === undefined) {
322
+ current[path[i]] = {};
323
+ }
324
+ current = current[path[i]];
325
+ }
326
+ current[path[path.length - 1]] = value;
327
+ }
328
+ function replaceLastSegment(path, newSegment) {
329
+ return [...path.slice(0, -1), newSegment];
330
+ }
331
+ function canSetAstColumn(args, textPath, astColumnName) {
332
+ const parentPath = textPath.slice(0, -1);
333
+ const parent = getNestedValue(args, parentPath);
334
+ return parent && typeof parent === 'object';
335
+ }
336
+ function canSetColumn(args, astPath, columnName) {
337
+ const parentPath = astPath.slice(0, -1);
338
+ const parent = getNestedValue(args, parentPath);
339
+ return parent && typeof parent === 'object';
340
+ }
341
+ export default SqlExpressionValidatorPlugin;
package/index.d.ts ADDED
@@ -0,0 +1,21 @@
1
+ import type { Plugin } from 'graphile-build';
2
+ export interface SqlExpressionValidatorOptions {
3
+ allowedFunctions?: string[];
4
+ allowedSchemas?: string[];
5
+ maxExpressionLength?: number;
6
+ allowOwnedSchemas?: boolean;
7
+ getAdditionalAllowedSchemas?: (context: any) => Promise<string[]>;
8
+ }
9
+ export declare function parseAndValidateSqlExpression(expression: string, options?: SqlExpressionValidatorOptions): Promise<{
10
+ valid: boolean;
11
+ ast?: any;
12
+ canonicalText?: string;
13
+ error?: string;
14
+ }>;
15
+ export declare function validateAst(ast: any, options?: SqlExpressionValidatorOptions): Promise<{
16
+ valid: boolean;
17
+ canonicalText?: string;
18
+ error?: string;
19
+ }>;
20
+ declare const SqlExpressionValidatorPlugin: Plugin;
21
+ export default SqlExpressionValidatorPlugin;
package/index.js ADDED
@@ -0,0 +1,345 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.parseAndValidateSqlExpression = parseAndValidateSqlExpression;
4
+ exports.validateAst = validateAst;
5
+ const pgsql_parser_1 = require("pgsql-parser");
6
+ const pgsql_deparser_1 = require("pgsql-deparser");
7
+ const DEFAULT_ALLOWED_FUNCTIONS = [
8
+ 'uuid_generate_v4',
9
+ 'gen_random_uuid',
10
+ 'now',
11
+ 'current_timestamp',
12
+ 'current_date',
13
+ 'current_time',
14
+ 'localtime',
15
+ 'localtimestamp',
16
+ 'clock_timestamp',
17
+ 'statement_timestamp',
18
+ 'transaction_timestamp',
19
+ 'timeofday',
20
+ 'random',
21
+ 'setseed',
22
+ ];
23
+ const ALLOWED_NODE_TYPES = new Set([
24
+ 'A_Const',
25
+ 'TypeCast',
26
+ 'A_Expr',
27
+ 'FuncCall',
28
+ 'CoalesceExpr',
29
+ 'NullTest',
30
+ 'BoolExpr',
31
+ 'CaseExpr',
32
+ 'CaseWhen',
33
+ ]);
34
+ const FORBIDDEN_NODE_TYPES = new Set([
35
+ 'SelectStmt',
36
+ 'InsertStmt',
37
+ 'UpdateStmt',
38
+ 'DeleteStmt',
39
+ 'CreateStmt',
40
+ 'AlterTableStmt',
41
+ 'DropStmt',
42
+ 'TruncateStmt',
43
+ 'ColumnRef',
44
+ 'SubLink',
45
+ 'RangeVar',
46
+ 'RangeSubselect',
47
+ 'JoinExpr',
48
+ 'FromExpr',
49
+ ]);
50
+ function getNodeType(node) {
51
+ if (!node || typeof node !== 'object')
52
+ return null;
53
+ const keys = Object.keys(node);
54
+ if (keys.length === 1)
55
+ return keys[0];
56
+ return null;
57
+ }
58
+ function validateAstNode(node, allowedFunctions, allowedSchemas, path = []) {
59
+ if (!node || typeof node !== 'object') {
60
+ return { valid: true };
61
+ }
62
+ const nodeType = getNodeType(node);
63
+ if (nodeType && FORBIDDEN_NODE_TYPES.has(nodeType)) {
64
+ return {
65
+ valid: false,
66
+ error: `Forbidden node type "${nodeType}" at path: ${path.join('.')}`,
67
+ };
68
+ }
69
+ if (nodeType === 'FuncCall') {
70
+ const funcCall = node.FuncCall;
71
+ const funcName = funcCall?.funcname;
72
+ if (Array.isArray(funcName)) {
73
+ const names = funcName.map((n) => n.String?.sval || n.str || '');
74
+ const schemaName = names.length > 1 ? names[0] : null;
75
+ const functionName = names[names.length - 1];
76
+ if (schemaName) {
77
+ if (!allowedSchemas.includes(schemaName)) {
78
+ return {
79
+ valid: false,
80
+ error: `Function schema "${schemaName}" is not in the allowed schemas list`,
81
+ };
82
+ }
83
+ }
84
+ else {
85
+ if (!allowedFunctions.includes(functionName.toLowerCase())) {
86
+ return {
87
+ valid: false,
88
+ error: `Function "${functionName}" is not in the allowed functions list`,
89
+ };
90
+ }
91
+ }
92
+ }
93
+ }
94
+ for (const [key, value] of Object.entries(node)) {
95
+ if (Array.isArray(value)) {
96
+ for (let i = 0; i < value.length; i++) {
97
+ const result = validateAstNode(value[i], allowedFunctions, allowedSchemas, [...path, key, String(i)]);
98
+ if (!result.valid)
99
+ return result;
100
+ }
101
+ }
102
+ else if (value && typeof value === 'object') {
103
+ const result = validateAstNode(value, allowedFunctions, allowedSchemas, [
104
+ ...path,
105
+ key,
106
+ ]);
107
+ if (!result.valid)
108
+ return result;
109
+ }
110
+ }
111
+ return { valid: true };
112
+ }
113
+ async function parseAndValidateSqlExpression(expression, options = {}) {
114
+ const { allowedFunctions = DEFAULT_ALLOWED_FUNCTIONS, allowedSchemas = [], maxExpressionLength = 10000, } = options;
115
+ if (!expression || typeof expression !== 'string') {
116
+ return { valid: false, error: 'Expression must be a non-empty string' };
117
+ }
118
+ if (expression.length > maxExpressionLength) {
119
+ return {
120
+ valid: false,
121
+ error: `Expression exceeds maximum length of ${maxExpressionLength} characters`,
122
+ };
123
+ }
124
+ if (expression.includes(';')) {
125
+ return {
126
+ valid: false,
127
+ error: 'Expression cannot contain semicolons (no stacked statements)',
128
+ };
129
+ }
130
+ try {
131
+ const wrappedSql = `SELECT (${expression})`;
132
+ const parseResult = await (0, pgsql_parser_1.parse)(wrappedSql);
133
+ if (!parseResult ||
134
+ !parseResult.stmts ||
135
+ parseResult.stmts.length !== 1) {
136
+ return { valid: false, error: 'Failed to parse expression' };
137
+ }
138
+ const stmt = parseResult.stmts[0]?.stmt;
139
+ if (!stmt?.SelectStmt) {
140
+ return { valid: false, error: 'Unexpected parse result structure' };
141
+ }
142
+ const targetList = stmt.SelectStmt.targetList;
143
+ if (!targetList || targetList.length !== 1) {
144
+ return { valid: false, error: 'Expected single expression' };
145
+ }
146
+ const resTarget = targetList[0]?.ResTarget;
147
+ if (!resTarget || !resTarget.val) {
148
+ return { valid: false, error: 'Could not extract expression from parse result' };
149
+ }
150
+ const expressionAst = resTarget.val;
151
+ const validationResult = validateAstNode(expressionAst, allowedFunctions.map((f) => f.toLowerCase()), allowedSchemas);
152
+ if (!validationResult.valid) {
153
+ return { valid: false, error: validationResult.error };
154
+ }
155
+ let canonicalText;
156
+ try {
157
+ canonicalText = await (0, pgsql_deparser_1.deparse)([expressionAst]);
158
+ }
159
+ catch (deparseError) {
160
+ return {
161
+ valid: false,
162
+ error: `Failed to deparse expression: ${deparseError}`,
163
+ };
164
+ }
165
+ return {
166
+ valid: true,
167
+ ast: expressionAst,
168
+ canonicalText,
169
+ };
170
+ }
171
+ catch (parseError) {
172
+ return {
173
+ valid: false,
174
+ error: `Failed to parse SQL expression: ${parseError.message || parseError}`,
175
+ };
176
+ }
177
+ }
178
+ async function validateAst(ast, options = {}) {
179
+ const { allowedFunctions = DEFAULT_ALLOWED_FUNCTIONS, allowedSchemas = [] } = options;
180
+ if (!ast || typeof ast !== 'object') {
181
+ return { valid: false, error: 'AST must be a non-null object' };
182
+ }
183
+ const validationResult = validateAstNode(ast, allowedFunctions.map((f) => f.toLowerCase()), allowedSchemas);
184
+ if (!validationResult.valid) {
185
+ return { valid: false, error: validationResult.error };
186
+ }
187
+ try {
188
+ const canonicalText = await (0, pgsql_deparser_1.deparse)([ast]);
189
+ return { valid: true, canonicalText };
190
+ }
191
+ catch (deparseError) {
192
+ return {
193
+ valid: false,
194
+ error: `Failed to deparse AST: ${deparseError}`,
195
+ };
196
+ }
197
+ }
198
+ const OWNED_SCHEMAS_CACHE_KEY = Symbol('sqlExpressionValidator.ownedSchemas');
199
+ async function resolveEffectiveOptions(baseOptions, gqlContext) {
200
+ const { allowedSchemas = [], allowOwnedSchemas = false, getAdditionalAllowedSchemas, ...rest } = baseOptions;
201
+ const effectiveSchemas = [...allowedSchemas];
202
+ if (allowOwnedSchemas && gqlContext?.pgClient) {
203
+ let ownedSchemas = gqlContext[OWNED_SCHEMAS_CACHE_KEY];
204
+ if (!ownedSchemas) {
205
+ try {
206
+ const result = await gqlContext.pgClient.query(`SELECT schema_name FROM collections_public.schema WHERE database_id = jwt_private.current_database_id()`);
207
+ ownedSchemas = result.rows.map((row) => row.schema_name);
208
+ gqlContext[OWNED_SCHEMAS_CACHE_KEY] = ownedSchemas;
209
+ }
210
+ catch (err) {
211
+ ownedSchemas = [];
212
+ }
213
+ }
214
+ effectiveSchemas.push(...ownedSchemas);
215
+ }
216
+ if (getAdditionalAllowedSchemas) {
217
+ try {
218
+ const additionalSchemas = await getAdditionalAllowedSchemas(gqlContext);
219
+ effectiveSchemas.push(...additionalSchemas);
220
+ }
221
+ catch (err) {
222
+ }
223
+ }
224
+ const uniqueSchemas = [...new Set(effectiveSchemas)];
225
+ return {
226
+ ...rest,
227
+ allowedSchemas: uniqueSchemas,
228
+ };
229
+ }
230
+ const SqlExpressionValidatorPlugin = (builder, options = {}) => {
231
+ const validatorOptions = options.sqlExpressionValidator || {};
232
+ builder.hook('GraphQLObjectType:fields:field', (field, build, context) => {
233
+ const { scope: { isRootMutation, fieldName, pgFieldIntrospection: table }, } = context;
234
+ if (!isRootMutation || !table) {
235
+ return field;
236
+ }
237
+ const sqlExpressionColumns = build.pgIntrospectionResultsByKind.attribute
238
+ .filter((attr) => attr.classId === table.id)
239
+ .filter((attr) => attr.tags?.sqlExpression);
240
+ if (sqlExpressionColumns.length === 0) {
241
+ return field;
242
+ }
243
+ const defaultResolver = (obj) => obj[fieldName];
244
+ const { resolve: oldResolve = defaultResolver, ...rest } = field;
245
+ return {
246
+ ...rest,
247
+ async resolve(source, args, gqlContext, info) {
248
+ const effectiveOptions = await resolveEffectiveOptions(validatorOptions, gqlContext);
249
+ for (const attr of sqlExpressionColumns) {
250
+ const textGqlName = build.inflection.column(attr);
251
+ const rawSqlAstFieldTag = attr.tags?.rawSqlAstField;
252
+ const astDbName = typeof rawSqlAstFieldTag === 'string' && rawSqlAstFieldTag.trim()
253
+ ? rawSqlAstFieldTag.trim()
254
+ : `${attr.name}_ast`;
255
+ const astAttr = build.pgIntrospectionResultsByKind.attribute.find((a) => a.classId === table.id && a.name === astDbName);
256
+ let astGqlName = null;
257
+ if (astAttr) {
258
+ astGqlName = build.inflection.column(astAttr);
259
+ }
260
+ else if (typeof rawSqlAstFieldTag === 'string' && rawSqlAstFieldTag.trim()) {
261
+ throw new Error(`@rawSqlAstField points to missing column "${astDbName}" on ${table.namespaceName}.${table.name}`);
262
+ }
263
+ const inputPath = findInputPath(args, textGqlName);
264
+ const astInputPath = astGqlName ? findInputPath(args, astGqlName) : null;
265
+ if (inputPath) {
266
+ const textValue = getNestedValue(args, inputPath);
267
+ if (textValue !== undefined && textValue !== null) {
268
+ const result = await parseAndValidateSqlExpression(textValue, effectiveOptions);
269
+ if (!result.valid) {
270
+ throw new Error(`Invalid SQL expression in ${textGqlName}: ${result.error}`);
271
+ }
272
+ setNestedValue(args, inputPath, result.canonicalText);
273
+ if (astGqlName && (astInputPath || canSetAstColumn(args, inputPath, astGqlName))) {
274
+ const astPath = astInputPath || replaceLastSegment(inputPath, astGqlName);
275
+ setNestedValue(args, astPath, result.ast);
276
+ }
277
+ }
278
+ }
279
+ if (astInputPath && !inputPath && astGqlName) {
280
+ const astValue = getNestedValue(args, astInputPath);
281
+ if (astValue !== undefined && astValue !== null) {
282
+ const result = await validateAst(astValue, effectiveOptions);
283
+ if (!result.valid) {
284
+ throw new Error(`Invalid SQL expression AST in ${astGqlName}: ${result.error}`);
285
+ }
286
+ const textPath = replaceLastSegment(astInputPath, textGqlName);
287
+ if (canSetColumn(args, astInputPath, textGqlName)) {
288
+ setNestedValue(args, textPath, result.canonicalText);
289
+ }
290
+ }
291
+ }
292
+ }
293
+ return oldResolve(source, args, gqlContext, info);
294
+ },
295
+ };
296
+ });
297
+ };
298
+ function findInputPath(obj, key, path = []) {
299
+ if (!obj || typeof obj !== 'object')
300
+ return null;
301
+ for (const [k, v] of Object.entries(obj)) {
302
+ if (k === key) {
303
+ return [...path, k];
304
+ }
305
+ if (v && typeof v === 'object') {
306
+ const result = findInputPath(v, key, [...path, k]);
307
+ if (result)
308
+ return result;
309
+ }
310
+ }
311
+ return null;
312
+ }
313
+ function getNestedValue(obj, path) {
314
+ let current = obj;
315
+ for (const key of path) {
316
+ if (current === null || current === undefined)
317
+ return undefined;
318
+ current = current[key];
319
+ }
320
+ return current;
321
+ }
322
+ function setNestedValue(obj, path, value) {
323
+ let current = obj;
324
+ for (let i = 0; i < path.length - 1; i++) {
325
+ if (current[path[i]] === undefined) {
326
+ current[path[i]] = {};
327
+ }
328
+ current = current[path[i]];
329
+ }
330
+ current[path[path.length - 1]] = value;
331
+ }
332
+ function replaceLastSegment(path, newSegment) {
333
+ return [...path.slice(0, -1), newSegment];
334
+ }
335
+ function canSetAstColumn(args, textPath, astColumnName) {
336
+ const parentPath = textPath.slice(0, -1);
337
+ const parent = getNestedValue(args, parentPath);
338
+ return parent && typeof parent === 'object';
339
+ }
340
+ function canSetColumn(args, astPath, columnName) {
341
+ const parentPath = astPath.slice(0, -1);
342
+ const parent = getNestedValue(args, parentPath);
343
+ return parent && typeof parent === 'object';
344
+ }
345
+ exports.default = SqlExpressionValidatorPlugin;
package/package.json ADDED
@@ -0,0 +1,55 @@
1
+ {
2
+ "name": "graphile-sql-expression-validator",
3
+ "version": "0.2.0",
4
+ "description": "Graphile plugin for SQL expression validation and AST normalization",
5
+ "author": "Constructive <developers@constructive.io>",
6
+ "homepage": "https://github.com/constructive-io/constructive",
7
+ "license": "MIT",
8
+ "main": "index.js",
9
+ "module": "esm/index.js",
10
+ "types": "index.d.ts",
11
+ "scripts": {
12
+ "clean": "makage clean",
13
+ "prepack": "pnpm run build",
14
+ "build": "makage build",
15
+ "build:dev": "makage build --dev",
16
+ "lint": "eslint . --fix",
17
+ "test": "jest --passWithNoTests",
18
+ "test:watch": "jest --watch"
19
+ },
20
+ "publishConfig": {
21
+ "access": "public",
22
+ "directory": "dist"
23
+ },
24
+ "repository": {
25
+ "type": "git",
26
+ "url": "https://github.com/constructive-io/constructive"
27
+ },
28
+ "keywords": [
29
+ "postgraphile",
30
+ "graphile",
31
+ "constructive",
32
+ "pgpm",
33
+ "plugin",
34
+ "postgres",
35
+ "graphql",
36
+ "sql",
37
+ "ast",
38
+ "validation",
39
+ "security"
40
+ ],
41
+ "bugs": {
42
+ "url": "https://github.com/constructive-io/constructive/issues"
43
+ },
44
+ "devDependencies": {
45
+ "makage": "^0.1.9",
46
+ "ts-jest": "^29.4.6"
47
+ },
48
+ "dependencies": {
49
+ "graphile-build": "^4.14.1",
50
+ "graphql": "15.10.1",
51
+ "pgsql-deparser": "^17.17.0",
52
+ "pgsql-parser": "^17.9.9"
53
+ },
54
+ "gitHead": "e53cd1f303d796268d94b47e95cde30916e39c1f"
55
+ }