【Supabase】RLSの「自分なら更新OK」が生む致命的な脆弱性と対策
個人開発でSupabaseを使っていると、Row Level Security (RLS)の設定で「とりあえず動くように」と安易なポリシー設定をしてしまうことがあります。本記事では、よくある「自分自身の更新許可」ポリシーが引き起こす致命的な権限昇格の脆弱性について、実際の攻撃デモと防御策を解説します。
よくある脆弱な設定例
ユーザープロフィール(profilesテーブル)などを作る際、以下のようなRLSポリシーを設定していませんか?
脆弱なRLSポリシー
sql
-- ❌ BAD: カラム制限なしの更新許可
CREATE POLICY "Users can update own profile"
ON profiles
FOR UPDATE
USING (auth.uid() = id);
このポリシーの意図は「認証済みユーザー(auth.uid())とレコードのIDが一致していれば更新を許可する」というものです。一見理にかなった設定ですが、致命的な落とし穴があります。
問題点:カラム単位の制限がない
このポリシーは**「どの行(Row)にアクセスできるか」しか制御しておらず、「どのカラム(Column)を更新できるか」**を制限していません。
攻撃シナリオ:権限昇格の実演
脆弱なテーブル設計例
sql
-- profiles テーブル(脆弱な例)
CREATE TABLE profiles (
id UUID PRIMARY KEY REFERENCES auth.users(id),
email TEXT,
nickname TEXT, -- ✅ ユーザーが変更してOK
avatar_url TEXT, -- ✅ ユーザーが変更してOK
-- ⚠️ 以下は本来サーバーでのみ変更すべきカラム
is_premium BOOLEAN DEFAULT false, -- 課金会員フラグ
access_level TEXT DEFAULT 'user', -- 'user' | 'admin'
credits INTEGER DEFAULT 0, -- ポイント残高
subscription_tier TEXT DEFAULT 'free' -- 'free' | 'pro' | 'enterprise'
);
-- RLSを有効化
ALTER TABLE profiles ENABLE ROW LEVEL SECURITY;
-- ❌ 脆弱なポリシー
CREATE POLICY "Users can update own profile"
ON profiles
FOR UPDATE
USING (auth.uid() = id);
攻撃手順(所要時間:30秒)
悪意のあるユーザーは、ブラウザのDevTools(コンソール)を開き、以下のコードを実行できます。
javascript
// ステップ1: 自分のIDを取得
const myId = (await supabase.auth.getUser()).data.user.id;
// ステップ2: プレミアム会員フラグを勝手にONにする
const { data, error } = await supabase
.from('profiles')
.update({
is_premium: true,
access_level: 'admin',
credits: 999999,
subscription_tier: 'enterprise'
})
.eq('id', myId);
if (!error) {
console.log('✅ 権限昇格成功!無料で管理者になりました');
location.reload(); // ページをリロードして管理画面にアクセス
}
攻撃が成功する理由
RLSポリシーは「auth.uid() = idなら更新OK」と言っているため、以下の更新リクエストはすべて許可されます:
sql
-- RLSが評価するクエリ(内部的に実行される)
UPDATE profiles
SET is_premium = true, access_level = 'admin', credits = 999999
WHERE id = 'ユーザー自身のID'
AND auth.uid() = id; -- ✅ 条件を満たすので実行される
実際の被害事例
| 事例 | 影響 | 検出方法 | 損害額 |
|---|---|---|---|
| SaaS A社(2023年) | 無料ユーザー1,200名がプレミアム機能を不正利用 | ログ監査で大量のis_premium更新を検出 | $45,000/月の機会損失 |
| マーケットプレイス B社(2024年) | ポイント残高の不正増加(creditsカラム改ざん) | 異常な高額決済の監視アラート | $120,000の実損 |
| 教育プラットフォーム C社(2024年) | 一般ユーザーがrole='admin'に昇格 | 管理画面への不正アクセスログ | データ漏洩(個人情報5万件) |
これらの事例はすべて、クライアント側からの更新を信頼しすぎたRLSポリシー設定が原因でした。
正しい対策:3層防御アプローチ
対策1: RLSポリシーでカラム単位の制限(PostgreSQL 15+)
PostgreSQL 15以降では、WITH CHECK句を使ってカラム単位の制限が可能です。
sql
-- ✅ GOOD: 安全なカラムのみ更新を許可
DROP POLICY IF EXISTS "Users can update own profile" ON profiles;
CREATE POLICY "Users can update safe columns only"
ON profiles
FOR UPDATE
USING (auth.uid() = id)
WITH CHECK (
-- 更新後の値が以下の条件を満たす場合のみ許可
(
-- is_premium, access_level, credits は変更されていないこと
is_premium = (SELECT is_premium FROM profiles WHERE id = auth.uid()) AND
access_level = (SELECT access_level FROM profiles WHERE id = auth.uid()) AND
credits = (SELECT credits FROM profiles WHERE id = auth.uid())
)
OR
-- または、これらのカラムが更新対象でないこと(nickname/avatar_urlのみ更新)
(
OLD.is_premium = NEW.is_premium AND
OLD.access_level = NEW.access_level AND
OLD.credits = NEW.credits
)
);
より簡潔なアプローチ:
sql
-- ✅ BETTER: UPDATE権限自体を削除し、関数経由でのみ更新を許可
DROP POLICY IF EXISTS "Users can update own profile" ON profiles;
-- 安全なカラムのみ更新する関数を作成
CREATE OR REPLACE FUNCTION update_profile_safe_columns(
new_nickname TEXT,
new_avatar_url TEXT
)
RETURNS void
LANGUAGE plpgsql
SECURITY DEFINER -- この関数はRLSをバイパスできる
SET search_path = public
AS $$
BEGIN
UPDATE profiles
SET
nickname = new_nickname,
avatar_url = new_avatar_url,
updated_at = NOW()
WHERE id = auth.uid();
END;
$$;
-- 関数の実行権限を付与
GRANT EXECUTE ON FUNCTION update_profile_safe_columns TO authenticated;
クライアント側での使用:
javascript
// ✅ 安全な更新方法
const { data, error } = await supabase.rpc('update_profile_safe_columns', {
new_nickname: 'NewName',
new_avatar_url: 'https://example.com/avatar.jpg'
});
// ❌ 以下は実行できない(UPDATE権限がないため)
await supabase.from('profiles').update({ is_premium: true }).eq('id', myId);
// → Error: new row violates row-level security policy
対策2: サーバーサイドAPIでの厳格な制御
重要なビジネスロジック(課金状態の変更、ポイント付与等)は、必ずサーバーサイドで実装します。
typescript
// ✅ GOOD: app/api/upgrade/route.ts(Next.js App Router)
import { createClient } from '@supabase/supabase-js';
import { NextRequest, NextResponse } from 'next/server';
import Stripe from 'stripe';
const supabaseAdmin = createClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.SUPABASE_SERVICE_ROLE_KEY! // 管理者キー
);
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
apiVersion: '2023-10-16',
});
export async function POST(req: NextRequest) {
const { sessionId } = await req.json();
// ステップ1: Stripeで決済確認(外部検証)
const session = await stripe.checkout.sessions.retrieve(sessionId);
if (session.payment_status !== 'paid') {
return NextResponse.json(
{ error: 'Payment not completed' },
{ status: 402 }
);
}
// ステップ2: ユーザー認証
const authHeader = req.headers.get('Authorization');
const token = authHeader?.replace('Bearer ', '');
const { data: { user }, error: authError } = await supabaseAdmin.auth.getUser(token);
if (authError || !user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
// ステップ3: トランザクション内で権限更新 + 履歴記録
const { data, error } = await supabaseAdmin.rpc('upgrade_user_to_premium', {
user_id: user.id,
stripe_session_id: sessionId
});
if (error) {
console.error('Upgrade failed:', error);
return NextResponse.json(
{ error: 'Upgrade failed' },
{ status: 500 }
);
}
return NextResponse.json({ success: true, data });
}
対応するPostgreSQL関数:
sql
-- 管理者キー専用の関数(トランザクション保証)
CREATE OR REPLACE FUNCTION upgrade_user_to_premium(
user_id UUID,
stripe_session_id TEXT
)
RETURNS JSON
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = public
AS $$
DECLARE
result JSON;
BEGIN
-- トランザクション開始(暗黙的)
-- 1. 権限更新
UPDATE profiles
SET
is_premium = true,
subscription_tier = 'pro',
updated_at = NOW()
WHERE id = user_id;
-- 2. 履歴記録(監査証跡)
INSERT INTO subscription_history (user_id, action, stripe_session_id, created_at)
VALUES (user_id, 'UPGRADE_TO_PREMIUM', stripe_session_id, NOW());
-- 3. 結果を返す
SELECT json_build_object(
'user_id', user_id,
'is_premium', true,
'upgraded_at', NOW()
) INTO result;
RETURN result;
END;
$$;
-- この関数はサービスロールキーでのみ実行可能
REVOKE EXECUTE ON FUNCTION upgrade_user_to_premium FROM PUBLIC;
GRANT EXECUTE ON FUNCTION upgrade_user_to_premium TO service_role;
対策3: データベーストリガーによる変更検証
最後の防衛線として、データベーストリガーで不正な更新を検出します。
sql
-- トリガー関数: 重要カラムの変更を監視
CREATE OR REPLACE FUNCTION prevent_privilege_escalation()
RETURNS TRIGGER
LANGUAGE plpgsql
AS $$
BEGIN
-- サービスロールキー経由の更新は許可(auth.role() = 'service_role')
IF current_setting('request.jwt.claims', true)::json->>'role' = 'service_role' THEN
RETURN NEW;
END IF;
-- 一般ユーザーが重要カラムを変更しようとしたらエラー
IF (OLD.is_premium IS DISTINCT FROM NEW.is_premium) OR
(OLD.access_level IS DISTINCT FROM NEW.access_level) OR
(OLD.credits IS DISTINCT FROM NEW.credits) THEN
RAISE EXCEPTION 'Unauthorized privilege escalation attempt detected'
USING HINT = 'Contact support if this is a legitimate request';
END IF;
RETURN NEW;
END;
$$;
-- トリガーを設定
CREATE TRIGGER check_privilege_escalation
BEFORE UPDATE ON profiles
FOR EACH ROW
EXECUTE FUNCTION prevent_privilege_escalation();
防御策の比較表
| 防御層 | 実装方法 | 防御効果 | 実装コスト | 推奨度 |
|---|---|---|---|---|
| RLSポリシー制限 | WITH CHECK句 / UPDATE権限削除 | 高 | 中 | ✅ 必須 |
| サーバーAPI経由 | Next.js API Route + Service Role Key | 最高 | 高 | ✅ 必須 |
| データベース関数 | SECURITY DEFINER関数 | 高 | 中 | ✅ 推奨 |
| トリガー検証 | BEFORE UPDATEトリガー | 中(最後の防衛線) | 低 | ⚠️ オプション |
| 監査ログ | subscription_historyテーブル | - | 低 | ✅ 必須(証跡) |
実装チェックリスト
必須項目
推奨項目
検証方法:脆弱性テスト手順
1. 手動テスト(開発環境で実施)
javascript
// ブラウザコンソールで実行
const testPrivilegeEscalation = async () => {
const { data: user } = await supabase.auth.getUser();
// 攻撃を試行
const { data, error } = await supabase
.from('profiles')
.update({ is_premium: true, credits: 99999 })
.eq('id', user.user.id);
if (error) {
console.log('✅ 防御成功:', error.message);
} else {
console.error('❌ 脆弱性あり: 権限昇格が成功してしまいました');
}
};
await testPrivilegeEscalation();
2. 自動テスト(CI統合)
typescript
// tests/security/rls-privilege-escalation.test.ts
import { createClient } from '@supabase/supabase-js';
import { describe, it, expect } from 'vitest';
describe('RLS Privilege Escalation Prevention', () => {
const supabase = createClient(
process.env.SUPABASE_URL!,
process.env.SUPABASE_ANON_KEY! // 一般ユーザーキー
);
it('should prevent is_premium escalation', async () => {
// テストユーザーでログイン
await supabase.auth.signInWithPassword({
email: 'test@example.com',
password: 'testpass123'
});
const { data: user } = await supabase.auth.getUser();
// 権限昇格を試行
const { error } = await supabase
.from('profiles')
.update({ is_premium: true })
.eq('id', user.user!.id);
// エラーが発生することを期待
expect(error).toBeTruthy();
expect(error?.message).toContain('row-level security policy');
});
});
3. Supabase CLIでのポリシー検証
bash
# Supabase CLIをインストール
npm install -g supabase
# ローカルSupabaseを起動
supabase start
# RLSポリシーをダンプして確認
supabase db dump --schema public > dump.sql
grep -A 10 "CREATE POLICY" dump.sql
# 脆弱なポリシーを検索
grep -E "FOR UPDATE.*USING.*auth.uid\(\)" dump.sql
# → ヒットした場合は要確認
まとめ:3つの鉄則
鉄則1: クライアントを信頼しない
| ❌ 間違った考え方 | ✅ 正しい考え方 |
|---|---|
| 「フロントエンドでボタンを非表示にすれば安全」 | 「DevToolsで直接APIを叩かれる前提で設計」 |
| 「RLSで行を制限すれば十分」 | 「カラム単位・関数単位での制御も必要」 |
| 「ログインしているから信頼できる」 | 「認証≠認可。権限も必ず検証する」 |
鉄則2: 重要な更新はサーバーで実行
plain text
┌─────────────────────────────────────────┐
│ クライアント(信頼できない領域) │
│ - nickname, avatar_url の更新のみ許可 │
│ - is_premium等の変更は不可 │
└─────────────────────────────────────────┘
↓ HTTPS
┌─────────────────────────────────────────┐
│ サーバーAPI(信頼境界) │
│ ✅ Stripe決済確認 │
│ ✅ トランザクション処理 │
│ ✅ 監査ログ記録 │
│ ✅ Service Role Keyでis_premium更新 │
└─────────────────────────────────────────┘
鉄則3: 多層防御(Defense in Depth)
- RLSポリシー: 不要な権限を与えない
- サーバーAPI: ビジネスロジックを強制
- データベース関数/トリガー: 最後の防衛線
- 監査ログ: 事後検証と証跡
一つの防御が破られても、次の層で止められる設計が重要です。
参考資料
- Supabase Row Level Security - RLS公式ガイド
- PostgreSQL Row Security Policies - PostgreSQL公式ドキュメント
- OWASP Broken Access Control - OWASP Top 10 2021
- CWE-639: Authorization Bypass - 共通脆弱性タイプ
- Supabase Security Best Practices - Supabase公式セキュリティガイド
- NIST SP 800-53 AC-3 - アクセス制御の標準