メインコンテンツへスキップ
友田 陽大
アプリ層セキュリティ
Supabase
PostgreSQL
セキュリティ
TypeScript

Supabase / PostgreSQL のSQLインジェクション対策 — rpc・生SQL・関数内の動的SQLの罠

SupabaseはPostgRESTとパラメータ化クエリで素のSQLインジェクションが起きにくい。だが関数内の動的SQL(EXECUTEと文字列連結)・format()の%s誤用・生SQL・検索フィルタの組み立ては注入経路になる。EXECUTE USINGのパラメータ化、format()の%I/%L、quote_ident/literal、許可リスト検証で安全にする方法を、脆弱→修正の実SQL/TSで解説します。

公開日
読了時間
20分
著者
友田 陽大
シェア

最初に結論を述べます。Supabase / PostgreSQL は「素のSQLインジェクション(SQLi)」が起きにくいスタックです。 PostgRESTが公開するREST APIも、supabase-js の構造化API(.eq().ilike() など)も、入力を文字列としてSQL文に埋め込むのではなく 値をパラメータとして渡す ため、入力がSQLの一部として解釈される余地がそもそも小さい。ですが——「起きにくい」は「起きない」ではありません。 安全な層の"外側"、すなわち 関数(RPC)の中の動的SQL・アプリ側の生SQL・検索フィルタ文字列の組み立て では、SQLiは今でも普通に起きます。

本記事は、Supabaseで「SQLiが起きにくい場所」と「それでも起きる場所」の地図を引き直し、攻撃がどう成立するか、そして パラメータ化・format()%I/%Lquote_ident/quote_literal・許可リスト検証 でどう塞ぐかを、脆弱→修正の実SQL/TSで示します。SQLインジェクションは OWASP Top 10 の Injection として長年トップクラスに位置づけられる古典ですが、Supabaseという組み合わせには特有の"再導入経路"があります。注入を含むアプリ層セキュリティ全体の地図はNext.js × Supabase アプリケーションセキュリティ完全ガイドにあり、本記事はその中の「SQLi」に絞って深掘りするものです。


1. なぜSupabaseでは「素のSQLi」が起きにくいのか

最初に、安全な側を正確に理解します。ここを誤解すると、安全な所を過剰に疑い、危険な所を見落とします。

SupabaseのデータアクセスはPostgREST経由が基本で、supabase-js の構造化APIはフィルタの をクエリ文字列のパラメータとして送ります。サーバー側のPostgRESTは、それを準備済みステートメントの バインド変数 としてPostgreSQLに渡します。つまり、ユーザー入力は最後まで「データ」であって「SQL文」にはなりません。

// 安全:値は最後までパラメータ。SQL文として解釈されない
const q = new URL(req.url).searchParams.get("q") ?? "";
const { data } = await supabase
  .from("posts")
  .select("title, body")
  .eq("status", "published")
  .ilike("title", `%${q}%`); // q は「値」。区切り文字を仕込んでもSQLは壊れない

q'; drop table posts; -- を入れても、それは「そういう文字列を含むタイトルを探す」だけで、SQL文の構造には一切影響しません。構造化APIを「値渡し」で使っている限り、SQLiは構造的に起きない。 これがSupabaseの初期設定が安全寄りである理由です。

問題は、開発者(やAIエージェント)が この安全な層の外に出る瞬間 に生まれます。次節で、その境界を地図にします。


2. では、どこで起きるのか——4つの危険地帯

SQLiが再導入されるのは、決まって以下の4箇所です。共通点は「入力がSQL文の"文字列"として組み立てられる」こと。値ではなく文になった瞬間に、注入の扉が開きます。

危険地帯何が起きるか主因
関数内の動的SQL(EXECUTE)RPCの中で入力を連結してSQL文を組む文字列連結 / %s
アプリ側の生SQLsql.unsafe や生クエリに入力を連結エスケープなしの連結
検索フィルタの組み立て.or()/.filter() のフィルタ文字列を連結PostgRESTフィルタ injection
任意SQL実行関数の公開exec_sql(text) のような汎用関数を晒す注入そのものをAPI化

このうち最も深刻で、最も見落とされやすいのが1つめ——関数(RPC)の中の動的SQL です。rpc() の呼び出し自体は安全に見えるため、危険が"関数の中"に隠れます。まずここを徹底的に分解します。


