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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
100 changes: 97 additions & 3 deletions packages/server/src/services/data-source-query/validate-sql.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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 = {
Expand All @@ -245,6 +247,7 @@ type MetadataStatementDefinition = {
expressionKinds: readonly string[];
firstKeywords: readonly string[];
statementKind: MetadataStatementKind;
validateStatement?: (expr: Expression) => Violation | null;
};

type Expression = PolyglotSdk.ast.Expression;
Expand Down Expand Up @@ -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;
};
Expand Down Expand Up @@ -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<DatabaseCredentialProviderType, readonly MetadataStatementDefinition[]>
>;
> = {
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;
Expand Down Expand Up @@ -470,6 +507,7 @@ function extractParsedQuery(
const metadataStatement = findReadOnlyMetadataStatement(statement, context);
if (metadataStatement) {
return Result.ok({
metadataStatementDefinition: metadataStatement,
statement,
statementKind: metadataStatement.statementKind,
});
Expand All @@ -486,6 +524,12 @@ function validateReadOnlyQuery(
parsedQuery: ParsedQuery,
context: ValidationContext
): ResultType<null, SqlValidationError> {
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);
Expand Down Expand Up @@ -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<PragmaData>(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));
Expand Down
Loading