Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .github/workflows/run-tests.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,8 @@ jobs:
env: {}
- package: graphile/graphile-pg-type-mappings
env: {}
- package: graphile/graphile-sql-expression-validator
env: {}

env:
PGHOST: pg_db
Expand Down
123 changes: 123 additions & 0 deletions graphile/graphile-sql-expression-validator/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
# graphile-sql-expression-validator

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.

## Installation

```sh
npm install graphile-sql-expression-validator
```

## Usage

### Smart Comments

Tag columns that contain SQL expressions with `@sqlExpression`:

```sql
COMMENT ON COLUMN collections_public.field.default_value IS E'@sqlExpression';
```

The plugin will automatically look for a companion `*_ast` column (e.g., `default_value_ast`) to store the parsed AST.

#### Custom AST Field Name

By default, the plugin looks for a companion column named `<column>_ast`. You can override this with `@rawSqlAstField`:

```sql
-- Use a custom AST column name
COMMENT ON COLUMN collections_public.field.default_value IS E'@sqlExpression\n@rawSqlAstField my_custom_ast_column';
```

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).

### Plugin Configuration

```typescript
import SqlExpressionValidatorPlugin from 'graphile-sql-expression-validator';

const postgraphileOptions = {
appendPlugins: [SqlExpressionValidatorPlugin],
graphileBuildOptions: {
sqlExpressionValidator: {
// Optional: Additional allowed functions beyond defaults
allowedFunctions: ['my_custom_function'],
// Optional: Allowed schema names for schema-qualified functions
allowedSchemas: ['my_schema'],
// Optional: Maximum expression length (default: 10000)
maxExpressionLength: 5000,
// Optional: Auto-allow schemas owned by the current database
// Queries: SELECT schema_name FROM collections_public.schema
// WHERE database_id = jwt_private.current_database_id()
allowOwnedSchemas: true,
// Optional: Custom hook for dynamic schema resolution
getAdditionalAllowedSchemas: async (context) => {
// Return additional allowed schemas based on request context
return ['dynamic_schema'];
},
},
},
};
```

## How It Works

1. **On mutation input**, the plugin detects fields tagged with `@sqlExpression`
2. **If text is provided**: Parses the SQL expression, validates the AST, and stores both the canonical text and AST
3. **If AST is provided**: Validates the AST and deparses to canonical text
4. **Validation includes**:
- Node type allowlist (constants, casts, operators, function calls)
- Function name allowlist for unqualified functions
- Schema allowlist for schema-qualified functions
- Rejection of dangerous constructs (subqueries, DDL, DML, column references)

## Default Allowed Functions

- `uuid_generate_v4`
- `gen_random_uuid`
- `now`
- `current_timestamp`
- `current_date`
- `current_time`
- `localtime`
- `localtimestamp`
- `clock_timestamp`
- `statement_timestamp`
- `transaction_timestamp`
- `timeofday`
- `random`
- `setseed`

## API

### `parseAndValidateSqlExpression(expression, options)`

Parse and validate a SQL expression string.

```typescript
import { parseAndValidateSqlExpression } from 'graphile-sql-expression-validator';

const result = parseAndValidateSqlExpression('uuid_generate_v4()');
// { valid: true, ast: {...}, canonicalText: 'uuid_generate_v4()' }

const invalid = parseAndValidateSqlExpression('SELECT * FROM users');
// { valid: false, error: 'Forbidden node type "SelectStmt"...' }
```

### `validateAst(ast, options)`

Validate an existing AST and get canonical text.

```typescript
import { validateAst } from 'graphile-sql-expression-validator';

const result = validateAst(myAst);
// { valid: true, canonicalText: 'uuid_generate_v4()' }
```

## Security Notes

- This plugin provides defense-in-depth at the GraphQL layer
- It does not replace database-level security measures
- Superuser/admin paths that bypass GraphQL are not protected
- Always use RLS and proper database permissions as the primary security layer
222 changes: 222 additions & 0 deletions graphile/graphile-sql-expression-validator/__tests__/validator.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,222 @@
import {
parseAndValidateSqlExpression,
validateAst,
} from '../src';

