diff --git a/packages/server/src/services/data-source-query/validate-sql.test.ts b/packages/server/src/services/data-source-query/validate-sql.test.ts index 48e7e14d..ebcc8bb2 100644 --- a/packages/server/src/services/data-source-query/validate-sql.test.ts +++ b/packages/server/src/services/data-source-query/validate-sql.test.ts @@ -114,11 +114,51 @@ describe("validateAndNormalizeReadOnlyQuery", () => { await expectValidationError( "DELETE FROM events", - "Only SELECT, SHOW, DESCRIBE, or EXPLAIN queries are allowed. Got: delete", + "Only SELECT, SHOW, DESCRIBE, EXPLAIN, or PRAGMA queries are allowed. Got: delete", "cloudflare_d1" ); }); + it("allows read-only Cloudflare D1 PRAGMA statements", async () => { + const pragmaQueries = [ + "PRAGMA table_list", + 'PRAGMA table_info("events")', + "PRAGMA table_xinfo(events)", + "PRAGMA index_list(events)", + "PRAGMA index_info(idx_events_created_at)", + "PRAGMA index_xinfo(idx_events_created_at)", + "PRAGMA foreign_key_list(events)", + "PRAGMA foreign_key_check", + "PRAGMA quick_check", + ]; + + for (const sql of pragmaQueries) { + await expectValidQuery( + sql, + { + sql, + }, + "cloudflare_d1" + ); + } + }); + + it("rejects D1 PRAGMA statements that are not read-only metadata", async () => { + for (const sql of [ + "PRAGMA foreign_keys=off", + "PRAGMA foreign_keys", + "PRAGMA case_sensitive_like", + "PRAGMA optimize", + "PRAGMA table_info(randomblob(1))", + ]) { + await expectValidationError( + sql, + "Only read-only PRAGMA statements are allowed", + "cloudflare_d1" + ); + } + }); + it("validates Cloudflare R2 SQL queries with the Athena dialect", async () => { await expectValidQuery( "SELECT * FROM default.transactions LIMIT 10", diff --git a/packages/server/src/services/data-source-query/validate-sql.ts b/packages/server/src/services/data-source-query/validate-sql.ts index d8499333..37489a10 100644 --- a/packages/server/src/services/data-source-query/validate-sql.ts +++ b/packages/server/src/services/data-source-query/validate-sql.ts @@ -16,6 +16,7 @@ const ERRORS = { cteSelectOnly: "CTEs must only contain SELECT statements", unsafeFunction: (name: string) => `Side-effecting SQL functions are not allowed: ${name}`, + unsafePragma: "Only read-only PRAGMA statements are allowed", locking: "SELECT locking clauses are not allowed", selectInto: "SELECT INTO is not allowed", } as const; @@ -232,11 +233,12 @@ type ValidationContext = { dialect: (typeof DIALECT_MAP)[DatabaseCredentialProviderType]; }; -type MetadataStatementKind = "describe" | "explain" | "show"; +type MetadataStatementKind = "describe" | "explain" | "pragma" | "show"; type ReadOnlyStatementKind = "query" | MetadataStatementKind; type ParsedQuery = { statement: Expression; statementKind: ReadOnlyStatementKind; + metadataStatementDefinition?: MetadataStatementDefinition; }; type MetadataStatementDefinition = { @@ -245,6 +247,7 @@ type MetadataStatementDefinition = { expressionKinds: readonly string[]; firstKeywords: readonly string[]; statementKind: MetadataStatementKind; + validateStatement?: (expr: Expression) => Violation | null; }; type Expression = PolyglotSdk.ast.Expression; @@ -275,6 +278,13 @@ type MethodCallData = ExpressionRecord & { method?: unknown; }; +type PragmaData = ExpressionRecord & { + args?: unknown[]; + name?: unknown; + use_assignment_syntax?: unknown; + value?: unknown; +}; + type IdentifierLike = { name?: unknown; }; @@ -310,9 +320,36 @@ const DEFAULT_READ_ONLY_METADATA_STATEMENTS = [ statementKind: "explain", }, ] as const satisfies readonly MetadataStatementDefinition[]; -const READ_ONLY_METADATA_STATEMENT_OVERRIDES = {} as Partial< +const CLOUDFLARE_D1_READ_ONLY_METADATA_STATEMENTS = [ + ...DEFAULT_READ_ONLY_METADATA_STATEMENTS, + { + allowOpaqueCommand: false, + displayName: "PRAGMA", + expressionKinds: ["pragma"], + firstKeywords: ["pragma"], + statementKind: "pragma", + validateStatement: findReadOnlySqlitePragmaViolation, + }, +] as const satisfies readonly MetadataStatementDefinition[]; +const READ_ONLY_METADATA_STATEMENT_OVERRIDES: Partial< Record ->; +> = { + cloudflare_d1: CLOUDFLARE_D1_READ_ONLY_METADATA_STATEMENTS, +}; + +const READ_ONLY_SQLITE_PRAGMA_NAMES = new Set([ + "foreign_key_check", + "foreign_key_list", + "index_info", + "index_list", + "index_xinfo", + "quick_check", + "table_info", + "table_list", + "table_xinfo", +]); + +const READ_ONLY_SQLITE_PRAGMA_ARG_KINDS = new Set(["column", "literal"]); function getSelectIntoError(dbType: DatabaseCredentialProviderType): Violation { return dbType === "mysql" ? OUTFILE_ERROR : ERRORS.selectInto; @@ -470,6 +507,7 @@ function extractParsedQuery( const metadataStatement = findReadOnlyMetadataStatement(statement, context); if (metadataStatement) { return Result.ok({ + metadataStatementDefinition: metadataStatement, statement, statementKind: metadataStatement.statementKind, }); @@ -486,6 +524,12 @@ function validateReadOnlyQuery( parsedQuery: ParsedQuery, context: ValidationContext ): ResultType { + const metadataStatementViolation = + findMetadataStatementViolation(parsedQuery); + if (metadataStatementViolation) { + return invalid("non_select_query", metadataStatementViolation); + } + const cteViolation = findCteViolation(parsedQuery.statement); if (cteViolation) { return invalid("cte_must_select", cteViolation); @@ -673,6 +717,56 @@ function readFirstKeyword(context: ValidationContext): string | null { return keyword.length > 0 ? keyword : null; } +function findMetadataStatementViolation( + parsedQuery: ParsedQuery +): Violation | null { + if (parsedQuery.statementKind === "query") { + return null; + } + + return ( + parsedQuery.metadataStatementDefinition?.validateStatement?.( + parsedQuery.statement + ) ?? null + ); +} + +function findReadOnlySqlitePragmaViolation(expr: Expression): Violation | null { + const pragma = getExpressionData(expr, "pragma"); + if (!pragma) { + return ERRORS.unsafePragma; + } + + const name = normalizeIdentifierName(pragma.name); + if (!name || !READ_ONLY_SQLITE_PRAGMA_NAMES.has(name)) { + return ERRORS.unsafePragma; + } + + if ( + pragma.use_assignment_syntax === true || + (pragma.value !== null && pragma.value !== undefined) + ) { + return ERRORS.unsafePragma; + } + + const args = pragma.args ?? []; + if ( + !Array.isArray(args) || + args.some((arg) => !isReadOnlySqlitePragmaArg(arg)) + ) { + return ERRORS.unsafePragma; + } + + return null; +} + +function isReadOnlySqlitePragmaArg(value: unknown): boolean { + return ( + isExpression(value) && + READ_ONLY_SQLITE_PRAGMA_ARG_KINDS.has(getExpressionKind(value)) + ); +} + function isCteQueryExpression(expr: Expression): boolean { const unwrapped = unwrapParenthesizedQuery(expr); return Boolean(unwrapped && isTopLevelQueryExpression(unwrapped));