From 4d4e69faddf38afd5c7e2a32be7f4ff2fb32ffb6 Mon Sep 17 00:00:00 2001 From: Dan Lynch Date: Wed, 13 May 2026 23:20:47 +0000 Subject: [PATCH] feat: add FieldType/FieldDefault AST validators with schema-aware allowlists MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add FieldType and FieldDefault TypeScript types for structured JSONB models - Add converters: FieldType → TypeName AST, FieldDefault → expression AST - Add fieldTypeToSql/fieldDefaultToSql for canonical SQL text output - Add validateFieldType with identifier checks, forbidden types, schema allowlists - Add validateFieldDefault with recursive validation, schema-function maps, depth limits, and full expression AST validation via existing validator - Add allowedSchemaFunctions option for per-schema function allowlists - Fix: add missing 'isnull' to ALLOWED_NODE_TYPES (NULL literal support) - 155 new tests covering all type categories, real-world defaults, SQL injection prevention, and invalid structure rejection --- .../__tests__/field-types.test.ts | 1205 +++++++++++++++++ .../src/field-types.ts | 827 +++++++++++ .../src/index.ts | 22 + .../src/validator.ts | 3 +- 4 files changed, 2056 insertions(+), 1 deletion(-) create mode 100644 graphile/graphile-sql-expression-validator/__tests__/field-types.test.ts create mode 100644 graphile/graphile-sql-expression-validator/src/field-types.ts diff --git a/graphile/graphile-sql-expression-validator/__tests__/field-types.test.ts b/graphile/graphile-sql-expression-validator/__tests__/field-types.test.ts new file mode 100644 index 0000000000..b50741c667 --- /dev/null +++ b/graphile/graphile-sql-expression-validator/__tests__/field-types.test.ts @@ -0,0 +1,1205 @@ +import { + validateFieldType, + validateFieldDefault, + fieldTypeToAst, + fieldTypeToSql, + fieldDefaultToAst, + fieldDefaultToSql, + FORBIDDEN_TYPES +} from '../src/field-types'; +import type { + FieldType, + FieldDefault, + FieldTypeValidationOptions, + FieldDefaultValidationOptions +} from '../src/field-types'; +import { DEFAULT_ALLOWED_FUNCTIONS } from '../src/validator'; + +// ═══════════════════════════════════════════════════════════════════ +// FieldType Validation +// ═══════════════════════════════════════════════════════════════════ + +describe('validateFieldType', () => { + // ─── Valid types ──────────────────────────────────────────────── + + describe('valid types', () => { + it('should accept simple type: text', () => { + const result = validateFieldType({ name: 'text' }); + expect(result.valid).toBe(true); + expect(result.canonicalSql).toBe('text'); + }); + + it('should accept simple type: uuid', () => { + const result = validateFieldType({ name: 'uuid' }); + expect(result.valid).toBe(true); + expect(result.canonicalSql).toBe('uuid'); + }); + + it('should accept simple type: jsonb', () => { + const result = validateFieldType({ name: 'jsonb' }); + expect(result.valid).toBe(true); + expect(result.canonicalSql).toBe('jsonb'); + }); + + it('should accept simple type: boolean', () => { + const result = validateFieldType({ name: 'boolean' }); + expect(result.valid).toBe(true); + expect(result.canonicalSql).toBe('boolean'); + }); + + it('should accept simple type: integer', () => { + const result = validateFieldType({ name: 'integer' }); + expect(result.valid).toBe(true); + expect(result.canonicalSql).toBe('integer'); + }); + + it('should accept simple type: timestamptz', () => { + const result = validateFieldType({ name: 'timestamptz' }); + expect(result.valid).toBe(true); + expect(result.canonicalSql).toBe('timestamptz'); + }); + + it('should accept simple type: citext', () => { + const result = validateFieldType({ name: 'citext' }); + expect(result.valid).toBe(true); + expect(result.canonicalSql).toBe('citext'); + }); + + it('should accept simple type: tsvector', () => { + const result = validateFieldType({ name: 'tsvector' }); + expect(result.valid).toBe(true); + expect(result.canonicalSql).toBe('tsvector'); + }); + }); + + // ─── Types with arguments ────────────────────────────────────── + + describe('types with arguments', () => { + it('should accept geometry(Point, 4326)', () => { + const result = validateFieldType({ + name: 'geometry', + args: ['Point', 4326] + }); + expect(result.valid).toBe(true); + expect(result.canonicalSql).toBe('geometry(Point, 4326)'); + }); + + it('should accept geometry(PointZM, 4326)', () => { + const result = validateFieldType({ + name: 'geometry', + args: ['PointZM', 4326] + }); + expect(result.valid).toBe(true); + expect(result.canonicalSql).toBe('geometry(PointZM, 4326)'); + }); + + it('should accept geography(Point, 4326)', () => { + const result = validateFieldType({ + name: 'geography', + args: ['Point', 4326] + }); + expect(result.valid).toBe(true); + expect(result.canonicalSql).toBe('geography(Point, 4326)'); + }); + + it('should accept numeric(10, 2)', () => { + const result = validateFieldType({ + name: 'numeric', + args: [10, 2] + }); + expect(result.valid).toBe(true); + expect(result.canonicalSql).toBe('numeric(10, 2)'); + }); + + it('should accept varchar(255)', () => { + const result = validateFieldType({ + name: 'varchar', + args: [255] + }); + expect(result.valid).toBe(true); + expect(result.canonicalSql).toBe('varchar(255)'); + }); + + it('should accept vector(1536)', () => { + const result = validateFieldType({ + name: 'vector', + args: [1536] + }); + expect(result.valid).toBe(true); + expect(result.canonicalSql).toBe('vector(1536)'); + }); + + it('should accept bit(32)', () => { + const result = validateFieldType({ + name: 'bit', + args: [32] + }); + expect(result.valid).toBe(true); + expect(result.canonicalSql).toBe('bit(32)'); + }); + + it('should accept interval(6) — precision arg', () => { + const result = validateFieldType({ + name: 'interval', + args: [6] + }); + expect(result.valid).toBe(true); + expect(result.canonicalSql).toBe('interval(6)'); + }); + + it('should accept geometry(LineString, 4326)', () => { + const result = validateFieldType({ + name: 'geometry', + args: ['LineString', 4326] + }); + expect(result.valid).toBe(true); + expect(result.canonicalSql).toBe('geometry(LineString, 4326)'); + }); + + it('should accept geometry(Polygon, 4326)', () => { + const result = validateFieldType({ + name: 'geometry', + args: ['Polygon', 4326] + }); + expect(result.valid).toBe(true); + expect(result.canonicalSql).toBe('geometry(Polygon, 4326)'); + }); + + it('should accept geometry(MultiPoint, 4326)', () => { + const result = validateFieldType({ + name: 'geometry', + args: ['MultiPoint', 4326] + }); + expect(result.valid).toBe(true); + expect(result.canonicalSql).toBe('geometry(MultiPoint, 4326)'); + }); + }); + + // ─── Array types ─────────────────────────────────────────────── + + describe('array types', () => { + it('should accept text[]', () => { + const result = validateFieldType({ + name: 'text', + array_dimensions: 1 + }); + expect(result.valid).toBe(true); + expect(result.canonicalSql).toBe('text[]'); + }); + + it('should accept integer[]', () => { + const result = validateFieldType({ + name: 'integer', + array_dimensions: 1 + }); + expect(result.valid).toBe(true); + expect(result.canonicalSql).toBe('integer[]'); + }); + + it('should accept text[][]', () => { + const result = validateFieldType({ + name: 'text', + array_dimensions: 2 + }); + expect(result.valid).toBe(true); + expect(result.canonicalSql).toBe('text[][]'); + }); + + it('should accept numeric(10,2)[]', () => { + const result = validateFieldType({ + name: 'numeric', + args: [10, 2], + array_dimensions: 1 + }); + expect(result.valid).toBe(true); + expect(result.canonicalSql).toBe('numeric(10, 2)[]'); + }); + + it('should accept array_dimensions: 0 (scalar)', () => { + const result = validateFieldType({ + name: 'text', + array_dimensions: 0 + }); + expect(result.valid).toBe(true); + expect(result.canonicalSql).toBe('text'); + }); + }); + + // ─── Schema-qualified types ──────────────────────────────────── + + describe('schema-qualified types', () => { + it('should accept schema-qualified type when schema is allowed', () => { + const result = validateFieldType( + { name: 'my_type', schema: 'my_schema' }, + { allowedTypeSchemas: ['my_schema'] } + ); + expect(result.valid).toBe(true); + expect(result.canonicalSql).toBe('my_schema.my_type'); + }); + + it('should reject schema-qualified type when schema is not allowed', () => { + const result = validateFieldType( + { name: 'my_type', schema: 'evil_schema' }, + { allowedTypeSchemas: ['my_schema'] } + ); + expect(result.valid).toBe(false); + expect(result.error).toContain('not in the allowed schemas list'); + }); + + it('should accept schema-qualified type when no schema allowlist configured', () => { + const result = validateFieldType( + { name: 'my_type', schema: 'any_schema' } + ); + expect(result.valid).toBe(true); + expect(result.canonicalSql).toBe('any_schema.my_type'); + }); + + it('should match schema allowlist case-insensitively', () => { + const result = validateFieldType( + { name: 'my_type', schema: 'My_Schema' }, + { allowedTypeSchemas: ['my_schema'] } + ); + expect(result.valid).toBe(true); + }); + }); + + // ─── Interval range types ───────────────────────────────────── + + describe('interval range types', () => { + it('should accept interval day to second', () => { + const result = validateFieldType({ + name: 'interval', + range: ['day', 'second'] + }); + expect(result.valid).toBe(true); + expect(result.canonicalSql).toBe('interval day to second'); + }); + + it('should accept interval year to month', () => { + const result = validateFieldType({ + name: 'interval', + range: ['year', 'month'] + }); + expect(result.valid).toBe(true); + expect(result.canonicalSql).toBe('interval year to month'); + }); + + it('should accept interval hour', () => { + const result = validateFieldType({ + name: 'interval', + range: ['hour'] + }); + expect(result.valid).toBe(true); + expect(result.canonicalSql).toBe('interval hour'); + }); + + it('should reject range on non-interval type', () => { + const result = validateFieldType({ + name: 'text', + range: ['day', 'second'] + }); + expect(result.valid).toBe(false); + expect(result.error).toContain('only valid for interval types'); + }); + + it('should reject invalid interval field', () => { + const result = validateFieldType({ + name: 'interval', + range: ['week'] + }); + expect(result.valid).toBe(false); + expect(result.error).toContain('valid interval field'); + }); + + it('should reject range with more than 2 elements', () => { + const result = validateFieldType({ + name: 'interval', + range: ['day', 'hour', 'second'] + }); + expect(result.valid).toBe(false); + expect(result.error).toContain('1 or 2 elements'); + }); + + it('should reject empty range', () => { + const result = validateFieldType({ + name: 'interval', + range: [] + }); + expect(result.valid).toBe(false); + expect(result.error).toContain('1 or 2 elements'); + }); + }); + + // ─── Forbidden types ────────────────────────────────────────── + + describe('forbidden types', () => { + it.each([ + 'regclass', 'regtype', 'regproc', 'regprocedure', + 'regoper', 'regoperator', 'regnamespace', 'regrole', + 'regconfig', 'regdictionary' + ])('should reject forbidden type: %s', (typeName) => { + const result = validateFieldType({ name: typeName }); + expect(result.valid).toBe(false); + expect(result.error).toContain('Forbidden type'); + expect(result.error).toContain('system catalog'); + }); + + it('should reject additional forbidden types', () => { + const result = validateFieldType( + { name: 'dangerous_type' }, + { additionalForbiddenTypes: ['dangerous_type'] } + ); + expect(result.valid).toBe(false); + expect(result.error).toContain('Forbidden type'); + }); + }); + + // ─── Invalid inputs ─────────────────────────────────────────── + + describe('invalid inputs', () => { + it('should reject null', () => { + const result = validateFieldType(null); + expect(result.valid).toBe(false); + expect(result.error).toContain('non-null object'); + }); + + it('should reject undefined', () => { + const result = validateFieldType(undefined); + expect(result.valid).toBe(false); + }); + + it('should reject string', () => { + const result = validateFieldType('text'); + expect(result.valid).toBe(false); + expect(result.error).toContain('non-null object'); + }); + + it('should reject array', () => { + const result = validateFieldType(['text']); + expect(result.valid).toBe(false); + expect(result.error).toContain('non-null object'); + }); + + it('should reject missing name', () => { + const result = validateFieldType({}); + expect(result.valid).toBe(false); + expect(result.error).toContain('name is required'); + }); + + it('should reject non-string name', () => { + const result = validateFieldType({ name: 42 }); + expect(result.valid).toBe(false); + expect(result.error).toContain('name is required and must be a string'); + }); + + it('should reject name with special characters', () => { + const result = validateFieldType({ name: 'text; DROP TABLE' }); + expect(result.valid).toBe(false); + expect(result.error).toContain('valid SQL identifier'); + }); + + it('should reject name starting with number', () => { + const result = validateFieldType({ name: '3text' }); + expect(result.valid).toBe(false); + expect(result.error).toContain('valid SQL identifier'); + }); + + it('should reject name with spaces', () => { + const result = validateFieldType({ name: 'my type' }); + expect(result.valid).toBe(false); + expect(result.error).toContain('valid SQL identifier'); + }); + + it('should reject unknown keys', () => { + const result = validateFieldType({ name: 'text', evil: true }); + expect(result.valid).toBe(false); + expect(result.error).toContain('Unknown FieldType key'); + }); + + it('should reject schema with special characters', () => { + const result = validateFieldType({ name: 'text', schema: 'evil; DROP' }); + expect(result.valid).toBe(false); + expect(result.error).toContain('valid SQL identifier'); + }); + + it('should reject non-array args', () => { + const result = validateFieldType({ name: 'geometry', args: 'Point' as unknown as (string | number | boolean)[] }); + expect(result.valid).toBe(false); + expect(result.error).toContain('args must be an array'); + }); + + it('should reject object in args', () => { + const result = validateFieldType({ name: 'geometry', args: [{ evil: true }] as unknown as (string | number | boolean)[] }); + expect(result.valid).toBe(false); + expect(result.error).toContain('args[0] must be a string, number, or boolean'); + }); + + it('should reject non-identifier string in args', () => { + const result = validateFieldType({ name: 'geometry', args: ['Point; DROP TABLE'] }); + expect(result.valid).toBe(false); + expect(result.error).toContain('args[0] string must be a valid identifier'); + }); + + it('should reject negative array_dimensions', () => { + const result = validateFieldType({ name: 'text', array_dimensions: -1 }); + expect(result.valid).toBe(false); + expect(result.error).toContain('between 0 and 6'); + }); + + it('should reject array_dimensions > 6', () => { + const result = validateFieldType({ name: 'text', array_dimensions: 7 }); + expect(result.valid).toBe(false); + expect(result.error).toContain('between 0 and 6'); + }); + + it('should reject non-integer array_dimensions', () => { + const result = validateFieldType({ name: 'text', array_dimensions: 1.5 }); + expect(result.valid).toBe(false); + expect(result.error).toContain('must be an integer'); + }); + }); +}); + +// ═══════════════════════════════════════════════════════════════════ +// FieldType to SQL +// ═══════════════════════════════════════════════════════════════════ + +describe('fieldTypeToSql', () => { + it('should convert simple type', () => { + expect(fieldTypeToSql({ name: 'text' })).toBe('text'); + }); + + it('should convert type with args', () => { + expect(fieldTypeToSql({ name: 'geometry', args: ['Point', 4326] })).toBe('geometry(Point, 4326)'); + }); + + it('should convert type with schema', () => { + expect(fieldTypeToSql({ name: 'my_type', schema: 'my_schema' })).toBe('my_schema.my_type'); + }); + + it('should convert array type', () => { + expect(fieldTypeToSql({ name: 'text', array_dimensions: 1 })).toBe('text[]'); + }); + + it('should convert 2D array type', () => { + expect(fieldTypeToSql({ name: 'text', array_dimensions: 2 })).toBe('text[][]'); + }); + + it('should convert type with args and array', () => { + expect(fieldTypeToSql({ name: 'numeric', args: [10, 2], array_dimensions: 1 })) + .toBe('numeric(10, 2)[]'); + }); + + it('should convert interval with range', () => { + expect(fieldTypeToSql({ name: 'interval', range: ['day', 'second'] })) + .toBe('interval day to second'); + }); + + it('should convert interval with single range field', () => { + expect(fieldTypeToSql({ name: 'interval', range: ['hour'] })) + .toBe('interval hour'); + }); + + it('should convert schema + args + array', () => { + expect(fieldTypeToSql({ name: 'my_type', schema: 'my_schema', args: [10], array_dimensions: 1 })) + .toBe('my_schema.my_type(10)[]'); + }); +}); + +// ═══════════════════════════════════════════════════════════════════ +// FieldType to AST +// ═══════════════════════════════════════════════════════════════════ + +describe('fieldTypeToAst', () => { + it('should produce TypeName AST for simple type', () => { + const ast = fieldTypeToAst({ name: 'text' }); + expect(ast.names).toEqual([{ String: { sval: 'text' } }]); + expect(ast.typemod).toBe(-1); + }); + + it('should produce TypeName AST for schema-qualified type', () => { + const ast = fieldTypeToAst({ name: 'my_type', schema: 'my_schema' }); + expect(ast.names).toEqual([ + { String: { sval: 'my_schema' } }, + { String: { sval: 'my_type' } } + ]); + }); + + it('should produce TypeName AST with typmods for args', () => { + const ast = fieldTypeToAst({ name: 'numeric', args: [10, 2] }); + expect(ast.typmods).toEqual([ + { A_Const: { ival: { ival: 10 } } }, + { A_Const: { ival: { ival: 2 } } } + ]); + }); + + it('should produce TypeName AST with arrayBounds', () => { + const ast = fieldTypeToAst({ name: 'text', array_dimensions: 2 }); + expect(ast.arrayBounds).toEqual([ + { Integer: { ival: -1 } }, + { Integer: { ival: -1 } } + ]); + }); + + it('should produce ColumnRef typmods for string args (geometry)', () => { + const ast = fieldTypeToAst({ name: 'geometry', args: ['Point', 4326] }); + expect(ast.typmods).toEqual([ + { ColumnRef: { fields: [{ String: { sval: 'point' } }] } }, + { A_Const: { ival: { ival: 4326 } } } + ]); + }); +}); + +// ═══════════════════════════════════════════════════════════════════ +// FieldDefault Validation +// ═══════════════════════════════════════════════════════════════════ + +describe('validateFieldDefault', () => { + // ─── Simple function calls ──────────────────────────────────── + + describe('simple function calls', () => { + it('should accept now()', () => { + const result = validateFieldDefault({ function: 'now' }); + expect(result.valid).toBe(true); + expect(result.canonicalSql).toBe('now()'); + }); + + it('should accept gen_random_uuid()', () => { + const result = validateFieldDefault({ function: 'gen_random_uuid' }); + expect(result.valid).toBe(true); + expect(result.canonicalSql).toBe('gen_random_uuid()'); + }); + + it('should accept uuid_generate_v4()', () => { + const result = validateFieldDefault({ function: 'uuid_generate_v4' }); + expect(result.valid).toBe(true); + expect(result.canonicalSql).toBe('uuid_generate_v4()'); + }); + + it('should accept uuidv7()', () => { + const result = validateFieldDefault({ function: 'uuidv7' }); + expect(result.valid).toBe(true); + expect(result.canonicalSql).toBe('uuidv7()'); + }); + + it('should accept clock_timestamp()', () => { + const result = validateFieldDefault({ function: 'clock_timestamp' }); + expect(result.valid).toBe(true); + expect(result.canonicalSql).toBe('clock_timestamp()'); + }); + }); + + // ─── Schema-qualified function calls ────────────────────────── + + describe('schema-qualified function calls', () => { + it('should accept jwt_public.current_user_id() with schema function map', () => { + const result = validateFieldDefault( + { function: 'current_user_id', schema: 'jwt_public' }, + { + allowedSchemaFunctions: { + jwt_public: ['current_user_id', 'current_origin'] + } + } + ); + expect(result.valid).toBe(true); + expect(result.canonicalSql).toBe('jwt_public.current_user_id()'); + }); + + it('should accept jwt_public.current_origin() with schema function map', () => { + const result = validateFieldDefault( + { function: 'current_origin', schema: 'jwt_public' }, + { + allowedSchemaFunctions: { + jwt_public: ['current_user_id', 'current_origin'] + } + } + ); + expect(result.valid).toBe(true); + expect(result.canonicalSql).toBe('jwt_public.current_origin()'); + }); + + it('should reject schema-qualified function when schema not allowed', () => { + const result = validateFieldDefault( + { function: 'evil_func', schema: 'evil_schema' } + ); + expect(result.valid).toBe(false); + expect(result.error).toContain('not in the allowed'); + }); + + it('should reject function when schema is allowed but function is not', () => { + const result = validateFieldDefault( + { function: 'evil_func', schema: 'jwt_public' }, + { + allowedSchemaFunctions: { + jwt_public: ['current_user_id'] + } + } + ); + expect(result.valid).toBe(false); + expect(result.error).toContain('not in the allowed functions list'); + }); + }); + + // ─── Literal values ─────────────────────────────────────────── + + describe('literal values', () => { + it('should accept boolean false', () => { + const result = validateFieldDefault({ value: false }); + expect(result.valid).toBe(true); + }); + + it('should accept boolean true', () => { + const result = validateFieldDefault({ value: true }); + expect(result.valid).toBe(true); + }); + + it('should accept number 0', () => { + const result = validateFieldDefault({ value: 0 }); + expect(result.valid).toBe(true); + expect(result.canonicalSql).toBe('0'); + }); + + it('should accept number 250', () => { + const result = validateFieldDefault({ value: 250 }); + expect(result.valid).toBe(true); + expect(result.canonicalSql).toBe('250'); + }); + + it('should accept string literal', () => { + const result = validateFieldDefault({ value: 'pooled' }); + expect(result.valid).toBe(true); + expect(result.canonicalSql).toContain('pooled'); + }); + + it('should accept null', () => { + const result = validateFieldDefault({ value: null }); + expect(result.valid).toBe(true); + }); + }); + + // ─── Cast expressions ──────────────────────────────────────── + + describe('cast expressions', () => { + it('should accept {}::jsonb', () => { + const result = validateFieldDefault({ + value: '{}', + cast: { name: 'jsonb' } + }); + expect(result.valid).toBe(true); + expect(result.canonicalSql).toContain('jsonb'); + }); + + it('should accept {}::text[]', () => { + const result = validateFieldDefault({ + value: '{}', + cast: { name: 'text', array_dimensions: 1 } + }); + expect(result.valid).toBe(true); + }); + + it('should accept 15 minutes::interval', () => { + const result = validateFieldDefault({ + value: '15 minutes', + cast: { name: 'interval' } + }); + expect(result.valid).toBe(true); + expect(result.canonicalSql).toContain('interval'); + }); + + it('should reject cast to forbidden type', () => { + const result = validateFieldDefault({ + value: '42', + cast: { name: 'regclass' } + }); + expect(result.valid).toBe(false); + expect(result.error).toContain('Forbidden type'); + }); + }); + + // ─── Nested function calls ──────────────────────────────────── + + describe('nested function calls', () => { + it('should accept encode(gen_random_bytes(16), hex)', () => { + const result = validateFieldDefault( + { + function: 'encode', + args: [ + { function: 'gen_random_bytes', args: [16] }, + 'hex' + ] + }, + { + allowedFunctions: [ + ...DEFAULT_ALLOWED_FUNCTIONS, + 'encode', 'gen_random_bytes' + ] + } + ); + expect(result.valid).toBe(true); + expect(result.canonicalSql).toContain('encode'); + expect(result.canonicalSql).toContain('gen_random_bytes'); + }); + + it('should accept lpad(\'\', 32, \'0\')::bit(32)', () => { + const result = validateFieldDefault( + { + function: 'lpad', + args: ['', 32, '0'], + cast: { name: 'bit', args: [32] } + }, + { + allowedFunctions: [ + ...DEFAULT_ALLOWED_FUNCTIONS, + 'lpad' + ] + } + ); + expect(result.valid).toBe(true); + expect(result.canonicalSql).toContain('lpad'); + }); + + it('should reject disallowed nested function', () => { + const result = validateFieldDefault({ + function: 'encode', + args: [ + { function: 'pg_read_file', args: ['/etc/passwd'] }, + 'hex' + ] + }); + expect(result.valid).toBe(false); + expect(result.error).toContain('not in the allowed functions list'); + }); + + it('should reject excessive nesting depth', () => { + // Build 5 levels of nesting + let fd: FieldDefault = { function: 'now' }; + for (let i = 0; i < 5; i++) { + fd = { function: 'wrapper', args: [fd] }; + } + const result = validateFieldDefault(fd, { maxNestingDepth: 3 }); + expect(result.valid).toBe(false); + expect(result.error).toContain('nesting exceeds maximum depth'); + }); + }); + + // ─── Real-world defaults from codebase ──────────────────────── + + describe('real-world defaults from constructive-db generators', () => { + const realWorldOptions: FieldDefaultValidationOptions = { + allowedFunctions: [ + ...DEFAULT_ALLOWED_FUNCTIONS, + 'encode', 'gen_random_bytes', 'lpad', 'coalesce' + ], + allowedSchemaFunctions: { + jwt_public: [ + 'current_user_id', 'current_origin', + 'current_ip_address', 'current_user_agent' + ] + } + }; + + it('default_value: now()', () => { + const result = validateFieldDefault({ function: 'now' }, realWorldOptions); + expect(result.valid).toBe(true); + }); + + it('default_value: gen_random_uuid()', () => { + const result = validateFieldDefault({ function: 'gen_random_uuid' }, realWorldOptions); + expect(result.valid).toBe(true); + }); + + it('default_value: uuidv7()', () => { + const result = validateFieldDefault({ function: 'uuidv7' }, realWorldOptions); + expect(result.valid).toBe(true); + }); + + it('default_value: clock_timestamp()', () => { + const result = validateFieldDefault({ function: 'clock_timestamp' }, realWorldOptions); + expect(result.valid).toBe(true); + }); + + it('default_value: jwt_public.current_user_id()', () => { + const result = validateFieldDefault( + { function: 'current_user_id', schema: 'jwt_public' }, + realWorldOptions + ); + expect(result.valid).toBe(true); + }); + + it('default_value: jwt_public.current_origin()', () => { + const result = validateFieldDefault( + { function: 'current_origin', schema: 'jwt_public' }, + realWorldOptions + ); + expect(result.valid).toBe(true); + }); + + it('default_value: jwt_public.current_ip_address()', () => { + const result = validateFieldDefault( + { function: 'current_ip_address', schema: 'jwt_public' }, + realWorldOptions + ); + expect(result.valid).toBe(true); + }); + + it('default_value: jwt_public.current_user_agent()', () => { + const result = validateFieldDefault( + { function: 'current_user_agent', schema: 'jwt_public' }, + realWorldOptions + ); + expect(result.valid).toBe(true); + }); + + it('default_value: false (boolean)', () => { + const result = validateFieldDefault({ value: false }, realWorldOptions); + expect(result.valid).toBe(true); + }); + + it('default_value: true (boolean)', () => { + const result = validateFieldDefault({ value: true }, realWorldOptions); + expect(result.valid).toBe(true); + }); + + it('default_value: 0 (number)', () => { + const result = validateFieldDefault({ value: 0 }, realWorldOptions); + expect(result.valid).toBe(true); + }); + + it("default_value: 'pooled' (string literal)", () => { + const result = validateFieldDefault({ value: 'pooled' }, realWorldOptions); + expect(result.valid).toBe(true); + }); + + it("default_value: '{}'::jsonb", () => { + const result = validateFieldDefault( + { value: '{}', cast: { name: 'jsonb' } }, + realWorldOptions + ); + expect(result.valid).toBe(true); + }); + + it("default_value: '{}'::text[]", () => { + const result = validateFieldDefault( + { value: '{}', cast: { name: 'text', array_dimensions: 1 } }, + realWorldOptions + ); + expect(result.valid).toBe(true); + }); + + it("default_value: '15 minutes'::interval", () => { + const result = validateFieldDefault( + { value: '15 minutes', cast: { name: 'interval' } }, + realWorldOptions + ); + expect(result.valid).toBe(true); + }); + + it("default_value: '1 minute'::interval", () => { + const result = validateFieldDefault( + { value: '1 minute', cast: { name: 'interval' } }, + realWorldOptions + ); + expect(result.valid).toBe(true); + }); + + it('default_value: 250 (number)', () => { + const result = validateFieldDefault({ value: 250 }, realWorldOptions); + expect(result.valid).toBe(true); + }); + + it("default_value: lpad('', 32, '0')::bit(32)", () => { + const result = validateFieldDefault( + { + function: 'lpad', + args: ['', 32, '0'], + cast: { name: 'bit', args: [32] } + }, + realWorldOptions + ); + expect(result.valid).toBe(true); + }); + }); + + // ─── Security: SQL injection attempts ───────────────────────── + + describe('SQL injection prevention', () => { + it('should reject function name with SQL injection', () => { + const result = validateFieldDefault({ function: 'now(); DROP TABLE users' }); + expect(result.valid).toBe(false); + expect(result.error).toContain('valid SQL identifier'); + }); + + it('should reject schema with SQL injection', () => { + const result = validateFieldDefault({ + function: 'func', + schema: 'schema; DROP TABLE' + }); + expect(result.valid).toBe(false); + expect(result.error).toContain('valid SQL identifier'); + }); + + it('should reject disallowed function (pg_sleep)', () => { + const result = validateFieldDefault({ function: 'pg_sleep', args: [5] }); + expect(result.valid).toBe(false); + expect(result.error).toContain('not in the allowed functions list'); + }); + + it('should reject disallowed function (pg_read_file)', () => { + const result = validateFieldDefault({ function: 'pg_read_file', args: ['/etc/passwd'] }); + expect(result.valid).toBe(false); + expect(result.error).toContain('not in the allowed functions list'); + }); + + it('should reject cast to regclass (system catalog probing)', () => { + const result = validateFieldDefault({ + value: 'pg_class', + cast: { name: 'regclass' } + }); + expect(result.valid).toBe(false); + expect(result.error).toContain('Forbidden type'); + }); + + it('should reject cast to regtype', () => { + const result = validateFieldDefault({ + value: 'integer', + cast: { name: 'regtype' } + }); + expect(result.valid).toBe(false); + expect(result.error).toContain('Forbidden type'); + }); + + it('should reject unknown keys (potential bypass)', () => { + const result = validateFieldDefault({ + function: 'now', + evil: 'DROP TABLE users' + } as unknown as FieldDefault); + expect(result.valid).toBe(false); + expect(result.error).toContain('Unknown FieldDefault key'); + }); + }); + + // ─── Invalid structure ──────────────────────────────────────── + + describe('invalid structure', () => { + it('should reject null', () => { + const result = validateFieldDefault(null); + expect(result.valid).toBe(false); + expect(result.error).toContain('non-null object'); + }); + + it('should reject string', () => { + const result = validateFieldDefault('now()'); + expect(result.valid).toBe(false); + }); + + it('should reject empty object', () => { + const result = validateFieldDefault({}); + expect(result.valid).toBe(false); + expect(result.error).toContain('must have either "function" or "value"'); + }); + + it('should reject both function and value', () => { + const result = validateFieldDefault({ function: 'now', value: 42 }); + expect(result.valid).toBe(false); + expect(result.error).toContain('cannot have both'); + }); + + it('should reject schema without function', () => { + const result = validateFieldDefault({ value: 42, schema: 'public' }); + expect(result.valid).toBe(false); + expect(result.error).toContain('schema is only valid with "function"'); + }); + + it('should reject args without function', () => { + const result = validateFieldDefault({ value: 42, args: [1, 2] }); + expect(result.valid).toBe(false); + expect(result.error).toContain('args is only valid with "function"'); + }); + + it('should reject non-string function', () => { + const result = validateFieldDefault({ function: 42 }); + expect(result.valid).toBe(false); + expect(result.error).toContain('function must be a string'); + }); + + it('should reject non-array args', () => { + const result = validateFieldDefault({ function: 'now', args: 'bad' as unknown as unknown[] }); + expect(result.valid).toBe(false); + expect(result.error).toContain('args must be an array'); + }); + + it('should reject object value (not literal)', () => { + const result = validateFieldDefault({ value: { nested: true } as unknown as string }); + expect(result.valid).toBe(false); + expect(result.error).toContain('must be a string, number, boolean, null, or array'); + }); + }); +}); + +// ═══════════════════════════════════════════════════════════════════ +// FieldDefault to SQL (deparse round-trip) +// ═══════════════════════════════════════════════════════════════════ + +describe('fieldDefaultToSql', () => { + it('should deparse simple function call', () => { + expect(fieldDefaultToSql({ function: 'now' })).toBe('now()'); + }); + + it('should deparse schema-qualified function', () => { + const sql = fieldDefaultToSql({ function: 'current_user_id', schema: 'jwt_public' }); + expect(sql).toBe('jwt_public.current_user_id()'); + }); + + it('should deparse integer literal', () => { + expect(fieldDefaultToSql({ value: 42 })).toBe('42'); + }); + + it('should deparse string literal', () => { + const sql = fieldDefaultToSql({ value: 'hello' }); + expect(sql).toContain('hello'); + }); + + it('should deparse cast expression', () => { + const sql = fieldDefaultToSql({ value: '{}', cast: { name: 'jsonb' } }); + expect(sql).toContain('jsonb'); + expect(sql).toContain('{}'); + }); + + it('should deparse nested function call', () => { + const sql = fieldDefaultToSql({ + function: 'encode', + args: [ + { function: 'gen_random_bytes', args: [16] }, + 'hex' + ] + }); + expect(sql).toContain('encode'); + expect(sql).toContain('gen_random_bytes'); + expect(sql).toContain('16'); + expect(sql).toContain('hex'); + }); + + it('should deparse function with cast', () => { + const sql = fieldDefaultToSql({ + function: 'lpad', + args: ['', 32, '0'], + cast: { name: 'bit', args: [32] } + }); + expect(sql).toContain('lpad'); + expect(sql).toContain('bit'); + }); +}); + +// ═══════════════════════════════════════════════════════════════════ +// FieldDefault to AST +// ═══════════════════════════════════════════════════════════════════ + +describe('fieldDefaultToAst', () => { + it('should produce FuncCall AST for function', () => { + const ast = fieldDefaultToAst({ function: 'now' }); + expect(ast).toHaveProperty('FuncCall'); + const fc = (ast as { FuncCall: Record }).FuncCall; + expect(fc.funcname).toEqual([{ String: { sval: 'now' } }]); + }); + + it('should produce FuncCall AST for schema-qualified function', () => { + const ast = fieldDefaultToAst({ function: 'current_user_id', schema: 'jwt_public' }); + const fc = (ast as { FuncCall: Record }).FuncCall; + expect(fc.funcname).toEqual([ + { String: { sval: 'jwt_public' } }, + { String: { sval: 'current_user_id' } } + ]); + }); + + it('should produce A_Const AST for integer literal', () => { + const ast = fieldDefaultToAst({ value: 42 }); + expect(ast).toEqual({ A_Const: { ival: { ival: 42 } } }); + }); + + it('should produce A_Const AST for string literal', () => { + const ast = fieldDefaultToAst({ value: 'hello' }); + expect(ast).toEqual({ A_Const: { sval: { sval: 'hello' } } }); + }); + + it('should produce A_Const AST for boolean true', () => { + const ast = fieldDefaultToAst({ value: true }); + expect(ast).toEqual({ A_Const: { boolval: { boolval: true } } }); + }); + + it('should produce A_Const AST for boolean false', () => { + const ast = fieldDefaultToAst({ value: false }); + expect(ast).toEqual({ A_Const: { boolval: {} } }); + }); + + it('should produce A_Const AST for null', () => { + const ast = fieldDefaultToAst({ value: null }); + expect(ast).toEqual({ A_Const: { isnull: true } }); + }); + + it('should produce TypeCast AST for cast expression', () => { + const ast = fieldDefaultToAst({ value: '{}', cast: { name: 'jsonb' } }); + expect(ast).toHaveProperty('TypeCast'); + const tc = (ast as { TypeCast: Record }).TypeCast; + expect(tc.arg).toEqual({ A_Const: { sval: { sval: '{}' } } }); + expect(tc.typeName).toBeDefined(); + }); + + it('should produce nested FuncCall AST', () => { + const ast = fieldDefaultToAst({ + function: 'encode', + args: [ + { function: 'gen_random_bytes', args: [16] }, + 'hex' + ] + }); + const fc = (ast as { FuncCall: Record }).FuncCall; + const args = fc.args as Record[]; + expect(args).toHaveLength(2); + expect(args[0]).toHaveProperty('FuncCall'); // nested gen_random_bytes + expect(args[1]).toEqual({ A_Const: { sval: { sval: 'hex' } } }); + }); + + it('should throw on excessive nesting depth', () => { + let fd: FieldDefault = { function: 'now' }; + for (let i = 0; i < 15; i++) { + fd = { function: 'wrapper', args: [fd] }; + } + expect(() => fieldDefaultToAst(fd)).toThrow('nesting exceeds maximum depth'); + }); + + it('should produce A_ArrayExpr AST for array value', () => { + const ast = fieldDefaultToAst({ value: ['a', 'b', 'c'] }); + expect(ast).toHaveProperty('A_ArrayExpr'); + const arr = (ast as { A_ArrayExpr: { elements: unknown[] } }).A_ArrayExpr; + expect(arr.elements).toHaveLength(3); + }); +}); + +// ═══════════════════════════════════════════════════════════════════ +// FORBIDDEN_TYPES constant +// ═══════════════════════════════════════════════════════════════════ + +describe('FORBIDDEN_TYPES', () => { + it('should contain all system catalog OID-alias types', () => { + expect(FORBIDDEN_TYPES.has('regclass')).toBe(true); + expect(FORBIDDEN_TYPES.has('regtype')).toBe(true); + expect(FORBIDDEN_TYPES.has('regproc')).toBe(true); + expect(FORBIDDEN_TYPES.has('regprocedure')).toBe(true); + expect(FORBIDDEN_TYPES.has('regoper')).toBe(true); + expect(FORBIDDEN_TYPES.has('regoperator')).toBe(true); + expect(FORBIDDEN_TYPES.has('regnamespace')).toBe(true); + expect(FORBIDDEN_TYPES.has('regrole')).toBe(true); + expect(FORBIDDEN_TYPES.has('regconfig')).toBe(true); + expect(FORBIDDEN_TYPES.has('regdictionary')).toBe(true); + }); + + it('should have exactly 10 entries', () => { + expect(FORBIDDEN_TYPES.size).toBe(10); + }); + + it('should NOT contain normal types', () => { + expect(FORBIDDEN_TYPES.has('text')).toBe(false); + expect(FORBIDDEN_TYPES.has('integer')).toBe(false); + expect(FORBIDDEN_TYPES.has('jsonb')).toBe(false); + }); +}); diff --git a/graphile/graphile-sql-expression-validator/src/field-types.ts b/graphile/graphile-sql-expression-validator/src/field-types.ts new file mode 100644 index 0000000000..67d6d232b3 --- /dev/null +++ b/graphile/graphile-sql-expression-validator/src/field-types.ts @@ -0,0 +1,827 @@ +/** + * FieldType and FieldDefault: structured JSONB models for PostgreSQL + * type declarations and default value expressions. + * + * These models provide a safe, validated representation that can be: + * 1. Validated structurally (JSON shape, identifier patterns, allowlists) + * 2. Converted to pgsql-parser AST nodes + * 3. Validated at the AST level (reuses the expression validator for defaults) + * 4. Deparsed to canonical SQL text + * + * The key security insight: the structured model eliminates entire attack + * categories by construction (no subqueries, no column refs, no stacked + * statements). What remains is identifier validation + allowlist enforcement. + */ + +import { deparseSync } from 'pgsql-deparser'; +import { validateAst } from './validator'; +import type { SqlExpressionValidatorOptions, AstValidationResult } from './validator'; + +// ─── Types ──────────────────────────────────────────────────────── + +/** + * Structured representation of a PostgreSQL data type. + * + * @example Simple type + * { name: "text" } + * + * @example Type with arguments + * { name: "geometry", args: ["Point", 4326] } + * { name: "numeric", args: [10, 2] } + * { name: "vector", args: [1536] } + * + * @example Array type + * { name: "text", array_dimensions: 1 } + * + * @example Schema-qualified type + * { name: "my_type", schema: "my_schema" } + * + * @example Interval with field range + * { name: "interval", range: ["day", "second"] } + */ +export interface FieldType { + /** Type name (required). Must be a valid SQL identifier. */ + name: string; + /** Schema qualifier (optional). Must be a valid SQL identifier. */ + schema?: string; + /** Type arguments (optional). Each is a string identifier, number, or boolean. */ + args?: (string | number | boolean)[]; + /** Number of array dimensions (optional). 1 = `text[]`, 2 = `text[][]`. */ + array_dimensions?: number; + /** Interval field range (optional). 1-2 elements: ["day"] or ["day", "second"]. */ + range?: string[]; +} + +/** + * Argument to a function in a FieldDefault expression. + * Can be a literal value or a nested FieldDefault (recursive). + */ +export type FieldDefaultArg = string | number | boolean | null | FieldDefault; + +/** + * Structured representation of a PostgreSQL default value expression. + * + * @example Literal values + * { value: false } + * { value: 0 } + * { value: "pooled" } + * + * @example Cast expression + * { value: "{}", cast: { name: "jsonb" } } + * { value: "15 minutes", cast: { name: "interval" } } + * + * @example Simple function call + * { function: "now" } + * { function: "gen_random_uuid" } + * + * @example Schema-qualified function + * { function: "current_user_id", schema: "jwt_public" } + * + * @example Function with arguments (nested) + * { function: "encode", args: [{ function: "gen_random_bytes", args: [16] }, "hex"] } + * + * @example Function with cast + * { function: "lpad", args: ["", 32, "0"], cast: { name: "bit", args: [32] } } + */ +export interface FieldDefault { + /** Literal value (string, number, boolean, null, or array). */ + value?: string | number | boolean | null | unknown[]; + /** Function name. Must be a valid SQL identifier. */ + function?: string; + /** Schema qualifier for function (optional). */ + schema?: string; + /** Function arguments (optional, recursive). */ + args?: FieldDefaultArg[]; + /** Output type cast (optional). Reuses FieldType shape. */ + cast?: FieldType; +} + +// ─── Validation Options ────────────────────────────────────────── + +export interface FieldTypeValidationOptions { + /** Allowed schemas for schema-qualified types. */ + allowedTypeSchemas?: string[]; + /** Additional forbidden type names beyond the built-in set. */ + additionalForbiddenTypes?: string[]; +} + +export interface FieldDefaultValidationOptions extends SqlExpressionValidatorOptions { + /** Allowed schemas for schema-qualified types in casts. */ + allowedTypeSchemas?: string[]; + /** Additional forbidden type names beyond the built-in set. */ + additionalForbiddenTypes?: string[]; + /** Maximum nesting depth for recursive args. Defaults to 4. */ + maxNestingDepth?: number; + /** + * Map of schema → allowed functions for schema-qualified calls. + * Functions in this map do NOT need to also appear in allowedFunctions. + */ + allowedSchemaFunctions?: Record; +} + +export interface FieldTypeValidationResult { + valid: boolean; + /** Canonical SQL text (e.g., "geometry(Point,4326)", "text[]") */ + canonicalSql?: string; + /** The TypeName AST node */ + ast?: Record; + error?: string; +} + +export interface FieldDefaultValidationResult { + valid: boolean; + /** Canonical SQL text (e.g., "now()", "'{}'::jsonb") */ + canonicalSql?: string; + /** The expression AST node */ + ast?: Record; + error?: string; +} + +// ─── Constants ─────────────────────────────────────────────────── + +/** Valid SQL identifier pattern. */ +const IDENTIFIER_PATTERN = /^[a-zA-Z_][a-zA-Z0-9_]*$/; + +/** + * PostgreSQL system catalog type casts that are forbidden. + * These OID-alias types can be used to probe the system catalog. + * Shared with the expression validator (same list). + */ +export const FORBIDDEN_TYPES = new Set([ + 'regclass', + 'regtype', + 'regproc', + 'regprocedure', + 'regoper', + 'regoperator', + 'regnamespace', + 'regrole', + 'regconfig', + 'regdictionary' +]); + +/** + * Valid interval field qualifiers for the `range` property. + */ +const VALID_INTERVAL_FIELDS = new Set([ + 'year', 'month', 'day', 'hour', 'minute', 'second' +]); + +/** Allowed keys in a FieldType object. */ +const FIELD_TYPE_KEYS = new Set(['name', 'schema', 'args', 'array_dimensions', 'range']); + +/** Allowed keys in a FieldDefault object. */ +const FIELD_DEFAULT_KEYS = new Set(['value', 'function', 'schema', 'args', 'cast']); + +// ─── Internal: AST Builders ────────────────────────────────────── + +/** Build a pgsql-parser String node. */ +function astString(sval: string): Record { + return { String: { sval } }; +} + +/** Build a pgsql-parser A_Const node for an integer. */ +function astConstInt(ival: number): Record { + return { A_Const: { ival: { ival } } }; +} + +/** Build a pgsql-parser A_Const node for a string. */ +function astConstStr(sval: string): Record { + return { A_Const: { sval: { sval } } }; +} + +/** Build a pgsql-parser A_Const node for a boolean. */ +function astConstBool(boolval: boolean): Record { + if (boolval) { + return { A_Const: { boolval: { boolval: true } } }; + } + return { A_Const: { boolval: {} } }; +} + +/** Build a pgsql-parser A_Const node for NULL. */ +function astConstNull(): Record { + return { A_Const: { isnull: true } }; +} + +// ─── Converters: FieldType → AST ───────────────────────────────── + +/** + * Convert a FieldType to a pgsql-parser TypeName AST node. + * + * The TypeName node format: + * { + * names: [{ String: { sval: "schema" } }, { String: { sval: "name" } }], + * typmods: [], + * arrayBounds: [{ Integer: { ival: -1 } }, ...], + * typemod: -1 + * } + */ +export function fieldTypeToAst(ft: FieldType): Record { + const names: Record[] = []; + if (ft.schema) { + names.push(astString(ft.schema)); + } + names.push(astString(ft.name)); + + const typeName: Record = { + names, + typemod: -1 + }; + + // Type arguments → typmods + if (ft.args && ft.args.length > 0) { + const typmods: Record[] = []; + for (const arg of ft.args) { + if (typeof arg === 'number') { + typmods.push(astConstInt(arg)); + } else if (typeof arg === 'string') { + // String args in type modifiers are identifiers (e.g., "Point" in geometry(Point, 4326)) + // PostgreSQL parser represents these as ColumnRef nodes + typmods.push({ + ColumnRef: { + fields: [astString(arg.toLowerCase())] + } + }); + } else if (typeof arg === 'boolean') { + typmods.push(astConstBool(arg)); + } + } + typeName.typmods = typmods; + } + + // Array dimensions → arrayBounds + if (ft.array_dimensions && ft.array_dimensions > 0) { + const arrayBounds: Record[] = []; + for (let i = 0; i < ft.array_dimensions; i++) { + arrayBounds.push({ Integer: { ival: -1 } }); + } + typeName.arrayBounds = arrayBounds; + } + + // Interval range is encoded as a numeric typmod by PostgreSQL. + // We store it as readable strings; conversion to the numeric bitmask + // happens in field_type_to_text() at the SQL layer. + // For AST purposes, we skip typmods for interval range — the deparser + // handles it via the INTERVAL keyword + field qualifiers. + + return typeName; +} + +/** + * Convert a FieldType to its canonical SQL text representation. + * + * Uses the deparser for the base type, then appends array brackets. + * Handles interval range fields manually since the deparser needs + * special handling for INTERVAL ... DAY TO SECOND syntax. + */ +export function fieldTypeToSql(ft: FieldType): string { + // Build the base type string + let sql = ''; + + if (ft.schema) { + sql += ft.schema + '.'; + } + sql += ft.name; + + // Type arguments + if (ft.args && ft.args.length > 0) { + const argParts = ft.args.map(arg => { + if (typeof arg === 'string') return arg; + return String(arg); + }); + sql += '(' + argParts.join(', ') + ')'; + } + + // Interval range + if (ft.range && ft.range.length > 0 && ft.name.toLowerCase() === 'interval') { + if (ft.range.length === 1) { + sql += ' ' + ft.range[0]; + } else if (ft.range.length === 2) { + sql += ' ' + ft.range[0] + ' to ' + ft.range[1]; + } + } + + // Array dimensions + if (ft.array_dimensions && ft.array_dimensions > 0) { + for (let i = 0; i < ft.array_dimensions; i++) { + sql += '[]'; + } + } + + return sql; +} + +// ─── Converters: FieldDefault → AST ───────────────────────────── + +/** + * Convert a FieldDefault to a pgsql-parser expression AST node. + * + * Handles: + * - Literal values → A_Const + * - Function calls → FuncCall (with recursive args) + * - Type casts → TypeCast wrapping the inner expression + * - Combinations (function + cast, value + cast) + */ +export function fieldDefaultToAst(fd: FieldDefault, depth: number = 0): Record { + if (depth > 10) { + throw new Error('FieldDefault nesting exceeds maximum depth'); + } + + let expr: Record; + + if (fd.function !== undefined) { + // Function call + const funcname: Record[] = []; + if (fd.schema) { + funcname.push(astString(fd.schema)); + } + funcname.push(astString(fd.function)); + + const funcCall: Record = { + funcname, + funcformat: 'COERCE_EXPLICIT_CALL' + }; + + // Function arguments + if (fd.args && fd.args.length > 0) { + const astArgs: Record[] = []; + for (const arg of fd.args) { + astArgs.push(fieldDefaultArgToAst(arg, depth + 1)); + } + funcCall.args = astArgs; + } + + expr = { FuncCall: funcCall }; + } else if (fd.value !== undefined) { + // Literal value + expr = literalToAst(fd.value); + } else { + throw new Error('FieldDefault must have either "function" or "value"'); + } + + // Wrap in TypeCast if cast is present + if (fd.cast) { + expr = { + TypeCast: { + arg: expr, + typeName: fieldTypeToAst(fd.cast) + } + }; + } + + return expr; +} + +/** + * Convert a FieldDefaultArg to an AST node. + */ +function fieldDefaultArgToAst(arg: FieldDefaultArg, depth: number): Record { + if (arg === null) { + return astConstNull(); + } + if (typeof arg === 'string') { + return astConstStr(arg); + } + if (typeof arg === 'number') { + if (Number.isInteger(arg)) { + return astConstInt(arg); + } + // Float — represented as string in pgsql-parser + return { A_Const: { fval: { fval: String(arg) } } }; + } + if (typeof arg === 'boolean') { + return astConstBool(arg); + } + // Nested FieldDefault object + return fieldDefaultToAst(arg as FieldDefault, depth); +} + +/** + * Convert a literal value to an A_Const (or A_ArrayExpr) AST node. + */ +function literalToAst(value: string | number | boolean | null | unknown[]): Record { + if (value === null) { + return astConstNull(); + } + if (typeof value === 'string') { + return astConstStr(value); + } + if (typeof value === 'number') { + if (Number.isInteger(value)) { + return astConstInt(value); + } + return { A_Const: { fval: { fval: String(value) } } }; + } + if (typeof value === 'boolean') { + return astConstBool(value); + } + if (Array.isArray(value)) { + const elements = value.map(el => { + if (el === null) return astConstNull(); + if (typeof el === 'string') return astConstStr(el); + if (typeof el === 'number') { + if (Number.isInteger(el)) return astConstInt(el); + return { A_Const: { fval: { fval: String(el) } } }; + } + if (typeof el === 'boolean') return astConstBool(el); + throw new Error(`Unsupported array element type: ${typeof el}`); + }); + return { A_ArrayExpr: { elements } }; + } + throw new Error(`Unsupported literal type: ${typeof value}`); +} + +/** + * Convert a FieldDefault to its canonical SQL text representation. + */ +export function fieldDefaultToSql(fd: FieldDefault): string { + const ast = fieldDefaultToAst(fd); + return deparseSync([ast] as Parameters[0]); +} + +// ─── Structural Validators ─────────────────────────────────────── + +/** + * Validate a FieldType object structurally. + * + * Checks: + * - Object shape (no unknown keys, required keys present) + * - `name` is a valid SQL identifier + * - `name` is not a forbidden type (regclass, regtype, etc.) + * - `schema` (if present) is a valid SQL identifier and on the allowlist + * - `args` elements are literals (string, number, boolean) + * - `array_dimensions` is a positive integer + * - `range` elements are valid interval field qualifiers + * + * Then converts to SQL text for canonical output. + */ +export function validateFieldType( + ft: unknown, + options: FieldTypeValidationOptions = {} +): FieldTypeValidationResult { + const { + allowedTypeSchemas = [], + additionalForbiddenTypes = [] + } = options; + + // Must be a non-null object + if (!ft || typeof ft !== 'object' || Array.isArray(ft)) { + return { valid: false, error: 'FieldType must be a non-null object' }; + } + + const obj = ft as Record; + + // No unknown keys + for (const key of Object.keys(obj)) { + if (!FIELD_TYPE_KEYS.has(key)) { + return { valid: false, error: `Unknown FieldType key: "${key}"` }; + } + } + + // name: required, valid identifier + if (typeof obj.name !== 'string') { + return { valid: false, error: 'FieldType.name is required and must be a string' }; + } + if (!IDENTIFIER_PATTERN.test(obj.name)) { + return { + valid: false, + error: `FieldType.name must be a valid SQL identifier, got: "${obj.name}"` + }; + } + + // Forbidden types + const nameLower = obj.name.toLowerCase(); + if (FORBIDDEN_TYPES.has(nameLower)) { + return { + valid: false, + error: `Forbidden type: "${obj.name}" — system catalog OID-alias types are not allowed` + }; + } + for (const forbidden of additionalForbiddenTypes) { + if (nameLower === forbidden.toLowerCase()) { + return { + valid: false, + error: `Forbidden type: "${obj.name}"` + }; + } + } + + // schema: optional, valid identifier + allowlist + if (obj.schema !== undefined) { + if (typeof obj.schema !== 'string') { + return { valid: false, error: 'FieldType.schema must be a string' }; + } + if (!IDENTIFIER_PATTERN.test(obj.schema)) { + return { + valid: false, + error: `FieldType.schema must be a valid SQL identifier, got: "${obj.schema}"` + }; + } + if (allowedTypeSchemas.length > 0) { + const schemaLower = obj.schema.toLowerCase(); + if (!allowedTypeSchemas.some(s => s.toLowerCase() === schemaLower)) { + return { + valid: false, + error: `Type schema "${obj.schema}" is not in the allowed schemas list` + }; + } + } + } + + // args: optional, must be array of literals + if (obj.args !== undefined) { + if (!Array.isArray(obj.args)) { + return { valid: false, error: 'FieldType.args must be an array' }; + } + for (let i = 0; i < obj.args.length; i++) { + const arg = obj.args[i]; + if (typeof arg !== 'string' && typeof arg !== 'number' && typeof arg !== 'boolean') { + return { + valid: false, + error: `FieldType.args[${i}] must be a string, number, or boolean, got: ${typeof arg}` + }; + } + // String args must be valid identifiers (they become type modifiers) + if (typeof arg === 'string' && !IDENTIFIER_PATTERN.test(arg)) { + return { + valid: false, + error: `FieldType.args[${i}] string must be a valid identifier, got: "${arg}"` + }; + } + } + } + + // array_dimensions: optional, positive integer + if (obj.array_dimensions !== undefined) { + if (typeof obj.array_dimensions !== 'number' || !Number.isInteger(obj.array_dimensions)) { + return { valid: false, error: 'FieldType.array_dimensions must be an integer' }; + } + if (obj.array_dimensions < 0 || obj.array_dimensions > 6) { + return { + valid: false, + error: 'FieldType.array_dimensions must be between 0 and 6' + }; + } + } + + // range: optional, 1-2 valid interval field qualifiers + if (obj.range !== undefined) { + if (!Array.isArray(obj.range)) { + return { valid: false, error: 'FieldType.range must be an array' }; + } + if (obj.range.length < 1 || obj.range.length > 2) { + return { valid: false, error: 'FieldType.range must have 1 or 2 elements' }; + } + for (let i = 0; i < obj.range.length; i++) { + const field = obj.range[i]; + if (typeof field !== 'string') { + return { + valid: false, + error: `FieldType.range[${i}] must be a string` + }; + } + if (!VALID_INTERVAL_FIELDS.has(field.toLowerCase())) { + return { + valid: false, + error: `FieldType.range[${i}] must be a valid interval field (year, month, day, hour, minute, second), got: "${field}"` + }; + } + } + // range only makes sense on interval + if (nameLower !== 'interval') { + return { + valid: false, + error: 'FieldType.range is only valid for interval types' + }; + } + } + + // Generate canonical SQL and AST + const typedFt = obj as unknown as FieldType; + try { + const ast = fieldTypeToAst(typedFt); + const canonicalSql = fieldTypeToSql(typedFt); + return { valid: true, canonicalSql, ast }; + } catch (e: unknown) { + const msg = e instanceof Error ? e.message : String(e); + return { valid: false, error: `Failed to convert FieldType to SQL: ${msg}` }; + } +} + +/** + * Validate a FieldDefault object. + * + * Structural validation + AST conversion + expression validation. + * + * 1. Validates the JSON structure (shape, types, required fields) + * 2. Converts to pgsql-parser expression AST + * 3. Validates the AST through the expression validator (function/schema allowlists) + * 4. Deparses to canonical SQL text + */ +export function validateFieldDefault( + fd: unknown, + options: FieldDefaultValidationOptions = {} +): FieldDefaultValidationResult { + const { + maxNestingDepth = 4, + allowedTypeSchemas = [], + additionalForbiddenTypes = [], + allowedSchemaFunctions = {}, + ...expressionOptions + } = options; + + // Structural validation first + const structResult = validateFieldDefaultStructure( + fd, 0, maxNestingDepth, allowedTypeSchemas, additionalForbiddenTypes + ); + if (!structResult.valid) { + return structResult; + } + + // Build merged options for the expression validator + // Include schema-qualified functions in both allowedFunctions and allowedSchemas + const mergedFunctions = [...(expressionOptions.allowedFunctions ?? [])]; + const mergedSchemas = [...(expressionOptions.allowedSchemas ?? [])]; + + for (const [schema, funcs] of Object.entries(allowedSchemaFunctions)) { + if (!mergedSchemas.some(s => s.toLowerCase() === schema.toLowerCase())) { + mergedSchemas.push(schema); + } + for (const func of funcs) { + if (!mergedFunctions.some(f => f.toLowerCase() === func.toLowerCase())) { + mergedFunctions.push(func); + } + } + } + + const mergedExpressionOptions: SqlExpressionValidatorOptions = { + ...expressionOptions, + allowedFunctions: mergedFunctions.length > 0 ? mergedFunctions : undefined, + allowedSchemas: mergedSchemas.length > 0 ? mergedSchemas : undefined + }; + + // Convert to AST + const typedFd = fd as FieldDefault; + let ast: Record; + try { + ast = fieldDefaultToAst(typedFd); + } catch (e: unknown) { + const msg = e instanceof Error ? e.message : String(e); + return { valid: false, error: `Failed to convert FieldDefault to AST: ${msg}` }; + } + + // Validate the AST through the expression validator + const astResult = validateAst(ast, mergedExpressionOptions); + if (!astResult.valid) { + return { valid: false, error: astResult.error }; + } + + return { + valid: true, + canonicalSql: astResult.canonicalText, + ast + }; +} + +/** + * Recursively validate the structure of a FieldDefault object. + */ +function validateFieldDefaultStructure( + fd: unknown, + depth: number, + maxDepth: number, + allowedTypeSchemas: string[], + additionalForbiddenTypes: string[] +): FieldDefaultValidationResult { + if (depth > maxDepth) { + return { + valid: false, + error: `FieldDefault nesting exceeds maximum depth of ${maxDepth}` + }; + } + + if (!fd || typeof fd !== 'object' || Array.isArray(fd)) { + return { valid: false, error: 'FieldDefault must be a non-null object' }; + } + + const obj = fd as Record; + + // No unknown keys + for (const key of Object.keys(obj)) { + if (!FIELD_DEFAULT_KEYS.has(key)) { + return { valid: false, error: `Unknown FieldDefault key: "${key}"` }; + } + } + + // Must have either function or value (not both, not neither) + const hasFunction = obj.function !== undefined; + const hasValue = obj.value !== undefined; + + if (!hasFunction && !hasValue) { + return { valid: false, error: 'FieldDefault must have either "function" or "value"' }; + } + if (hasFunction && hasValue) { + return { valid: false, error: 'FieldDefault cannot have both "function" and "value"' }; + } + + // schema without function is invalid + if (obj.schema !== undefined && !hasFunction) { + return { valid: false, error: 'FieldDefault.schema is only valid with "function"' }; + } + + // args without function is invalid + if (obj.args !== undefined && !hasFunction) { + return { valid: false, error: 'FieldDefault.args is only valid with "function"' }; + } + + if (hasFunction) { + // function: must be valid identifier + if (typeof obj.function !== 'string') { + return { valid: false, error: 'FieldDefault.function must be a string' }; + } + if (!IDENTIFIER_PATTERN.test(obj.function)) { + return { + valid: false, + error: `FieldDefault.function must be a valid SQL identifier, got: "${obj.function}"` + }; + } + + // schema: optional, valid identifier + if (obj.schema !== undefined) { + if (typeof obj.schema !== 'string') { + return { valid: false, error: 'FieldDefault.schema must be a string' }; + } + if (!IDENTIFIER_PATTERN.test(obj.schema)) { + return { + valid: false, + error: `FieldDefault.schema must be a valid SQL identifier, got: "${obj.schema}"` + }; + } + } + + // args: optional, recursive + if (obj.args !== undefined) { + if (!Array.isArray(obj.args)) { + return { valid: false, error: 'FieldDefault.args must be an array' }; + } + for (let i = 0; i < obj.args.length; i++) { + const arg = obj.args[i]; + if (arg === null || typeof arg === 'string' || typeof arg === 'number' || typeof arg === 'boolean') { + continue; // Literal — valid + } + if (typeof arg === 'object') { + // Nested FieldDefault + const nestedResult = validateFieldDefaultStructure( + arg, depth + 1, maxDepth, allowedTypeSchemas, additionalForbiddenTypes + ); + if (!nestedResult.valid) { + return { + valid: false, + error: `FieldDefault.args[${i}]: ${nestedResult.error}` + }; + } + } else { + return { + valid: false, + error: `FieldDefault.args[${i}] must be a string, number, boolean, null, or FieldDefault object` + }; + } + } + } + } + + if (hasValue) { + // value: must be string, number, boolean, null, or array + const v = obj.value; + if (v !== null && typeof v !== 'string' && typeof v !== 'number' && typeof v !== 'boolean' && !Array.isArray(v)) { + return { + valid: false, + error: `FieldDefault.value must be a string, number, boolean, null, or array` + }; + } + if (Array.isArray(v)) { + for (let i = 0; i < v.length; i++) { + const el = v[i]; + if (el !== null && typeof el !== 'string' && typeof el !== 'number' && typeof el !== 'boolean') { + return { + valid: false, + error: `FieldDefault.value[${i}] array elements must be string, number, boolean, or null` + }; + } + } + } + } + + // cast: optional, must be valid FieldType + if (obj.cast !== undefined) { + const castResult = validateFieldType(obj.cast, { + allowedTypeSchemas, + additionalForbiddenTypes + }); + if (!castResult.valid) { + return { valid: false, error: `FieldDefault.cast: ${castResult.error}` }; + } + } + + return { valid: true }; +} diff --git a/graphile/graphile-sql-expression-validator/src/index.ts b/graphile/graphile-sql-expression-validator/src/index.ts index dc0e676ede..7ec577ed93 100644 --- a/graphile/graphile-sql-expression-validator/src/index.ts +++ b/graphile/graphile-sql-expression-validator/src/index.ts @@ -50,3 +50,25 @@ export { createSqlExpressionValidatorPlugin, SqlExpressionValidatorPreset } from './plugin'; + +// FieldType / FieldDefault structured models +export { + validateFieldType, + validateFieldDefault, + fieldTypeToAst, + fieldTypeToSql, + fieldDefaultToAst, + fieldDefaultToSql, + FORBIDDEN_TYPES +} from './field-types'; + +// FieldType / FieldDefault types +export type { + FieldType, + FieldDefault, + FieldDefaultArg, + FieldTypeValidationOptions, + FieldDefaultValidationOptions, + FieldTypeValidationResult, + FieldDefaultValidationResult +} from './field-types'; diff --git a/graphile/graphile-sql-expression-validator/src/validator.ts b/graphile/graphile-sql-expression-validator/src/validator.ts index f6cb344d3a..1ca90cb1d2 100644 --- a/graphile/graphile-sql-expression-validator/src/validator.ts +++ b/graphile/graphile-sql-expression-validator/src/validator.ts @@ -112,7 +112,8 @@ const ALLOWED_NODE_TYPES = new Set([ 'sval', 'fval', 'boolval', - 'bsval' + 'bsval', + 'isnull' ]); /**