3. 動的SQLの本質——「EXECUTEで実行する文字列」だけが危ない

PL/pgSQLでは、SQL文を文字列として組み立ててから EXECUTE で実行できます(動的SQL)。SQLiの議論で最も重要なのは、「変数を値として使うこと」と「変数をSQL文の文字列に連結すること」はまったくの別物 だという一点です。ここが混同の温床です。

-- (A) 危険:入力を「SQL本文の文字列」に連結してから EXECUTE する
--     keyword は SQL の一部として解釈される=古典的SQLi
execute 'select * from documents where title = ''' || keyword || '''';

-- (B) 安全:通常の文(EXECUTE なし)。keyword は「値」として束縛される
--     ここでの || は文字列“値”の連結であって、SQL本文の組み立てではない
select * from documents where title = keyword;                   -- 完全一致
select * from documents where title ilike '%' || keyword || '%'; -- 部分一致

(B) の || を見て「連結しているから危険」と早合点しないでください。(B) では keyword は実行時に評価される テキスト値 にすぎず、PostgreSQLはそれをパラメータとして扱います。SQL文の構造は固定です。注入が起きるのは、組み立てた文字列を EXECUTE に渡したとき(A)だけ。 言い換えれば——

静的に書ける処理を、不要に EXECUTE で動的化しないこと。 これが第一の防御です。動的SQLは、テーブル名や列名を実行時に変える等、本当に必要なときだけの道具です。

動的SQLが本当に必要なときの安全な書き方は第6節にまとめます。その前に、Supabase特有の"見えにくさ"——rpc() を見ます。


4. rpc() の落とし穴——引数渡しは安全、関数の中身が危ない

SupabaseはPostgreSQLの関数を rpc() でAPI公開できます。ここで多くの人が誤解します。rpc() の引数受け渡し自体は安全です。PostgRESTが引数をパラメータとしてバインドするため、引数がSQL文に化けることはありません。危険なのは 関数の本体が、その引数で動的SQLを組み立てるとき です。

-- 脆弱:検索語を文字列連結で動的SQLに埋め込む RPC
create or replace function search_documents(keyword text)
returns setof documents
language plpgsql
security definer                       -- 所有者権限で実行=RLSを越える(後述)
as $$
begin
  return query execute
    'select * from documents where title ilike ''%' || keyword || '%''';
end;
$$;
// アプリ側:rpc の呼び出しはパラメータ化される(ここは安全)
const q = new URL(req.url).searchParams.get("q") ?? ""; // ← 汚染入力(source)
const { data } = await supabase.rpc("search_documents", { keyword: q });
// だが keyword は関数の中で SQL本文に連結される=注入は“関数の中”で起きる

攻撃は古典そのものです。q に次を入れます。

%' union select * from auth_tokens --

組み立てられるSQLはこうなります。

select * from documents where title ilike '%%' union select * from auth_tokens --%'

union で別テーブルを連結し、-- で末尾を無効化。documents に完璧なRLSが張ってあっても無関係 です。理由は次節。


5. SECURITY DEFINER が被害を最大化する

第4節の関数は security definer でした。これは関数を 定義者(所有者)の権限で実行する 設定で、典型的には所有者がテーブル所有者=RLSを越えられるロールです(PostgreSQL: CREATE FUNCTION)。つまり——

  • security invoker(既定)の関数内SQLi → 呼び出したユーザーの権限の範囲で被害が止まる
  • security definer の関数内SQLi → 所有者権限でRLSを丸ごと飛び越え、全テーブルを読み書きされる

Supabase公式も、service_role 同様にRLSを越える経路の存在を明記しています(Supabase: Row Level Security)。動的SQLと SECURITY DEFINER の組み合わせは、SQLiの被害を「テナント内の一部」から「データベース全体」へ拡大する増幅器 です。だからこの2つが重なる関数は、最優先で監査すべき対象になります。

SECURITY DEFINER 自体の正しい使い方(search_path の固定によるスキーマ乗っ取り対策、最小権限の所有者設計など)は論点が多いため、SECURITY DEFINER関数とsearch_pathの安全設計に切り出しています。本記事では「定義者権限の関数で動的SQLを組むなら、入力は絶対に文字列連結しない」という原則だけ徹底します。


