From f6afbb15bb2e5241125c3f4e7a860f465336f3f7 Mon Sep 17 00:00:00 2001 From: Jonathan Fulton Date: Sat, 31 Jan 2026 15:16:15 -0500 Subject: [PATCH 1/2] feat: add RLS Policy Playground API endpoints Adds new API endpoints for simulating and debugging Row Level Security policies: - GET /rls-playground/roles - List available database roles - GET /rls-playground/tables?schema=X - List tables with RLS status - GET /rls-playground/policies/:schema/:table - Get policies for a table - GET /rls-playground/rls-status/:schema/:table - Check if RLS is enabled - POST /rls-playground/simulate - Run policy simulation with custom context - POST /rls-playground/evaluate-expression - Test single policy expression The simulation endpoint allows users to: - Set a database role (anon, authenticated, service_role, custom) - Provide JWT claims to simulate auth.jwt() - See which rows are accessible under the simulated context - Get policy-by-policy evaluation results for debugging All simulations run in rolled-back transactions for safety. --- src/lib/PostgresMetaRLSPlayground.ts | 445 +++++++++++++++++++++++++++ src/server/routes/index.ts | 2 + src/server/routes/rls-playground.ts | 233 ++++++++++++++ 3 files changed, 680 insertions(+) create mode 100644 src/lib/PostgresMetaRLSPlayground.ts create mode 100644 src/server/routes/rls-playground.ts diff --git a/src/lib/PostgresMetaRLSPlayground.ts b/src/lib/PostgresMetaRLSPlayground.ts new file mode 100644 index 00000000..3dbda672 --- /dev/null +++ b/src/lib/PostgresMetaRLSPlayground.ts @@ -0,0 +1,445 @@ +import { ident, literal } from 'pg-format' +import { PostgresMetaResult, PostgresPolicy } from './types.js' +import { POLICIES_SQL } from './sql/policies.sql.js' +import { filterByList } from './helpers.js' + +export interface RLSSimulationContext { + role: string + jwtClaims?: Record + userId?: string +} + +export interface RLSPolicyEvaluation { + policy_id: number + policy_name: string + command: string + action: string + passed: boolean + expression: string | null + check_expression: string | null +} + +export interface RLSRowResult { + row_data: Record + row_number: number + policies_evaluated: RLSPolicyEvaluation[] + accessible: boolean +} + +export interface RLSSimulationResult { + table_name: string + schema_name: string + operation: 'SELECT' | 'INSERT' | 'UPDATE' | 'DELETE' + context: RLSSimulationContext + rls_enabled: boolean + policies: PostgresPolicy[] + rows: RLSRowResult[] + total_rows_without_rls: number + accessible_rows: number + error?: string +} + +export default class PostgresMetaRLSPlayground { + query: (sql: string) => Promise> + + constructor(query: (sql: string) => Promise>) { + this.query = query + } + + /** + * Get all policies for a specific table + */ + async getPoliciesForTable({ + schema = 'public', + table, + }: { + schema?: string + table: string + }): Promise> { + const schemaFilter = filterByList([schema], []) + const sql = POLICIES_SQL({ schemaFilter }) + ` AND c.relname = ${literal(table)}` + return await this.query(sql) + } + + /** + * Check if RLS is enabled for a table + */ + async isRLSEnabled({ + schema = 'public', + table, + }: { + schema?: string + table: string + }): Promise> { + const sql = ` + SELECT relrowsecurity + FROM pg_class c + JOIN pg_namespace n ON n.oid = c.relnamespace + WHERE n.nspname = ${literal(schema)} + AND c.relname = ${literal(table)} + ` + const { data, error } = await this.query(sql) + if (error) return { data: null, error } + return { data: data[0]?.relrowsecurity ?? false, error: null } + } + + /** + * Get database roles that can be used for simulation + */ + async getAvailableRoles(): Promise> { + const sql = ` + SELECT rolname + FROM pg_roles + WHERE rolcanlogin = true + OR rolname IN ('anon', 'authenticated', 'service_role') + ORDER BY rolname + ` + const { data, error } = await this.query(sql) + if (error) return { data: null, error } + return { data: data.map((r: any) => r.rolname), error: null } + } + + /** + * Simulate RLS policy evaluation for a query + * This runs in a transaction that always rolls back + */ + async simulate({ + schema = 'public', + table, + operation = 'SELECT', + context, + limit = 100, + testData, + }: { + schema?: string + table: string + operation?: 'SELECT' | 'INSERT' | 'UPDATE' | 'DELETE' + context: RLSSimulationContext + limit?: number + testData?: Record // For INSERT/UPDATE simulation + }): Promise> { + // Get table policies first + const { data: policies, error: policiesError } = await this.getPoliciesForTable({ schema, table }) + if (policiesError) return { data: null, error: policiesError } + + // Check if RLS is enabled + const { data: rlsEnabled, error: rlsError } = await this.isRLSEnabled({ schema, table }) + if (rlsError) return { data: null, error: rlsError } + + // Filter policies for this operation + const relevantPolicies = policies!.filter( + (p) => p.command === operation || p.command === 'ALL' + ) + + // Build the simulation SQL + const jwtClaimsJson = context.jwtClaims ? JSON.stringify(context.jwtClaims) : '{}' + + // Build simulation query based on operation + let simulationSql: string + + if (operation === 'SELECT') { + simulationSql = this.buildSelectSimulation({ + schema, + table, + context, + jwtClaimsJson, + relevantPolicies, + limit, + }) + } else if (operation === 'INSERT' && testData) { + simulationSql = this.buildInsertSimulation({ + schema, + table, + context, + jwtClaimsJson, + relevantPolicies, + testData, + }) + } else { + simulationSql = this.buildSelectSimulation({ + schema, + table, + context, + jwtClaimsJson, + relevantPolicies, + limit, + }) + } + + const { data: simResult, error: simError } = await this.query(simulationSql) + + if (simError) { + return { + data: { + table_name: table, + schema_name: schema, + operation, + context, + rls_enabled: rlsEnabled!, + policies: policies!, + rows: [], + total_rows_without_rls: 0, + accessible_rows: 0, + error: simError.message, + }, + error: null, + } + } + + // Parse simulation results + const rows: RLSRowResult[] = [] + const policyResults = simResult[0] + + if (policyResults) { + // Results from simulation + const totalRows = policyResults.total_count || 0 + const accessibleData = policyResults.accessible_rows || [] + const policyEvals = policyResults.policy_evaluations || [] + + for (let i = 0; i < accessibleData.length; i++) { + const rowData = accessibleData[i] + const rowPolicyEvals: RLSPolicyEvaluation[] = relevantPolicies.map((policy) => { + const evalResult = policyEvals.find( + (e: any) => e.policy_id === policy.id && e.row_index === i + ) + return { + policy_id: policy.id, + policy_name: policy.name, + command: policy.command, + action: policy.action, + passed: evalResult?.passed ?? true, + expression: policy.definition, + check_expression: policy.check, + } + }) + + rows.push({ + row_data: rowData, + row_number: i + 1, + policies_evaluated: rowPolicyEvals, + accessible: true, + }) + } + + return { + data: { + table_name: table, + schema_name: schema, + operation, + context, + rls_enabled: rlsEnabled!, + policies: policies!, + rows, + total_rows_without_rls: totalRows, + accessible_rows: accessibleData.length, + }, + error: null, + } + } + + return { + data: { + table_name: table, + schema_name: schema, + operation, + context, + rls_enabled: rlsEnabled!, + policies: policies!, + rows: [], + total_rows_without_rls: 0, + accessible_rows: 0, + }, + error: null, + } + } + + private buildSelectSimulation({ + schema, + table, + context, + jwtClaimsJson, + relevantPolicies, + limit, + }: { + schema: string + table: string + context: RLSSimulationContext + jwtClaimsJson: string + relevantPolicies: PostgresPolicy[] + limit: number + }): string { + const tableRef = `${ident(schema)}.${ident(table)}` + + // Build policy evaluation expressions + const policyEvalCases = relevantPolicies.map((policy) => { + if (!policy.definition) return null + return ` + jsonb_build_object( + 'policy_id', ${policy.id}, + 'policy_name', ${literal(policy.name)}, + 'passed', CASE WHEN (${policy.definition}) THEN true ELSE false END + ) + ` + }).filter(Boolean) + + const policyEvalArray = policyEvalCases.length > 0 + ? `ARRAY[${policyEvalCases.join(', ')}]::jsonb[]` + : `ARRAY[]::jsonb[]` + + return ` + DO $$ + BEGIN + -- Set the simulated context + PERFORM set_config('role', ${literal(context.role)}, true); + PERFORM set_config('request.jwt.claims', ${literal(jwtClaimsJson)}, true); + ${context.userId ? `PERFORM set_config('request.jwt.claim.sub', ${literal(context.userId)}, true);` : ''} + END $$; + + WITH + -- Get total count without RLS (as superuser context we're in) + total_count AS ( + SELECT COUNT(*) as cnt FROM ${tableRef} + ), + -- Get rows accessible with the simulated role + accessible_data AS ( + SELECT + row_to_json(t.*) as row_data, + ${policyEvalArray} as policy_evals + FROM ${tableRef} t + LIMIT ${limit} + ) + SELECT + jsonb_build_object( + 'total_count', (SELECT cnt FROM total_count), + 'accessible_rows', (SELECT COALESCE(jsonb_agg(row_data), '[]'::jsonb) FROM accessible_data), + 'policy_evaluations', ( + SELECT COALESCE( + jsonb_agg( + jsonb_build_object( + 'row_index', row_num - 1, + 'evaluations', policy_evals + ) + ), + '[]'::jsonb + ) + FROM ( + SELECT ROW_NUMBER() OVER () as row_num, policy_evals + FROM accessible_data + ) numbered + ) + ) as result; + ` + } + + private buildInsertSimulation({ + schema, + table, + context, + jwtClaimsJson, + relevantPolicies, + testData, + }: { + schema: string + table: string + context: RLSSimulationContext + jwtClaimsJson: string + relevantPolicies: PostgresPolicy[] + testData: Record + }): string { + const tableRef = `${ident(schema)}.${ident(table)}` + + // Build WITH CHECK evaluation for INSERT policies + const checkPolicies = relevantPolicies.filter((p) => p.check) + const checkEvals = checkPolicies.map((policy) => ` + jsonb_build_object( + 'policy_id', ${policy.id}, + 'policy_name', ${literal(policy.name)}, + 'passed', CASE WHEN (${policy.check}) THEN true ELSE false END + ) + `) + + const columns = Object.keys(testData) + const values = Object.values(testData).map((v) => literal(v)) + + return ` + DO $$ + BEGIN + PERFORM set_config('role', ${literal(context.role)}, true); + PERFORM set_config('request.jwt.claims', ${literal(jwtClaimsJson)}, true); + ${context.userId ? `PERFORM set_config('request.jwt.claim.sub', ${literal(context.userId)}, true);` : ''} + END $$; + + BEGIN; + + -- Try the insert in a savepoint + SAVEPOINT test_insert; + + INSERT INTO ${tableRef} (${columns.map(ident).join(', ')}) + VALUES (${values.join(', ')}); + + -- Rollback the insert but capture what happened + ROLLBACK TO SAVEPOINT test_insert; + + -- Return policy evaluation results + SELECT jsonb_build_object( + 'total_count', 1, + 'accessible_rows', jsonb_build_array(${literal(JSON.stringify(testData))}::jsonb), + 'policy_evaluations', jsonb_build_array( + jsonb_build_object( + 'row_index', 0, + 'evaluations', ARRAY[${checkEvals.join(', ')}]::jsonb[] + ) + ), + 'insert_allowed', true + ) as result; + + ROLLBACK; + ` + } + + /** + * Evaluate a single policy expression against test data + */ + async evaluateExpression({ + schema = 'public', + table, + expression, + context, + testRow, + }: { + schema?: string + table: string + expression: string + context: RLSSimulationContext + testRow?: Record + }): Promise> { + const tableRef = `${ident(schema)}.${ident(table)}` + const jwtClaimsJson = context.jwtClaims ? JSON.stringify(context.jwtClaims) : '{}' + + // If testRow is provided, create a CTE with that row + const rowSource = testRow + ? `(SELECT ${Object.entries(testRow) + .map(([k, v]) => `${literal(v)} as ${ident(k)}`) + .join(', ')}) t` + : `(SELECT * FROM ${tableRef} LIMIT 1) t` + + const sql = ` + DO $$ + BEGIN + PERFORM set_config('role', ${literal(context.role)}, true); + PERFORM set_config('request.jwt.claims', ${literal(jwtClaimsJson)}, true); + ${context.userId ? `PERFORM set_config('request.jwt.claim.sub', ${literal(context.userId)}, true);` : ''} + END $$; + + SELECT + CASE WHEN (${expression}) THEN true ELSE false END as result + FROM ${rowSource}; + ` + + const { data, error } = await this.query(sql) + if (error) { + return { data: { result: false, error: error.message }, error: null } + } + return { data: { result: data[0]?.result ?? false }, error: null } + } +} diff --git a/src/server/routes/index.ts b/src/server/routes/index.ts index 46ffba0f..38e79236 100644 --- a/src/server/routes/index.ts +++ b/src/server/routes/index.ts @@ -22,6 +22,7 @@ import TypeScriptTypeGenRoute from './generators/typescript.js' import GoTypeGenRoute from './generators/go.js' import SwiftTypeGenRoute from './generators/swift.js' import PythonTypeGenRoute from './generators/python.js' +import RLSPlaygroundRoute from './rls-playground.js' import { PG_CONNECTION, CRYPTO_KEY } from '../constants.js' export default async (fastify: FastifyInstance) => { @@ -84,4 +85,5 @@ export default async (fastify: FastifyInstance) => { fastify.register(GoTypeGenRoute, { prefix: '/generators/go' }) fastify.register(SwiftTypeGenRoute, { prefix: '/generators/swift' }) fastify.register(PythonTypeGenRoute, { prefix: '/generators/python' }) + fastify.register(RLSPlaygroundRoute, { prefix: '/rls-playground' }) } diff --git a/src/server/routes/rls-playground.ts b/src/server/routes/rls-playground.ts new file mode 100644 index 00000000..f78aca70 --- /dev/null +++ b/src/server/routes/rls-playground.ts @@ -0,0 +1,233 @@ +import { FastifyInstance } from 'fastify' +import { PostgresMeta } from '../../lib/index.js' +import PostgresMetaRLSPlayground, { + RLSSimulationContext, + RLSSimulationResult, +} from '../../lib/PostgresMetaRLSPlayground.js' +import { createConnectionConfig } from '../utils.js' +import { extractRequestForLogging } from '../utils.js' + +export default async (fastify: FastifyInstance) => { + /** + * GET /rls-playground/roles + * Get available roles for simulation + */ + fastify.get<{ + Headers: { pg: string; 'x-pg-application-name'?: string } + }>('/roles', async (request, reply) => { + const config = createConnectionConfig(request) + const pgMeta = new PostgresMeta(config) + const rlsPlayground = new PostgresMetaRLSPlayground(pgMeta.query) + + const { data, error } = await rlsPlayground.getAvailableRoles() + await pgMeta.end() + + if (error) { + request.log.error({ error, request: extractRequestForLogging(request) }) + reply.code(500) + return { error: error.message } + } + + return data + }) + + /** + * GET /rls-playground/policies/:schema/:table + * Get policies for a specific table + */ + fastify.get<{ + Headers: { pg: string; 'x-pg-application-name'?: string } + Params: { + schema: string + table: string + } + }>('/policies/:schema/:table', async (request, reply) => { + const config = createConnectionConfig(request) + const { schema, table } = request.params + + const pgMeta = new PostgresMeta(config) + const rlsPlayground = new PostgresMetaRLSPlayground(pgMeta.query) + + const { data, error } = await rlsPlayground.getPoliciesForTable({ schema, table }) + await pgMeta.end() + + if (error) { + request.log.error({ error, request: extractRequestForLogging(request) }) + reply.code(500) + return { error: error.message } + } + + return data + }) + + /** + * GET /rls-playground/rls-status/:schema/:table + * Check if RLS is enabled for a table + */ + fastify.get<{ + Headers: { pg: string; 'x-pg-application-name'?: string } + Params: { + schema: string + table: string + } + }>('/rls-status/:schema/:table', async (request, reply) => { + const config = createConnectionConfig(request) + const { schema, table } = request.params + + const pgMeta = new PostgresMeta(config) + const rlsPlayground = new PostgresMetaRLSPlayground(pgMeta.query) + + const { data, error } = await rlsPlayground.isRLSEnabled({ schema, table }) + await pgMeta.end() + + if (error) { + request.log.error({ error, request: extractRequestForLogging(request) }) + reply.code(500) + return { error: error.message } + } + + return { rls_enabled: data } + }) + + /** + * POST /rls-playground/simulate + * Simulate RLS policy evaluation + */ + fastify.post<{ + Headers: { pg: string; 'x-pg-application-name'?: string } + Body: { + schema?: string + table: string + operation?: 'SELECT' | 'INSERT' | 'UPDATE' | 'DELETE' + context: RLSSimulationContext + limit?: number + testData?: Record + } + }>('/simulate', async (request, reply) => { + const config = createConnectionConfig(request) + const { schema, table, operation, context, limit, testData } = request.body + + if (!table) { + reply.code(400) + return { error: 'table is required' } + } + + if (!context || !context.role) { + reply.code(400) + return { error: 'context.role is required' } + } + + const pgMeta = new PostgresMeta(config) + const rlsPlayground = new PostgresMetaRLSPlayground(pgMeta.query) + + const { data, error } = await rlsPlayground.simulate({ + schema: schema || 'public', + table, + operation: operation || 'SELECT', + context, + limit: limit || 100, + testData, + }) + await pgMeta.end() + + if (error) { + request.log.error({ error, request: extractRequestForLogging(request) }) + reply.code(500) + return { error: error.message } + } + + return data + }) + + /** + * POST /rls-playground/evaluate-expression + * Evaluate a single policy expression + */ + fastify.post<{ + Headers: { pg: string; 'x-pg-application-name'?: string } + Body: { + schema?: string + table: string + expression: string + context: RLSSimulationContext + testRow?: Record + } + }>('/evaluate-expression', async (request, reply) => { + const config = createConnectionConfig(request) + const { schema, table, expression, context, testRow } = request.body + + if (!table || !expression || !context) { + reply.code(400) + return { error: 'table, expression, and context are required' } + } + + const pgMeta = new PostgresMeta(config) + const rlsPlayground = new PostgresMetaRLSPlayground(pgMeta.query) + + const { data, error } = await rlsPlayground.evaluateExpression({ + schema: schema || 'public', + table, + expression, + context, + testRow, + }) + await pgMeta.end() + + if (error) { + request.log.error({ error, request: extractRequestForLogging(request) }) + reply.code(500) + return { error: error.message } + } + + return data + }) + + /** + * GET /rls-playground/tables + * Get tables with their RLS status + */ + fastify.get<{ + Headers: { pg: string; 'x-pg-application-name'?: string } + Querystring: { + schema?: string + } + }>('/tables', async (request, reply) => { + const config = createConnectionConfig(request) + const schema = request.query.schema || 'public' + + const pgMeta = new PostgresMeta(config) + + // Use parameterized query to prevent SQL injection + const { literal } = await import('pg-format') + const sql = ` + SELECT + c.oid::int8 as id, + n.nspname as schema, + c.relname as name, + c.relrowsecurity as rls_enabled, + c.relforcerowsecurity as rls_forced, + ( + SELECT COUNT(*)::int + FROM pg_policy pol + WHERE pol.polrelid = c.oid + ) as policy_count + FROM pg_class c + JOIN pg_namespace n ON n.oid = c.relnamespace + WHERE c.relkind = 'r' + AND n.nspname = ${literal(schema)} + AND n.nspname NOT IN ('pg_catalog', 'information_schema') + ORDER BY c.relname + ` + + const { data, error } = await pgMeta.query(sql) + await pgMeta.end() + + if (error) { + request.log.error({ error, request: extractRequestForLogging(request) }) + reply.code(500) + return { error: error.message } + } + + return data + }) +} From 014053115d8895c3000e987edb692fe019b459e5 Mon Sep 17 00:00:00 2001 From: Jonathan Fulton Date: Sat, 31 Jan 2026 15:34:31 -0500 Subject: [PATCH 2/2] fix: prevent SQL injection in RLS playground endpoints Security fixes for Snyk HIGH severity issues: 1. Added validateRLSExpression() function to reject dangerous SQL patterns: - DDL statements (CREATE, DROP, ALTER, TRUNCATE) - DML statements (INSERT, UPDATE, DELETE) - Transaction control (COMMIT, ROLLBACK, SAVEPOINT) - Security commands (GRANT, REVOKE, SET ROLE) - Dangerous functions (pg_read_file, pg_terminate_backend, etc.) - Comment injection (-- and /*) - Statement separators (;) 2. evaluateExpression: Now validates user-provided expressions before execution and runs in a READ ONLY transaction that always rolls back 3. buildSelectSimulation: Added expression validation for policy definitions (defense in depth) and proper transaction isolation with ROLLBACK 4. buildInsertSimulation: Added validation for policy.check expressions and uses literal() for policy.id to prevent any potential injection 5. Route validation: Added input length limits and type validation for expression and context.role parameters 6. Limit sanitization: Ensured limit parameter is always a safe integer --- src/lib/PostgresMetaRLSPlayground.ts | 163 ++++++++++++++++++++++----- src/server/routes/rls-playground.ts | 11 ++ 2 files changed, 143 insertions(+), 31 deletions(-) diff --git a/src/lib/PostgresMetaRLSPlayground.ts b/src/lib/PostgresMetaRLSPlayground.ts index 3dbda672..309f77d3 100644 --- a/src/lib/PostgresMetaRLSPlayground.ts +++ b/src/lib/PostgresMetaRLSPlayground.ts @@ -3,6 +3,61 @@ import { PostgresMetaResult, PostgresPolicy } from './types.js' import { POLICIES_SQL } from './sql/policies.sql.js' import { filterByList } from './helpers.js' +/** + * Validate that an expression is safe for RLS policy evaluation. + * Rejects expressions containing dangerous SQL patterns. + */ +function validateRLSExpression(expression: string): { valid: boolean; error?: string } { + if (!expression || typeof expression !== 'string') { + return { valid: false, error: 'Expression must be a non-empty string' } + } + + // Normalize for checking (case-insensitive) + const normalized = expression.toUpperCase().replace(/\s+/g, ' ') + + // List of dangerous patterns that should never appear in RLS policy expressions + const dangerousPatterns = [ + // DDL statements + /\b(CREATE|DROP|ALTER|TRUNCATE|RENAME)\b/i, + // DML that modifies data + /\b(INSERT|UPDATE|DELETE)\b/i, + // Transaction control + /\b(COMMIT|ROLLBACK|SAVEPOINT|BEGIN)\b/i, + // Security-sensitive commands + /\b(GRANT|REVOKE|SET\s+ROLE|RESET\s+ROLE|SET\s+SESSION)\b/i, + // Dangerous functions + /\b(pg_read_file|pg_read_binary_file|pg_write_file|pg_terminate_backend|pg_cancel_backend)\s*\(/i, + /\b(lo_import|lo_export|lo_unlink)\s*\(/i, + /\b(pg_execute_server_program|pg_file_write|copy)\s*\(/i, + // System info functions that could leak data + /\b(pg_ls_dir|pg_stat_file)\s*\(/i, + // Extension manipulation + /\b(CREATE\s+EXTENSION|DROP\s+EXTENSION)\b/i, + // COPY command + /\bCOPY\b/i, + // DO blocks (arbitrary code execution) + /\bDO\s*\$/i, + // EXECUTE for dynamic SQL + /\bEXECUTE\b/i, + // Comment injection attempts + /--/, + /\/\*/, + ] + + for (const pattern of dangerousPatterns) { + if (pattern.test(expression)) { + return { valid: false, error: `Expression contains forbidden SQL pattern: ${pattern.source}` } + } + } + + // Check for semicolons (statement separators) which could indicate SQL injection + if (expression.includes(';')) { + return { valid: false, error: 'Expression must not contain semicolons' } + } + + return { valid: true } +} + export interface RLSSimulationContext { role: string jwtClaims?: Record @@ -269,13 +324,30 @@ export default class PostgresMetaRLSPlayground { limit: number }): string { const tableRef = `${ident(schema)}.${ident(table)}` + // Sanitize limit to ensure it's a positive integer + const safeLimit = Math.max(1, Math.min(Math.floor(Number(limit) || 100), 10000)) // Build policy evaluation expressions + // Policy definitions come from pg_policy table (database-stored policies) + // We validate them to ensure they don't contain dangerous patterns const policyEvalCases = relevantPolicies.map((policy) => { if (!policy.definition) return null + // Validate policy expression (policies from DB should be safe, but defense in depth) + const validation = validateRLSExpression(policy.definition) + if (!validation.valid) { + // Return a safe fallback that indicates validation failure + return ` + jsonb_build_object( + 'policy_id', ${literal(policy.id)}, + 'policy_name', ${literal(policy.name)}, + 'passed', false, + 'error', ${literal(`Policy expression validation failed: ${validation.error}`)} + ) + ` + } return ` jsonb_build_object( - 'policy_id', ${policy.id}, + 'policy_id', ${literal(policy.id)}, 'policy_name', ${literal(policy.name)}, 'passed', CASE WHEN (${policy.definition}) THEN true ELSE false END ) @@ -286,14 +358,14 @@ export default class PostgresMetaRLSPlayground { ? `ARRAY[${policyEvalCases.join(', ')}]::jsonb[]` : `ARRAY[]::jsonb[]` + // Execute in a read-only transaction that always rolls back return ` - DO $$ - BEGIN - -- Set the simulated context - PERFORM set_config('role', ${literal(context.role)}, true); - PERFORM set_config('request.jwt.claims', ${literal(jwtClaimsJson)}, true); - ${context.userId ? `PERFORM set_config('request.jwt.claim.sub', ${literal(context.userId)}, true);` : ''} - END $$; + BEGIN TRANSACTION READ ONLY; + + -- Set the simulated context + SET LOCAL role = ${literal(context.role)}; + SET LOCAL "request.jwt.claims" = ${literal(jwtClaimsJson)}; + ${context.userId ? `SET LOCAL "request.jwt.claim.sub" = ${literal(context.userId)};` : ''} WITH -- Get total count without RLS (as superuser context we're in) @@ -306,7 +378,7 @@ export default class PostgresMetaRLSPlayground { row_to_json(t.*) as row_data, ${policyEvalArray} as policy_evals FROM ${tableRef} t - LIMIT ${limit} + LIMIT ${safeLimit} ) SELECT jsonb_build_object( @@ -328,6 +400,8 @@ export default class PostgresMetaRLSPlayground { ) numbered ) ) as result; + + ROLLBACK; ` } @@ -349,28 +423,43 @@ export default class PostgresMetaRLSPlayground { const tableRef = `${ident(schema)}.${ident(table)}` // Build WITH CHECK evaluation for INSERT policies + // Validate policy check expressions (defense in depth) const checkPolicies = relevantPolicies.filter((p) => p.check) - const checkEvals = checkPolicies.map((policy) => ` - jsonb_build_object( - 'policy_id', ${policy.id}, - 'policy_name', ${literal(policy.name)}, - 'passed', CASE WHEN (${policy.check}) THEN true ELSE false END - ) - `) + const checkEvals = checkPolicies.map((policy) => { + const validation = validateRLSExpression(policy.check!) + if (!validation.valid) { + return ` + jsonb_build_object( + 'policy_id', ${literal(policy.id)}, + 'policy_name', ${literal(policy.name)}, + 'passed', false, + 'error', ${literal(`Policy check validation failed: ${validation.error}`)} + ) + ` + } + return ` + jsonb_build_object( + 'policy_id', ${literal(policy.id)}, + 'policy_name', ${literal(policy.name)}, + 'passed', CASE WHEN (${policy.check}) THEN true ELSE false END + ) + ` + }) const columns = Object.keys(testData) const values = Object.values(testData).map((v) => literal(v)) - return ` - DO $$ - BEGIN - PERFORM set_config('role', ${literal(context.role)}, true); - PERFORM set_config('request.jwt.claims', ${literal(jwtClaimsJson)}, true); - ${context.userId ? `PERFORM set_config('request.jwt.claim.sub', ${literal(context.userId)}, true);` : ''} - END $$; + // Escape testData for safe JSON embedding + const safeTestDataJson = literal(JSON.stringify(testData)) + return ` BEGIN; + -- Set the simulated context + SET LOCAL role = ${literal(context.role)}; + SET LOCAL "request.jwt.claims" = ${literal(jwtClaimsJson)}; + ${context.userId ? `SET LOCAL "request.jwt.claim.sub" = ${literal(context.userId)};` : ''} + -- Try the insert in a savepoint SAVEPOINT test_insert; @@ -383,7 +472,7 @@ export default class PostgresMetaRLSPlayground { -- Return policy evaluation results SELECT jsonb_build_object( 'total_count', 1, - 'accessible_rows', jsonb_build_array(${literal(JSON.stringify(testData))}::jsonb), + 'accessible_rows', jsonb_build_array(${safeTestDataJson}::jsonb), 'policy_evaluations', jsonb_build_array( jsonb_build_object( 'row_index', 0, @@ -398,7 +487,8 @@ export default class PostgresMetaRLSPlayground { } /** - * Evaluate a single policy expression against test data + * Evaluate a single policy expression against test data. + * The expression is validated to prevent SQL injection. */ async evaluateExpression({ schema = 'public', @@ -413,6 +503,15 @@ export default class PostgresMetaRLSPlayground { context: RLSSimulationContext testRow?: Record }): Promise> { + // Validate the expression to prevent SQL injection + const validation = validateRLSExpression(expression) + if (!validation.valid) { + return { + data: { result: false, error: validation.error }, + error: null + } + } + const tableRef = `${ident(schema)}.${ident(table)}` const jwtClaimsJson = context.jwtClaims ? JSON.stringify(context.jwtClaims) : '{}' @@ -423,17 +522,19 @@ export default class PostgresMetaRLSPlayground { .join(', ')}) t` : `(SELECT * FROM ${tableRef} LIMIT 1) t` + // Execute in a read-only transaction that always rolls back const sql = ` - DO $$ - BEGIN - PERFORM set_config('role', ${literal(context.role)}, true); - PERFORM set_config('request.jwt.claims', ${literal(jwtClaimsJson)}, true); - ${context.userId ? `PERFORM set_config('request.jwt.claim.sub', ${literal(context.userId)}, true);` : ''} - END $$; + BEGIN TRANSACTION READ ONLY; + + SET LOCAL role = ${literal(context.role)}; + SET LOCAL "request.jwt.claims" = ${literal(jwtClaimsJson)}; + ${context.userId ? `SET LOCAL "request.jwt.claim.sub" = ${literal(context.userId)};` : ''} SELECT CASE WHEN (${expression}) THEN true ELSE false END as result FROM ${rowSource}; + + ROLLBACK; ` const { data, error } = await this.query(sql) diff --git a/src/server/routes/rls-playground.ts b/src/server/routes/rls-playground.ts index f78aca70..2e9c0b19 100644 --- a/src/server/routes/rls-playground.ts +++ b/src/server/routes/rls-playground.ts @@ -161,6 +161,17 @@ export default async (fastify: FastifyInstance) => { return { error: 'table, expression, and context are required' } } + // Basic input validation + if (typeof expression !== 'string' || expression.length > 10000) { + reply.code(400) + return { error: 'Invalid expression: must be a string under 10000 characters' } + } + + if (!context.role || typeof context.role !== 'string') { + reply.code(400) + return { error: 'context.role must be a non-empty string' } + } + const pgMeta = new PostgresMeta(config) const rlsPlayground = new PostgresMetaRLSPlayground(pgMeta.query)