mcpField
S
safe-sql-execution

safe-sql-execution

github.com/supabaseセキュリティ114.3k184 回インストール今日更新
リポジトリ

safe-sql-execution について

Supabase Studio の開発者向け SQL 実行セキュリティガイドです。出所を追跡する型で実行可能な断片と信頼できない入力を分け、任意の SQL はユーザーの明示操作後に実行します。

主な機能

  1. 01SQL 断片の出所追跡
  2. 02実行可能な断片と未信頼断片の分離
  3. 03識別子と値のサニタイズ
  4. 04任意 SQL の明示的な承認
  5. 05分析 SQL 用の独立した型

活用シーン

  1. 01URL パラメーターによるクエリ改変の防止
  2. 02エディターで作成した SQL の承認後実行
  3. 03BigQuery や ClickHouse 向け断片の構築

インストール

コマンドをコピーしてターミナルで実行します。

$npx skills add https://github.com/supabase/supabase --skill safe-sql-execution

インストール後、Claude Code で/safe-sql-executionを入力して読み込まれたか確認します。

詳細

安全な SQL 実行

Supabase Studio は、ユーザーのデータベースに対して SQL ステートメントを直接実行します。これは認証済みユーザー自身のデータベースであるため、私たちのセキュリティモデルはほとんどのフロントエンドアプリケーションとは異なります。ユーザーが自分で作成したことが証明されている限り、任意の SQL ステートメントを実行できるべきです。許可してはならないのは、URL パラメーターなどを通じて攻撃者が影響を与えられる SQL ステートメントの実行です。

セキュリティモデル

Supabase Studio における SQL 実行のセキュリティモデルは、「作成者の証明」という原則に基づいています。つまり、ユーザーが実行できるのは自分で明示的に作成した SQL ステートメントのみであり、外部入力の影響を受けるステートメントは実行できないようにするということです。

SQL フラグメントは、次の 3 種類に分類されます。

  1. アプリケーションコード内にハードコードされているものです。攻撃者が影響を与えられないため、安全に実行できます。pg-meta の safeSql ユーティリティを使ってマークできます。

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

    safeSql は、ブランド型 SafeSqlFragment の文字列を自動的に作成します。(以下の「出所の追跡」を参照してください。)

  2. 第三者が影響を与えられるものです。URL パラメーターや LLM の出力などを通じて、攻撃者が影響を与えられる SQL フラグメントです。これらは pg-meta の untrustedSql ユーティリティを使ってマークしてください。

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

    untrustedSql は、ブランド型 UntrustedSqlFragment の文字列を作成します。(以下の「出所の追跡」を参照してください。)

  3. ユーザーが作成したものです。たとえば、テキスト入力フィールドなどの UI 上でユーザー自身が作成した SQL フラグメントです。作成者がユーザーであるため、これらは安全に実行できるものとして扱ってください。

    ただし、第三者が作成したコードとユーザーが作成したコードが混在し、ユーザー作成のコードが汚染される場合があるという注意点があります(たとえば、サニタイズされていない 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:URL パラメーターや LLM の出力など、攻撃者が影響を与えられる SQL フラグメントを表します。

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)自動保存されるため、決して安全とみなしてはなりません。スニペットの型では、UntrustedSqlFragment である unchecked_sql プロパティを使用して、これを強制しています。

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 型として定義されています。SafeSqlFragment への昇格は、明示的なユーザー操作を必要とするイベントハンドラー内で acceptUntrustedSql を介して行う必要があります。

// 🛑 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 に昇格させます。この昇格はセキュリティ境界です。実行操作(実行ボタンのクリック、Cmd+Enter)または承認が必要なツール呼び出し(AI ノートブックツール)からのみ呼び出してください。レンダリング、useEffect、その他の自動処理経路からは呼び出さないでください。ノートブックの保存経路でも、書き込み可能なノートブック型がセーフブランドを必要とするため、セルを昇格させます。これは保存時の型付けであり、実行の承認ではありません。また、他の場所で昇格させる前例にもなりません。Postgres 側の acceptUntrustedSql にも同じルールが適用されます。

エンドポイントの選択、OTEL クエリビルダー、その他の Studio の配線は、clickhouse-logs-queries スキル(references/codebase-integration.md)に記載されています。

コメント