6. 安全パターン——動的SQLが必要なときの4つの道具

ここからが対策の本体です。動的SQLが避けられないとき、入力の種類(値 / 識別子 / キーワード)ごとに正しい道具が違います。混ぜると穴が開きます。

6-1. 値は EXECUTE … USING のパラメータに分離する

最も安全で、最も使うべき手段です。$1 プレースホルダと USING 句で値を渡すと、入力は文字列連結を一切経由せず、バインド変数として扱われます。

-- 安全:値は $1 として USING で渡す(SQL本文には混ざらない)
execute 'select * from documents where title ilike $1'
  using '%' || keyword || '%'
  into result;
-- '%' || keyword || '%' は“値”の連結。$1 にバインドされるだけで構造は不変

6-2. 識別子は format('%I')——ただし本命は「許可リスト」

$1値にしか使えません。テーブル名・列名などの 識別子 はパラメータ化できないため、format()%I(内部で quote_ident を呼ぶ)で囲みます。%I は識別子として安全に引用符付けし、構文の破壊を防ぎます。

-- 識別子は %I、値は $1。両者を絶対に混ぜない
execute format('select count(*) from %I where owner_id = $1', target_table)
  using auth.uid()
  into result_count;

ここで 正直に線を引きます%Iquote_ident)が防ぐのは「識別子から抜け出して別のSQLを差し込む」という 構文上の注入 だけです。「その識別子を見せてよいか」という業務的な判断はしません。 攻撃者が target_table に実在する別テーブル(例:auth_secrets)を指定すれば、%I は何も警告せずそれを引用符付けして通します。だから識別子の本命の防御は 許可リスト検証 です。

create or replace function list_orders(sort_col text, dir text)
returns setof public.orders
language plpgsql
security invoker                      -- まず DEFINER が本当に要るか問い直す
set search_path = ''                  -- スキーマ乗っ取り対策(詳細は別記事)
as $$
begin
  -- 識別子(列名)は許可リストで検証する。%I だけでは「実在する別の列」を防げない
  if sort_col not in ('created_at', 'total_amount', 'status') then
    raise exception 'invalid sort column: %', sort_col;
  end if;
  -- 並び順は“キーワード”。値にも識別子にもできない=許可リスト必須
  if upper(dir) not in ('ASC', 'DESC') then
    raise exception 'invalid sort direction: %', dir;
  end if;

  return query execute
    format('select * from public.orders order by %I %s', sort_col, dir);
end;
$$;

dir%s(無検査の文字列置換)で埋めているのに安全なのは、直前で許可リスト検証を通したから です。ASC/DESC のようなSQLキーワードは値にも識別子にもできず、許可リスト→検証済みの値だけを置換、という順序でしか安全にできません。%s を使ってよいのは、このように「すでに自分で正当性を保証した値」だけです。

6-3. リテラルは %L(または quote_literal/quote_nullable)

値をどうしても文字列に埋め込む必要がある場面では、%L(内部で quote_literal)を使います。%L は文字列リテラルとして安全に引用符付けし、NULL も正しく NULL リテラルに変換します。

-- 安全:%L が keyword を安全なリテラルにエスケープする
execute format('select * from documents where title ilike %L', '%' || keyword || '%');

quote_literal を素で使うときの罠も押さえます。quote_literal(NULL) は文字列の 'NULL' ではなく SQLのNULL を返すため、連結すると壊れたSQLになります。NULLを取りうる値には quote_nullable を使うか、上記のように format%L に任せるのが安全です。

6-4. %s は「識別子・キーワードに対しては」使わない

format()%ssprintf 的な 無検査の文字列置換 です。識別子やキーワードに %s を使うと、%I の保護を失い、識別子注入を許します。

-- 脆弱:識別子に %s。quote_ident を経ないので注入できる
execute format('select * from %s', user_supplied_table);

-- 修正:識別子は %I(+許可リスト)
execute format('select * from %I', user_supplied_table);

4つの道具を一枚に整理します。

入力の種類正しい道具補足
値(VALUES, WHERE の比較値)$1USING最優先。連結を一切しない
リテラルとして埋め込む値%L / quote_literalNULLは %Lquote_nullable
識別子(テーブル名・列名)%I(+許可リスト)%I は構文のみ守る。妥当性は許可リスト
キーワード(ASC/DESC 等)許可リスト + 検証後に %s値にも識別子にもできない

