关于 safe-sql-execution
面向 Supabase Studio 开发者的 SQL 安全执行规范,借助来源标记和类型约束区分可信与不可信片段,并要求任意 SQL 在明确用户操作后才能执行。
主要功能
- 01跟踪 SQL 片段来源
- 02区分可执行与不可信片段
- 03提供标识符和值的清理工具
- 04要求显式操作确认任意 SQL
- 05为分析 SQL 使用独立类型标记
适用场景
- 01防止 URL 参数影响数据库查询
- 02安全执行用户编辑器中的 SQL
- 03为 BigQuery 或 ClickHouse 查询构造片段
安装
复制下面的命令,在终端中运行。
npx skills add https:/ / github.com/ supabase/ supabase --skill safe-sql-execution安装后在 Claude Code 中输入/safe-sql-execution确认已加载。
详细介绍
安全的 SQL 执行
Supabase Studio 会直接对用户的数据库执行 SQL 语句。 由于这是经过身份验证的用户自己的数据库,我们的安全模型与大多数前端应用不同:只要能证明 SQL 语句确实由用户本人编写,用户就应该能够执行任何 SQL 语句。我们不应允许执行可能受到攻击者影响的 SQL 语句,例如通过 URL 参数影响的语句。
安全模型
Supabase Studio 中 SQL 执行的安全模型基于“作者身份可验证”原则。这意味着,用户只能执行自己明确编写的 SQL 语句,而不能执行受到外部输入影响的语句。
SQL 片段分为三类:
-
在应用代码中硬编码。这些内容不会受到攻击者影响,因此可以安全执行。可以使用
pg-meta中的safeSql工具标记:import { safeSql } from '@supabase/pg-meta' const sql = safeSql` SELECT * FROM users WHERE id = 1 `safeSql会自动创建一个带有SafeSqlFragment品牌类型的字符串。(请参阅下文的来源追踪。) -
可受第三方影响。这些 SQL 片段可能受到攻击者影响,例如通过 URL 参数或 LLM 输出影响。应使用
pg-meta中的untrustedSql工具标记:import { untrustedSql } from '@supabase/pg-meta' const unsafeQuery = searchParams.get('query') const querySql = untrustedSql(unsafeQuery)untrustedSql会创建一个带有UntrustedSqlFragment品牌类型的字符串。(请参阅下文的来源追踪。) -
用户编写。这些 SQL 片段由用户本人在 UI 中编写,例如在文本输入字段中。由于作者是用户,因此应将其视为可安全执行的内容。
但是有一种例外情况:第三方代码可能与用户编写的代码混合,从而污染用户编写的代码(例如,输入字段预填了未经清理的 URL 参数)。来源追踪可以帮助我们跟踪这些情况。
例如,可以通过要求占位符和受控值的类型为
SafeSqlFragment,来实现安全的输入组件。在这种情况下,我们可以使用其 onChange 将用户输入提升为SafeSqlFragment类型,因为我们知道输入内容由用户编写。一个实现示例位于 @apps/studio/components/ui/SafeSqlInput.tsx:import { rawSql, type SafeSqlFragment } from '@supabase/pg-meta' import type { ChangeEvent, ComponentProps } from 'react' import { Input } from 'ui-patterns/DataInputs/Input' type InputProps = ComponentProps<typeof Input> export type SafeSqlInputProps = Omit< InputProps, 'placeholder' | 'value' | 'onChange' > & { placeholder?: SafeSqlFragment value: SafeSqlFragment onChange?: (event: ChangeEvent<HTMLInputElement>, value: SafeSqlFragment) => void } export const SafeSqlInput = ({ onChange, ...props }: SafeSqlInputProps) => ( <Input {...props} onChange={(event) => onChange?.(event, rawSql(event.target.value))} /> )这基本上是
pg-meta导出的 rawSql 的唯一有效用例,使用时应谨慎。
来源追踪
使用品牌类型追踪 SQL 片段的来源。这些类型由 pg-meta 导出:
SafeSqlFragment:表示可安全执行的 SQL 片段,因为它们要么在应用中硬编码,要么由用户本人编写。UntrustedSqlFragment:表示可能受攻击者影响的 SQL 片段,例如通过 URL 参数或大模型输出影响。
以下方式可以生成 SafeSqlFragment:
- 使用
pg-meta中的safeSql工具创建硬编码的 SQL 片段。 - 使用
pg-meta中的清理工具清理不可信输入,并将其提升为SafeSqlFragment:identliteralkeyword
- 使用安全的 SQL 操作工具:
joinSqlFragments(来自pg-meta)trimSafeSqlFragment(来自apps/studio/lib/sql.ts)
可以使用 untrustedSql() 从原始字符串生成 UntrustedSqlFragments。
此外,还有一个联合类型 DisplayableSqlFragment,表示可在 UI 中安全显示、但不一定可执行的 SQL 片段。它包括 SafeSqlFragment 和 UntrustedSqlFragment。
从用户数据库往返获取的 SQL 的安全性
直接从目录表派生的 SQL(例如函数定义、RLS 表达式等)被视为安全,并在从数据库查询时立即提升为安全类型。在大多数情况下,这发生在 apps/studio/data/*_/*.ts 文件中,具体是在执行 API 或数据库获取的工具函数里。
从数据库往返获取的 SQL 存在一个关键的安全例外:用户代码片段。它们绝不能被视为安全,因为它们既(a)可受外部影响,又(b)会自动保存。代码片段类型使用 unchecked_sql 属性(类型为 UntrustedSqlFragment)来强制执行这一点。
将 SQL 片段提升为 SafeSqlFragment 类型
对于不安全字符串或 UntrustedSqlFragment,我们该如何安全地将其提升为 SafeSqlFragment?
清理工具
当输入可清理时(例如,它是关系名称、列名称,或将作为字面值进行比较等),首选此方法。
pg-meta 库提供以下清理工具,可用于安全地将不可信输入提升为 SafeSqlFragment:
ident:用于清理表名、列名等标识符。literal:用于清理将在 SQL 语句中使用的字面值。keyword:用于清理 SQL 关键字。
acceptUntrustedSql
某些不可信 SQL 片段无法通过上述工具进行清理。例如,RLS 策略编辑器中的 USING 表达式就是任意 SQL 表达式。
在这些情况下,我们可以在_用户明确执行操作时_提升 SQL 片段。用户操作表明用户已看到 SQL,并同意执行它。例如,明确的用户操作可以是点击“运行”按钮。
提升操作通过 pg-meta 中的 acceptUntrustedSql 工具完成,
它接受一个 UntrustedSqlFragment 并返回一个 SafeSqlFragment。
此工具只能在事件处理程序中使用。绝不能在 useQuery 中、组件渲染主体中直接使用、在 useEffect 中使用,或在任何可能不经用户明确操作就自动运行的地方使用。
以下是安全的:
import { acceptUntrustedSql } from '@supabase/pg-meta'
function SafeComponent() {
const { mutate: execute } = useExecuteSqlMutation()
const handleRun = () => {
// ✅ GOOD: Safe because it is in an event handler which requires a user
// click
execute({ sql: acceptUntrustedSql(/* sql */) })
}
return (
<button onClick={handleRun}>Run</button>
)
}
以下是不安全的:
import { acceptUntrustedSql } from '@supabase/pg-meta'
function UnsafeComponent() {
const { data } = useQuery({
queryKey: ['execute-sql', sql],
queryFn: () => {
// 🛑 BAD: Unsafe because it is in a query which could auto-run without
// explicit user action
return execute({ sql: acceptUntrustedSql(/* sql */) })
},
})
}
类型保证
针对用户 Postgres 数据库运行的 SQL 会通过 executeSql 函数执行,该函数的 SQL 参数只接受 SafeSqlFragment 类型的参数。原始字符串或 UntrustedSqlFragment 会在编译时引发错误。
示例
硬编码 SQL
// ✅ GOOD: Automatically safe with `safeSql` utility
const selectStatement = safeSql`select 1`
含可清理插值的 SQL
// ✅ GOOD: `pg-meta` utilities sanitize the input
const tableName = ident(userInputTableName)
const searchString = literal(userInputSearchString)
const sqlStatement = safeSql`
SELECT *
FROM ${tableName}
WHERE search_column = ${searchString}
`
// 🛑 BAD: Passing raw strings will type error
const tableName = 'my_table'
const sqlStatement = safeSql`
SELECT *
FROM ${tableName}
`
来自用户输入的不可清理 SQL
// ✅ GOOD: SafeSqlInput only allows a value that is a SafeSqlFragment
import { SafeSqlInput } from '@apps/studio/components/ui/SafeSqlInput'
function MyComponent() {
const [sql, setSql] = useState<SafeSqlFragment>(safeSql``)
return (
<SafeSqlInput
placeholder={safeSql`Enter your SQL query here...`}
value={sql}
onChange={(event, value) => setSql(value)}
/>
)
}
// 🛑 BAD: This input mixes SafeSqlFragments and unsafe strings
function MyBadComponent() {
const [sql, setSql] = useState<SafeSqlFragment>(safeSql``)
return (
<Input
// 🛑 BAD: This is unsafe because the placeholder is a raw string
placeholder="Enter your SQL query here..."
value={sql}
onChange={(event) => setSql(event.target.value)}
/>
)
}
从数据库往返获取的 SQL(不包括代码片段内容)
// ✅ GOOD: SQL from the database is promoted to SafeSqlFragment at the point
// of fetching
// data/function-definitions.ts
function markFunctionDefinitionSafe(
functionDefinition: FunctionDefinition
): SafeFunctionDefinition {
return {
...functionDefinition,
definition: functionDefinition.definition as SafeSqlFragment,
}
}
// data/function-definitions.ts
function getFunctionDefinitions() {
return GET(`/function-definitions`).then((functionDefinitions) =>
functionDefinitions.map(markFunctionDefinitionSafe)
)
}
// 🛑 BAD: Strings are promoted to SafeSqlFragment in a utility function, where
// it is impossible to easily determine the safety of the input
// utils.ts
function markFunctionDefinitionSafe(
functionDefinition: FunctionDefinition
): SafeFunctionDefinition {
return {
...functionDefinition,
definition: functionDefinition.definition as SafeSqlFragment,
}
}
// Component.ts
function MyComponent() {
const { data: functionDefinitions } = useFunctionDefinitions()
const safeFunctionDefinitions = functionDefinitions.map(markFunctionDefinitionSafe)
}
片段内容始终不安全
片段会自动持久化到数据库,也可以通过可受外部影响的渠道(例如从 URL 参数预填)创建或修改。unchecked_sql 属性的类型为 UntrustedSqlFragment,以强制执行这一点——只能在需要用户明确操作的事件处理程序中,通过 acceptUntrustedSql 将其提升为 SafeSqlFragment。
// 🛑 BAD: Snippet content is executed automatically via useQuery, with no
// explicit user action confirming that the user has reviewed the SQL.
import { acceptUntrustedSql } from '@supabase/pg-meta'
function UnsafeSnippetPreview({ snippet }: { snippet: Snippet }) {
const { data } = useExecuteSqlQuery({
sql: acceptUntrustedSql(snippet.content.unchecked_sql),
})
return <Results data={data} />
}
// 🛑 BAD: Casting bypasses the type system entirely. The snippet's
// `unchecked_sql` is `UntrustedSqlFragment` for a reason — never cast it.
function UnsafeSnippetRunner({ snippet }: { snippet: Snippet }) {
const { mutate: execute } = useExecuteSqlMutation()
useEffect(() => {
execute({ sql: snippet.content.unchecked_sql as SafeSqlFragment })
}, [snippet])
}
// ✅ GOOD: Snippet content is only promoted to SafeSqlFragment inside an event
// handler, after the user clicks Run. The user has seen the SQL in the editor
// and explicitly chosen to execute it.
import { acceptUntrustedSql } from '@supabase/pg-meta'
function SnippetRunner({ snippet }: { snippet: Snippet }) {
const { mutate: execute } = useExecuteSqlMutation()
const handleRun = () => {
execute({ sql: acceptUntrustedSql(snippet.content.unchecked_sql) })
}
return (
<>
<SnippetEditor snippet={snippet} />
<button onClick={handleRun}>Run</button>
</>
)
}
分析 SQL(BigQuery / ClickHouse)
同一安全模型也适用于分析查询,这些查询通过
/platform/projects/{ref}/analytics/endpoints/logs.all{,.otel} 端点访问 BigQuery
或 ClickHouse。来自 URL 参数和 UI 输入的筛选键和值会拼接到针对项目日志执行的 SQL 中,因此同样存在注入风险。
分析 SQL 使用独立的 SafeLogSqlFragment 品牌
(apps/studio/data/logs/safe-analytics-sql.ts),并有意与 pg-meta 的 SafeSqlFragment 品牌完全区分。两种品牌保持分离,是因为转义语义不同——适用于 Postgres 的 E'…' 字符串、::jsonb 类型转换和双引号标识符,对 BigQuery 和/或 ClickHouse 并不安全,反之亦然。跨用两种品牌会悄无声息地生成不安全的 SQL。
网络边界是 apps/studio/data/logs/execute-analytics-sql.ts 中的 executeAnalyticsSql,与 pg-meta 的 executeSql 类似;它只接受 SafeLogSqlFragment。apps/studio/eslint.config.cjs 中的 eslint no-restricted-syntax 规则会阻止其他文件直接对 logs.all 端点调用 post()/get()。
使用 safe-analytics-sql.ts 中的辅助函数构建片段:
safeSql— 模板标签,仅接受SafeLogSqlFragment插值;普通字符串和 PostgresSafeSqlFragment会在编译时被拒绝。analyticsLiteral(value)— 清理字符串/数字/布尔字面量。quotedIdent(name)— 验证带点标识符,并使用反引号引用。keyword(value, allowed)— 根据允许列表解析值并返回对应片段(例如AND/OR);绝不返回原始输入。joinSqlFragments(fragments, separator)— 组合已有品牌的片段。
import { executeAnalyticsSql } from '@/data/logs/execute-analytics-sql'
import { analyticsLiteral, quotedIdent, safeSql } from '@/data/logs/safe-analytics-sql'
// ✅ GOOD: every interpolation is sanitized.
const sql = safeSql`
SELECT timestamp, event_message
FROM ${quotedIdent(table)}
WHERE id = ${analyticsLiteral(id)}
`
await executeAnalyticsSql({ projectRef, endpoint, sql, iso_timestamp_start, iso_timestamp_end })
// 🛑 BAD: raw string interpolation. This fails to type-check at the
// executeAnalyticsSql boundary because the result is `string`, not
// `SafeLogSqlFragment`.
const sql = `SELECT * FROM ${table} WHERE id = '${id}'`
await executeAnalyticsSql({ projectRef, endpoint, sql, iso_timestamp_start, iso_timestamp_end })
唯一不使用这些辅助函数构建 SQL 的路径是用户编写的编辑器文本:untrustedLogSql(text) 会将其标记为 UntrustedLogSqlFragment(可显示和存储,但绝不可执行),而 acceptUntrustedLogsSql 会将其提升为 SafeLogSqlFragment。这种提升属于安全边界——只能在运行操作时调用(点击 Run 按钮、按 Cmd+Enter),或在经过审批的工具调用(AI 笔记本工具)中调用。绝不能在渲染、useEffect 或任何自动执行路径中调用。笔记本持久化路径也会提升单元格,因为可写笔记本类型要求使用安全品牌;这是存储类型处理,不代表已获准执行,也不应作为在其他地方提升的先例。Postgres 侧的规则与 acceptUntrustedSql 相同。
端点选择、OTEL 查询构建器以及其余 Studio 接线代码位于 clickhouse-logs-queries 技能中
(references/codebase-integration.md)。