mcpField
S
safe-sql-execution

safe-sql-execution

github.com/supabase安全114.3k184 次安装今天更新
代码仓库

关于 safe-sql-execution

面向 Supabase Studio 开发者的 SQL 安全执行规范,借助来源标记和类型约束区分可信与不可信片段,并要求任意 SQL 在明确用户操作后才能执行。

主要功能

  1. 01跟踪 SQL 片段来源
  2. 02区分可执行与不可信片段
  3. 03提供标识符和值的清理工具
  4. 04要求显式操作确认任意 SQL
  5. 05为分析 SQL 使用独立类型标记

适用场景

  1. 01防止 URL 参数影响数据库查询
  2. 02安全执行用户编辑器中的 SQL
  3. 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 片段分为三类:

  1. 在应用代码中硬编码。这些内容不会受到攻击者影响,因此可以安全执行。可以使用 pg-meta 中的 safeSql 工具标记:

    import { safeSql } from '@supabase/pg-meta'
    
    const sql = safeSql`
      SELECT *
      FROM users
      WHERE id = 1
    `
    

    safeSql 会自动创建一个带有 SafeSqlFragment 品牌类型的字符串。(请参阅下文的来源追踪。)

  2. 可受第三方影响。这些 SQL 片段可能受到攻击者影响,例如通过 URL 参数或 LLM 输出影响。应使用 pg-meta 中的 untrustedSql 工具标记:

    import { untrustedSql } from '@supabase/pg-meta'
    
    const unsafeQuery = searchParams.get('query')
    const querySql = untrustedSql(unsafeQuery)
    

    untrustedSql 会创建一个带有 UntrustedSqlFragment 品牌类型的字符串。(请参阅下文的来源追踪。)

  3. 用户编写。这些 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:
    • ident
    • literal
    • keyword
  • 使用安全的 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 插值;普通字符串和 Postgres SafeSqlFragment 会在编译时被拒绝。
  • 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)。

评论

支持 Markdown