7. アプリ側の生SQL——プレースホルダか、タグ付きテンプレートで

関数の外、アプリのサーバーコードから直接SQLを投げる場合(postgres.jsnode-postgres など)も、原則は同じ「連結しない」です。

import postgres from "postgres";
const sql = postgres(process.env.DATABASE_URL!);

const name = new URL(req.url).searchParams.get("name") ?? ""; // ← 汚染入力(source)

// 脆弱:sql.unsafe に文字列連結(生SQLのインジェクション)
const bad = await sql.unsafe(`select * from users where name = '${name}'`);

// 修正:タグ付きテンプレートが ${} を $1 プレースホルダに変換する(連結しない)
const good = await sql`select * from users where name = ${name}`;
// node-postgres:プレースホルダ+値配列。文字列連結は絶対にしない
await client.query("select * from users where id = $1 and org_id = $2", [id, orgId]);

ライブラリのタグ付きテンプレート(sql`...${value}...`)は、${value} を自動でバインド変数に変換するため安全です。危険なのは、その安全装置を 明示的に外す sql.unsafe(...) に入力を連結したときだけ。unsafe という名前は伊達ではありません。

そして、最悪の反パターンが「任意SQLを実行する汎用関数」の公開です。

-- 絶対NG:任意SQLを実行する汎用関数。rpc 経由で誰でも叩ける“注入のAPI化”
create function exec_sql(query text) returns void
language plpgsql security definer as $$
begin execute query; end;
$$;
-- supabase.rpc('exec_sql', { query: '...' }) で任意SQLが通る。存在自体が脆弱性

これは「注入の脆弱性」というより「注入をそのままAPIにした」もので、SECURITY DEFINER なら一撃でDB全体が陥落します。便利だからと作ってはいけません。


8. 検索フィルタの組み立て——.or() のPostgRESTフィルタ injection

最後の危険地帯は、supabase-js.or()/.filter()生のフィルタ文字列 を渡すケースです。

const q = new URL(req.url).searchParams.get("q") ?? "";

// 脆弱:検索語を .or() のフィルタ文字列に連結(PostgRESTフィルタ injection)
await supabase.from("posts").select("*").or(`title.ilike.*${q}*,body.ilike.*${q}*`);
// q に "," や "*"、")" を仕込むとフィルタ文法を壊し、別の条件を足して別の行を引ける

// 修正:構造化APIで「値」として渡す(フィルタ文字列を組み立てない)
await supabase.from("posts").select("title, body").ilike("title", `*${q}*`);

正直に区別します。 これは厳密にはPostgreSQLレベルの生SQLiではありません。PostgRESTは最終的なSQLをパラメータ化するため、drop table のようなSQL文を直接注入することはできない。しかし .or() のフィルタ 文法 を壊して条件を改ざんできるため、「どの行が返るか」を攻撃者が操作できます。RLSが弱いテーブルと組み合わさると、認可の前提が崩れます。論理的な注入として、れっきとした注入クラスの一員です。構造化API(.eq.ilike 等)に値を渡せば、フィルタ文字列を組み立てる必要自体がなくなります。


9. taint解析——汚染入力からSQLシンクへの到達を追う

ここまでの危険地帯は、見た目はバラバラですが構造は同じです。クライアントが操作できる入力(source)が、検証されないままSQL文を組み立てる処理(sink)に到達する。 だから正規表現ではなく、データフロー解析(taint解析)で機械的に追えます。

汚染入力(source)危険シンク(sink)なぜ危ないか
rpc の引数 / searchParams / リクエストボディ関数内の EXECUTE +文字列連結入力がSQL本文に混ざる(古典的SQLi)
同上format の %s(識別子・キーワード)無検査の置換=識別子注入
同上sql.unsafe / 生クエリの文字列連結アプリ層の生SQLインジェクション
同上.or() / .filter() のフィルタ文字列PostgRESTフィルタ injection(論理的注入)