describe('SQL Expression Validator', () => {
describe('parseAndValidateSqlExpression', () => {
describe('valid expressions', () => {
it('accepts simple constants', async () => {
const result = await parseAndValidateSqlExpression("'hello'");
expect(result.valid).toBe(true);
expect(result.canonicalText).toBeDefined();
expect(result.ast).toBeDefined();
});

it('accepts numeric constants', async () => {
const result = await parseAndValidateSqlExpression('42');
expect(result.valid).toBe(true);
expect(result.canonicalText).toBe('42');
});

it('accepts boolean constants', async () => {
const result = await parseAndValidateSqlExpression('true');
expect(result.valid).toBe(true);
});

it('accepts NULL', async () => {
const result = await parseAndValidateSqlExpression('NULL');
expect(result.valid).toBe(true);
});

it('accepts type casts', async () => {
const result = await parseAndValidateSqlExpression("'2024-01-01'::date");
expect(result.valid).toBe(true);
});

it('accepts allowed functions - uuid_generate_v4', async () => {
const result = await parseAndValidateSqlExpression('uuid_generate_v4()');
expect(result.valid).toBe(true);
expect(result.canonicalText).toContain('uuid_generate_v4');
});

it('accepts allowed functions - gen_random_uuid', async () => {
const result = await parseAndValidateSqlExpression('gen_random_uuid()');
expect(result.valid).toBe(true);
});

it('accepts allowed functions - now', async () => {
const result = await parseAndValidateSqlExpression('now()');
expect(result.valid).toBe(true);
});

it('accepts allowed functions - current_timestamp', async () => {
const result = await parseAndValidateSqlExpression('current_timestamp');
expect(result.valid).toBe(true);
});

it('accepts COALESCE expressions', async () => {
const result = await parseAndValidateSqlExpression("COALESCE(NULL, 'default')");
expect(result.valid).toBe(true);
});

it('accepts CASE expressions', async () => {
const result = await parseAndValidateSqlExpression("CASE WHEN true THEN 'yes' ELSE 'no' END");
expect(result.valid).toBe(true);
});

it('accepts arithmetic expressions', async () => {
const result = await parseAndValidateSqlExpression('1 + 2 * 3');
expect(result.valid).toBe(true);
});

it('accepts boolean expressions', async () => {
const result = await parseAndValidateSqlExpression('true AND false OR true');
expect(result.valid).toBe(true);
});

it('accepts NULL test', async () => {
const result = await parseAndValidateSqlExpression("'value' IS NOT NULL");
expect(result.valid).toBe(true);
});
});

describe('invalid expressions', () => {
it('rejects SELECT statements (as subqueries)', async () => {
const result = await parseAndValidateSqlExpression('SELECT * FROM users');
expect(result.valid).toBe(false);
// When wrapped in SELECT (...), this becomes a SubLink (subquery)
expect(result.error).toContain('SubLink');
});

it('rejects INSERT statements', async () => {
const result = await parseAndValidateSqlExpression("INSERT INTO users VALUES (1, 'test')");
expect(result.valid).toBe(false);
});

it('rejects UPDATE statements', async () => {
const result = await parseAndValidateSqlExpression("UPDATE users SET name = 'test'");
expect(result.valid).toBe(false);
});

it('rejects DELETE statements', async () => {
const result = await parseAndValidateSqlExpression('DELETE FROM users');
expect(result.valid).toBe(false);
});

it('rejects subqueries', async () => {
const result = await parseAndValidateSqlExpression('(SELECT 1)');
expect(result.valid).toBe(false);
});

it('rejects column references', async () => {
const result = await parseAndValidateSqlExpression('users.id');
expect(result.valid).toBe(false);
expect(result.error).toContain('ColumnRef');
});

it('rejects semicolons (stacked statements)', async () => {
const result = await parseAndValidateSqlExpression("'test'; DROP TABLE users");
expect(result.valid).toBe(false);
expect(result.error).toContain('semicolon');
});

it('rejects unknown functions', async () => {
const result = await parseAndValidateSqlExpression('unknown_function()');
expect(result.valid).toBe(false);
expect(result.error).toContain('not in the allowed functions list');
});

it('rejects empty expressions', async () => {
const result = await parseAndValidateSqlExpression('');
expect(result.valid).toBe(false);
});

it('rejects expressions exceeding max length', async () => {
const longExpression = 'a'.repeat(20000);
const result = await parseAndValidateSqlExpression(longExpression);
expect(result.valid).toBe(false);
expect(result.error).toContain('exceeds maximum length');
});
});

describe('schema-qualified functions', () => {
it('rejects schema-qualified functions by default', async () => {
const result = await parseAndValidateSqlExpression('my_schema.my_function()');
expect(result.valid).toBe(false);
expect(result.error).toContain('not in the allowed schemas list');
});

it('accepts schema-qualified functions when schema is allowed', async () => {
const result = await parseAndValidateSqlExpression('my_schema.my_function()', {
allowedSchemas: ['my_schema'],
});
expect(result.valid).toBe(true);
});

it('rejects schema-qualified functions when schema is not in allowlist', async () => {
const result = await parseAndValidateSqlExpression('other_schema.my_function()', {
allowedSchemas: ['my_schema'],
});
expect(result.valid).toBe(false);
expect(result.error).toContain('other_schema');
});
});

describe('custom allowed functions', () => {
it('accepts custom allowed functions', async () => {
const result = await parseAndValidateSqlExpression('my_custom_function()', {
allowedFunctions: ['my_custom_function'],
});
expect(result.valid).toBe(true);
});

it('function matching is case-insensitive', async () => {
const result = await parseAndValidateSqlExpression('UUID_GENERATE_V4()');
expect(result.valid).toBe(true);
});
});

describe('canonicalization', () => {
it('normalizes whitespace', async () => {
const result = await parseAndValidateSqlExpression(' uuid_generate_v4( ) ');
expect(result.valid).toBe(true);
expect(result.canonicalText).not.toContain(' ');
});

it('produces consistent output for equivalent expressions', async () => {
const result1 = await parseAndValidateSqlExpression('1+2');
const result2 = await parseAndValidateSqlExpression('1 + 2');
expect(result1.canonicalText).toBe(result2.canonicalText);
});
});
});

describe('validateAst', () => {
it('validates a valid AST', async () => {
const parseResult = await parseAndValidateSqlExpression('uuid_generate_v4()');
expect(parseResult.valid).toBe(true);

const validateResult = await validateAst(parseResult.ast);
expect(validateResult.valid).toBe(true);
expect(validateResult.canonicalText).toBeDefined();
});

it('rejects invalid AST structure', async () => {
const result = await validateAst(null);
expect(result.valid).toBe(false);
expect(result.error).toContain('non-null object');
});

it('rejects AST with forbidden node types', async () => {
const maliciousAst: Record<string, unknown> = {
SelectStmt: {
targetList: [] as unknown[],
},
};
const result = await validateAst(maliciousAst);
expect(result.valid).toBe(false);
expect(result.error).toContain('SelectStmt');
});
});
});
6 changes: 6 additions & 0 deletions graphile/graphile-sql-expression-validator/jest.config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
module.exports = {
preset: 'ts-jest',
testEnvironment: 'node',
testMatch: ['**/__tests__/**/*.test.ts'],
moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json', 'node'],
};
Loading