【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)

  1. RLSポリシー: 不要な権限を与えない
  2. サーバーAPI: ビジネスロジックを強制
  3. データベース関数/トリガー: 最後の防衛線
  4. 監査ログ: 事後検証と証跡

一つの防御が破られても、次の層で止められる設計が重要です。


参考資料