source→sink の経路上に 妥当なサニタイザ$1/USING%I+許可リスト、%L、構造化API)があるかを見れば、危険な流れを自動で拾えます。SSRFやパストラバーサルも「source→sink」という同じ骨格で、入力をURLやパスのシンクに通すか否かで判定できます(SSRF対策ガイドでは同じ構造を別シンクで扱っています)。

ここで正直なスコープを引きます。 taint解析が機械化できるのは「汚染入力がサニタイザを経ずにSQLシンクへ到達したか」までです。サニタイザが"妥当か"——許可リストが正しい集合を網羅しているか、%I で囲った識別子の選択が業務的に妥当か——はツールには判定できません。 データフロー解析は関数内(intraprocedural)が基本で、関数が別の関数を呼ぶ流れや、文字列を組み立てて別の場所へ渡す流れは見逃します。検出は「よくある罠を踏んでいないこと」を示すだけで、「安全であること」を証明はしません。安全なクエリ設計は、最後は人間のレビューが要る——これが本記事の一貫した立場です。


10. 検出——npx で source→SQL sink を可視化する

設計で塞ぐと決めたら、「塞げているか」を継続的に検証します。私が公開しているOSS Aegis は、上のtaint解析(汚染入力→SQLシンク)に加え、supabase/migrations/**.sql を読んで 危険な動的SQLのパターンEXECUTE への文字列連結、format%s 識別子、SECURITY DEFINER +動的SQL、exec_sql 的な汎用関数)を洗い出します。インストール不要で走ります。

# インストール不要・設定不要。汚染入力→SQLシンクの到達と、
# migrations 内の危険な動的SQL(EXECUTE連結・format %s・DEFINER)を可視化
npx @aegiskit/cli scan

動的な確認も併用すると確度が上がります。SQLiの再現可否は、自分が所有する環境 に対して入力(区切り文字・union・コメント -- 等)を与え、エラーや想定外の行が返るかで確かめます。テスト観点はOWASPのテスティングガイドが体系的です(OWASP Web Security Testing Guide)。静的解析の「疑い」と動的の「再現」が一致したものを最優先で直す、という相関が効きます。


11. 本番前チェックリスト

外注でもAI製でも、本番投入の前に最低限これだけは確認してください。

  • 関数内の動的SQLで、値は EXECUTE … USING $1 に分離している(SQL本文へ連結しない)
  • 識別子は format%I で囲み、かつ 許可リストで検証している
  • リテラルは %L(または quote_literal/quote_nullable)。素の連結をしていない
  • format%s を識別子・キーワードに使っていない(使うなら許可リスト検証の後だけ)
  • 静的に書ける関数を、不要に EXECUTE で動的化していない
  • SECURITY DEFINER は本当に必要な関数だけ。search_path を固定している
  • アプリ側の生SQLは プレースホルダ($1)かタグ付きテンプレートsql.unsafe に入力を連結していない
  • 任意SQLを実行する汎用関数(exec_sql 等)を公開していない
  • 検索は .eq/.ilike など構造化APIで値渡し。.or() にユーザー入力を連結していない
  • 汚染入力→SQLシンクの静的解析をCIに常設している

発注者の視点で効く質問は3つです。「動的SQL(EXECUTE)を使っている関数はどれですか?」「SECURITY DEFINER の関数で入力をどう扱っていますか?」「検索はどう組み立てていますか?」——良い開発者は即答できます。


12. どこまで自分で、どこから監査か

最後に、正直に線を引きます。

汚染入力→SQLシンクの到達検出は、自動化で機械的に潰せます。 EXECUTE への連結、%s の識別子置換、sql.unsafe への連結、.or() への入力連結——これらは構造が定型的なので、静的解析をCIに入れれば人間が毎回見張る必要はありません。まずは Aegis(無料OSS、npx @aegiskit/cli scan)で現状を可視化するのが、最もコスパの良い第一歩です。

一方、「サニタイザが妥当か」「動的SQLの設計が正しいか」は人間の領域です。許可リストが正しい集合か、SECURITY DEFINER を使う必然性があるか、その関数がRLSを越えてよい範囲はどこまでか——これらは、あなたのデータモデルと業務ルールを理解した人間にしか判断できません。「このツールを入れればSQLiは完全に安全」と言い切る製品は、むしろ危険です。 ツールは検出を助けますが、クエリ設計の正しさは証明しません。

だからこそ線引きが要ります。動的SQLを含む関数の設計修正や、既存のSupabaseアプリのRPC・RLS・認可レビューが必要なら、セキュリティ監査で承ります。私自身、木材流通業界のDX案件で、221本のAPIとPostgreSQLの関数・RLSを含むデータ層の認可を実運用で設計・検証してきました。AIで速く作ること自体は正しい。速く作ったものを、漏らさず安全に固める——その仕組みづくりが本筋です。


よくある質問(FAQ)

Q. supabase-js の .eq().ilike() を使っていればSQLiは起きませんか? A. その経路では起きません。構造化APIに を渡している限り、入力はパラメータとして扱われ、SQL文の構造に影響しません。起きるのは、.or() にフィルタ文字列を連結する、RPCの中で動的SQLを組む、sql.unsafe を使う、といった「安全な層の外」に出たときだけです。

Q. rpc() を使うとSQLiの危険が増えますか? A. rpc() の呼び出し(引数渡し)自体は安全で、引数はパラメータとしてバインドされます。危険なのは 関数の本体 が、その引数を文字列連結で動的SQLに埋め込む場合です。危険は「rpc を使うこと」ではなく「関数の中身」にあります。

Q. format() を使えば安全ですか? A. プレースホルダ次第です。値は %L、識別子は %I なら安全側ですが、%s は無検査の文字列置換なので識別子・キーワードに使うと注入を許します。さらに %I は構文の破壊を防ぐだけで「その識別子を見せてよいか」は判定しません。識別子は %I許可リスト の二段で守ってください。

Q. RLSを張っていればSQLiは問題になりませんか? A. なりません。SECURITY DEFINER 関数の中のSQLiは RLSを丸ごと飛び越え ます。逆に SECURITY INVOKER でも、注入で意図しない行を引けば認可の前提は崩れます。RLSは必須ですが、SQLi対策(パラメータ化・許可リスト)は別レイヤーとして両方必要です。

Q. AIに「セキュアに書いて」と頼めば動的SQLは安全になりますか? A. 期待しすぎないでください。AIは「動くRPC」を最短で書きますが、EXECUTE への連結や %s の誤用はデモでは顕在化しません。検証ゲート(静的解析・テスト・レビュー)を通して初めて本番品質になります。


まとめ:安全な層の"外"に出る瞬間を見張る

要点を整理します。

  • Supabaseで「素のSQLi」が起きにくいのは、PostgRESTと構造化APIが 値をパラメータとして渡す から。だが 安全な層の外側——関数内の動的SQL・生SQL・検索フィルタの組み立て——では普通に起きる。
  • 最大の罠は SECURITY DEFINER 関数の中の動的SQL(EXECUTE +連結)。汚染入力がSQL本文に混ざると、所有者権限でRLSを越えて全テーブルを抜かれる。rpc() の引数渡し自体は安全でも、関数の中身が注入を再導入する。
  • 安全パターンは入力の種類で道具を分ける——値は $1/USING、リテラルは %L、識別子は %I +許可リスト、キーワードは許可リスト後に %s。そして 静的に書ける処理を不要に動的化しない
  • 汚染入力→SQLシンクの到達は taint解析で機械的に検出できる。だが サニタイザの妥当性とクエリ設計の正しさは、人間のレビューでしか担保できない。「入れれば完全に安全」な製品は存在しない。

既存のSupabaseアプリのRPC・動的SQL・RLSのレビューや、注入検出をCIに組み込む仕組みづくりが必要であれば、お気軽にご相談ください。


参考資料

友田

友田 陽大

経済産業大臣賞 受賞プロダクト開発者。TypeScript + Python + AWS で、SaaS・業界DX・ 実用レベルの生成AI(RAG)を、要件定義からインフラ・運用まで一人で完遂します。

この記事の対策、ツールで自動化できます

Next.js / Supabase のセキュリティ統制を、OSS の Aegis で自動化

この記事の対策の多くは、ミドルウェア1枚と静的解析で機械的に検出・強化できます。無料・MIT の Aegis なら、いまのプロジェクトを1コマンドからスキャンできます。設計が要る「縦のリスク」は監査でも承ります。

プロジェクト単位(請負)・技術顧問のどちらにも対応可能です。まずは30分の無料技術相談から。