最初に結論を述べます。Supabase / PostgreSQL は「素のSQLインジェクション(SQLi)」が起きにくいスタックです。 PostgRESTが公開するREST APIも、supabase-js の構造化API(.eq()・.ilike() など)も、入力を文字列としてSQL文に埋め込むのではなく 値をパラメータとして渡す ため、入力がSQLの一部として解釈される余地がそもそも小さい。ですが——「起きにくい」は「起きない」ではありません。 安全な層の"外側"、すなわち 関数(RPC)の中の動的SQL・アプリ側の生SQL・検索フィルタ文字列の組み立て では、SQLiは今でも普通に起きます。
本記事は、Supabaseで「SQLiが起きにくい場所」と「それでも起きる場所」の地図を引き直し、攻撃がどう成立するか、そして パラメータ化・format() の %I/%L・quote_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 |
| アプリ側の生SQL | sql.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;
ここで 正直に線を引きます。%I(quote_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() の %s は sprintf 的な 無検査の文字列置換 です。識別子やキーワードに %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 の比較値) | $1 + USING | 最優先。連結を一切しない |
| リテラルとして埋め込む値 | %L / quote_literal | NULLは %L か quote_nullable |
| 識別子(テーブル名・列名) | %I(+許可リスト) | %I は構文のみ守る。妥当性は許可リスト |
| キーワード(ASC/DESC 等) | 許可リスト + 検証後に %s | 値にも識別子にもできない |
7. アプリ側の生SQL——プレースホルダか、タグ付きテンプレートで
関数の外、アプリのサーバーコードから直接SQLを投げる場合(postgres.js・node-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に組み込む仕組みづくりが必要であれば、お気軽にご相談ください。