# ARTEX型AI攻撃にWebアプリはどう備えるべきか？Next.js・API・Supabaseの認可設計を、実際に動かして検証する

> AI自動攻撃で変わるのは速度と敷居で、守る境界は同じ。架空のB2B SaaSで、サーバー側認可とPostgreSQL RLSを29項目で実際に検証し、SECURITY DEFINERと存在オラクルの落とし穴、列挙の検知までを全コード付きで示します。

- 公開日: 2026-11-03
- 著者: 友田 陽大
- タグ: セキュリティ, 認証・認可, Supabase, RLS, PostgreSQL, OWASP, 脆弱性診断, Next.js
- URL: https://tomodahinata.com/blog/artex-agentic-ai-web-application-security-defense
- カテゴリ: アプリ層セキュリティ
- 総合ガイド: https://tomodahinata.com/blog/nextjs-supabase-application-security-guide

## 要点

- AI型の攻撃が変えたのは、試行の速さと並列数、そして導入の敷居。Googleが挙げる防御は長年の基本だが、攻撃のスケールを前提に見直す必要があるとも述べる
- 最も検証しやすい境界は、認証（誰か）と認可（そのデータに触れてよいか）の分離。WAFは、正当な形のリクエストの中身の権限を判断できない
- 架空のB2B SaaSで、アプリ層の認可（8テスト）とPostgreSQLのRLS（29項目）を実際に動かして確認した。ラボのSQLとテストは全文を載せる（検知クエリの合成データと、一部の変形は省略）
- 落とし穴は実測で見つかった。BYPASSRLSの定義者のSECURITY DEFINER関数は他社の行を返し、クライアント指定の主キーは他社IDの存在を漏らす
- 外には同じ404、内部の監査ログでは区別、という設計で列挙を検知できる。検証は素のPostgreSQL 14.23で行い、主要な落とし穴はSupabase公式イメージ（17.6）とPostgREST経由のHTTPでも再確認した。GoTrueと実プロジェクトは未検証

---

## 結論：変わるのは速度と敷居。守る境界は同じ

AIエージェント型の攻撃基盤（ARTEXなど）が実際の攻撃に使われた、という公開報告が2026年秋に相次ぎました。ARTEX自体の仕組みと報告の整理は[ARTEXとは？AI自律型ペネトレーションテストの仕組み](/blog/artex-ai-pentesting-architecture-guide)にまとめています。

**防御の中身が、根本から変わったわけではありません。** Google Threat Intelligence Group（GTIG）は、勧める防御策を「制御プレーンの分離、短命な認証情報、最小権限、セグメンテーション、振る舞い検知。いずれも長年のベストプラクティス」と書いています。同時にGTIGは、攻撃のスケールを前提に、防御の戦略を**根本的に考え直す必要がある**とも述べます。

変わったのは2つです。

1. **速度と並列数。** GTIGは、オペレーターが数時間かけていた調査、コードの修正、失敗の切り分け、次の手が、数分で起こり得ると述べます。
2. **敷居。** GTIGは、導入の敷居の低さを、幅広い脅威アクターにとっての魅力として挙げています。

この記事の主張は、3つです。

1. 「相手は手作業で遅い」という前提の対策は、見直す。
2. 守る境界（認証と認可の分離、テナント分離、最小権限）は同じ。ただし**本当に効いているかを、テストで示せること**の価値が上がる。
3. 「RLSを有効にした」「SQLに条件を足した」では**完全な安全にはならない**。実際に動かして、穴が残る場所を示す。

この記事は**ARTEXによる実攻撃の再現ではなく**、一般的なWebアプリの防御を、架空のSaaSで検証したものです。

### なぜ認可を取り上げるのか

正直に書きます。報告された事例の多くは、Active Directory、クラウドの鍵、連携アプリの権限などで、**Webアプリのテナント分離そのものではありません**。それでも認可を扱う理由は、筆者の判断で、3つです。(1) GTIGが、書き換え可能な管理者フラグ（`is_admin=true`）の悪用と、他ユーザーの添付ファイルの読取りの試行を記録している。(2) JPCERT/CCが、国内でインターネット公開のWebアプリを標的にした試みを報告している。(3) 認可は、自分で検証できる範囲が広い。

### 経営者向けの結論

- 確認すべきは技術の細部ではなく、**「他社のデータは見えない」ことをテストで示せているか**です。
- そのテストは、変更のたびに自動で回るか。
- 破られたら、どこまで広がり、どれだけで気づけるか。

損害額や発生確率は、根拠のない数字になるため示しません。起こり得る影響の種類は、顧客情報の漏えい、顧客との契約上の問題、調査・通知・監査対応の工数、信頼の低下です。

### 用語の確認

- **テナント**：契約している1社分のデータの領域。
- **RLS（Row Level Security、行レベルセキュリティ）**：「この行は、この人にだけ見せる」とデータベース自身が判断する仕組み。

## WAFで防げるもの、防げないもの

OWASP API Security Top 10 2023の**API1（Broken Object Level Authorization、BOLA）**は、認可の欠陥の代表です。ログインは通り、リクエストの形も正常で、**IDだけを他人のものに変えると、他人のデータが返る**。OWASPは、クライアント入力でレコードにアクセスするすべての関数で権限を確認すること、推測困難なIDを使うこと、**認可の脆弱性を評価するテストを書き、落ちる変更はデプロイしないこと**を挙げています。

WAFで防げない理由は、リクエストが**形式上すべて正当**だからです。[WAFによる多層防御](/blog/waf-defense-in-depth-aws-waf-cloud-armor-owasp-guide)で述べているとおり、WAFは追加の層であって認可の代わりではありません。Next.js × Supabaseでの生まれ方は[IDOR/BOLAの発見と修正](/blog/nextjs-supabase-idor-broken-authorization-rls-detection-guide)にあります。

## 弱くなる前提【設計考察】

| 以前の前提 | なぜ弱くなるか | 見直し |
|---|---|---|
| 「IDが分かりにくいから大丈夫」 | 試行は安く、並列で、失敗が罰にならない。IDはURL・ログ・応答から漏れる | 推測困難なIDは使うが、認可を別に持つ |
| 「診断は定期的に受けている」 | 公開面の変化と、攻撃の試行の頻度が釣り合わない | 認可のテストをCIで毎回回す |
| 「不審なアクセスは担当者が見る」 | 短時間に大量のイベントが出る | しきい値つきの自動検知と、一時遮断 |
| 「ロックアウトがあるから総当たりは平気」 | Microsoftが観測したスプレーでは、応答の約98%がロックアウトで、成功は無かった（止めたのは何か、は筆者の解釈）。別経路は止まらなかった | ロックアウトは維持しつつ、外部アプリの権限と資格情報を棚卸しする |

GTIGは、エージェント型の攻撃が**騒がしい**とも評価します。熟練の人間と比べ、フォレンジック上はるかに騒がしい。防御側にとっては、検知の機会が増える、という意味です。ただしGTIGは、検知と阻止は別で、時間内に検知しても目標の達成は妨げられない、と指摘します。

## 題材：架空のB2B SaaS

会社Aと会社Bが同じWebアプリを使い、それぞれ請求書を保存します。

| 登場人物 | 所属 | ロール |
|---|---|---|
| alice | 会社A | admin（請求書を作成・更新できる） |
| amy | 会社A | member（読めるが、更新できない） |
| bob | 会社B | admin |
| stranger | どの会社にも属さないが、ログイン済み | なし |
| anon | 未ログイン | なし |

請求書は、会社Aに2件（A1、A2）、会社Bに1件（B1）です。**脅威モデル**：攻撃者は有効なアカウント（会社Aのalice）を持ち、会社Bの請求書B1を読む・書く・存在を確かめることが目標。手段は、APIのIDの差し替え、他社の`tenant_id`での作成・更新、権限のないロールでの更新。**範囲外**：サーバーの乗っ取り、鍵の漏えい、運用者の内部不正、サプライチェーン。

```text
 Browser (untrusted)
    |  request + session token
    v
 [Gateway / Auth]   verifies signature, expiry, audience -> verified claims (sub)
    |
    v
 [App server]       L2: authorize(principal, action, resource)   <- TypeScript
    |   query
    v
 [PostgreSQL]       L3: Row Level Security (policies + WITH CHECK) <- SQL

 Paths AROUND L3: service role / BYPASSRLS / SECURITY DEFINER / owner
```

- **L1 ゲートウェイ**：トークンの署名・有効期限・対象を検証し、検証済みの主体（`sub`）だけを下流に渡す。JWTの落とし穴は[JWT攻撃手法](/blog/jwt-attack-techniques-alg-none-key-confusion-secret-cracking-guide)。
- **L2 アプリの認可**：リソース単位で判定する。
- **L3 データベースのRLS**：アプリが間違えても他テナントの行が返らない最後の砦。

層が重なるのは、**どの層も単独では間違える**からです。

## L2：アプリ層の認可（TypeScript）

判定を、入出力だけの純粋な関数にします。I/Oがないので、すべてのルールを単体テストできます。

```ts
// invoice-access.ts — 前提: Node 24（型の除去で .ts を直接実行）、TypeScript strict。外部依存なし。
export type Role = "admin" | "member";
export type Action = "read" | "update";
export type Principal = { readonly userId: string; readonly roles: ReadonlyMap<string, Role> }; // tenantId -> role
export type Invoice = { readonly id: string; readonly tenantId: string; readonly amountJpy: number };

const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;

/** 信頼境界：unknown のクレームを Principal に絞る。不正なら null（=未認証）。例外は投げない。 */
export function toPrincipal(claims: unknown, roles: ReadonlyMap<string, Role>): Principal | null {
  if (typeof claims !== "object" || claims === null) return null;
  const sub = (claims as { sub?: unknown }).sub;
  return typeof sub === "string" && UUID.test(sub) ? { userId: sub, roles } : null;
}

/** detail は監査ログ専用。HTTPレスポンスには絶対に出さない。 */
export type AuditDetail = "ok" | "unauthenticated" | "missing" | "cross_tenant" | "role_insufficient";
export type Outcome = { readonly status: 200 | 401 | 403 | 404; readonly body: { readonly error?: string; readonly invoice?: Invoice } };
export type AuditEvent = { readonly principalId: string | null; readonly action: Action; readonly invoiceId: string; readonly decision: "allow" | "deny"; readonly detail: AuditDetail };

const NOT_FOUND: Outcome = { status: 404, body: { error: "not_found" } };

export function authorize(principal: Principal | null, action: Action, invoiceId: string, store: ReadonlyMap<string, Invoice>): { outcome: Outcome; audit: AuditEvent } {
  const ev = (decision: "allow" | "deny", detail: AuditDetail): AuditEvent => ({ principalId: principal?.userId ?? null, action, invoiceId, decision, detail });
  if (!principal) return { outcome: { status: 401, body: { error: "unauthenticated" } }, audit: ev("deny", "unauthenticated") };
  const invoice = store.get(invoiceId);
  if (!invoice) return { outcome: NOT_FOUND, audit: ev("deny", "missing") };
  const role = principal.roles.get(invoice.tenantId);
  // 他テナントは「そんな請求書は無い」と外から全く同じに見える（ステータスも本文も同じ）…
  if (!role) return { outcome: NOT_FOUND, audit: ev("deny", "cross_tenant") };           // …が、監査ログでは区別する。
  if (action === "update" && role !== "admin") return { outcome: { status: 403, body: { error: "forbidden" } }, audit: ev("deny", "role_insufficient") };
  return { outcome: { status: 200, body: { invoice } }, audit: ev("allow", "ok") };
}
```

設計上の要点です。

1. **ロールはテナントごと。** 会社Aのadminは会社Bのadminではありません。**`roles` は必ずリクエストごとに、検証済みの`sub`をキーにサーバー側のDBから引きます。** JWTに載せたロールは、失効が遅れます。この関数はその呼び出し側の責任を前提にしています。
2. **他社のIDは、不存在と同じ404。** 403を返すと「そのIDは存在する」と教えてしまいます。**自分のテナントで権限だけ足りない**場合は、すでに見えているリソースなので403にします。
3. **理由は監査ログにだけ残す。** `detail`で「越境」と「不存在」を区別します。
4. IDの形式（UUIDか）の検証は、ルートの境界で行う前提です。

トレードオフ：この関数は、IDで取得してから権限を判定します。取得時にテナント条件を付ければ（`where id = $1 and tenant_id = any($2)`）、他社の行がアプリに入りませんが、監査ログで「越境」と「不存在」を区別できなくなります。どちらを重視するかは設計の判断で、**どちらでもL3のRLSが最後に残る**のが、層を重ねる理由です。

### テスト（8件）

```ts
// invoice-access.test.ts — node --test invoice-access.test.ts
import test from "node:test";
import assert from "node:assert/strict";
import { authorize, toPrincipal, type Invoice, type Role } from "./invoice-access.ts";

const TA = "aaaaaaaa-0000-0000-0000-000000000001", TB = "bbbbbbbb-0000-0000-0000-000000000002";
const A1 = "11111111-0000-0000-0000-0000000000a1", B1 = "22222222-0000-0000-0000-0000000000b1", NONE = "99999999-0000-0000-0000-000000000000";
const store = new Map<string, Invoice>([
  [A1, { id: A1, tenantId: TA, amountJpy: 1000 }],
  [B1, { id: B1, tenantId: TB, amountJpy: 9000 }],
]);
const who = (sub: string, roles: [string, Role][]) => toPrincipal({ sub }, new Map(roles))!;
const alice = who("a1111111-0000-0000-0000-000000000001", [[TA, "admin"]]);
const amy = who("a2222222-0000-0000-0000-000000000002", [[TA, "member"]]);
const dual = who("d5555555-0000-0000-0000-000000000005", [[TA, "admin"], [TB, "member"]]);

test("unauthenticated -> 401, audit has no principal", () => {
  const r = authorize(null, "read", A1, store);
  assert.equal(r.outcome.status, 401); assert.equal(r.audit.principalId, null);
});
test("own-tenant read -> 200; admin update -> 200", () => {
  assert.equal(authorize(alice, "read", A1, store).outcome.status, 200);
  assert.equal(authorize(alice, "update", A1, store).outcome.status, 200);
});
test("cross-tenant id and nonexistent id are indistinguishable to the caller, distinguishable in the audit log", () => {
  const cross = authorize(alice, "read", B1, store), none = authorize(alice, "read", NONE, store);
  assert.deepEqual(cross.outcome, none.outcome);
  assert.equal(cross.audit.detail, "cross_tenant"); assert.equal(none.audit.detail, "missing");
});
test("cross-tenant WRITE is also 404 (never 403)", () => {
  assert.equal(authorize(alice, "update", B1, store).outcome.status, 404);
  assert.equal(authorize(amy, "update", B1, store).outcome.status, 404);
});
test("member can read but not update own tenant -> 403", () => {
  assert.equal(authorize(amy, "read", A1, store).outcome.status, 200);
  const r = authorize(amy, "update", A1, store);
  assert.equal(r.outcome.status, 403); assert.equal(r.audit.detail, "role_insufficient");
});
test("roles are per tenant: admin in A is only a member in B", () => {
  assert.equal(authorize(dual, "read", B1, store).outcome.status, 200);
  assert.equal(authorize(dual, "update", B1, store).outcome.status, 403);
  assert.equal(authorize(dual, "update", A1, store).outcome.status, 200);
});
test("denied responses never leak the invoice body", () => {
  for (const r of [authorize(alice, "read", B1, store), authorize(amy, "update", A1, store), authorize(null, "read", A1, store)])
    assert.equal(r.outcome.body.invoice, undefined);
});
test("toPrincipal fails closed on malformed claims", () => {
  for (const bad of [undefined, null, 42, "x", {}, { sub: 7 }, { sub: "not-a-uuid" }, { sub: "' or 1=1 --" }])
    assert.equal(toPrincipal(bad, new Map()), null);
});
```

8件すべて通過し、`tsc --strict --noEmit --allowImportingTsExtensions`の型検査も通りました（テストが`.ts`の拡張子つきでimportするため、このフラグが要ります）。**テストが本当に失敗しうるか**を確かめるため、越境時に403を返す改変（変異）を入れたところ、2件が落ちました。落ちないテストは何も保証しません。

## L3：PostgreSQLのRLS（全文）

**前提**：PostgreSQL 14.23（ローカルのHomebrew版、外部公開なしの一時クラスタ）。本編のラボはSupabaseそのものではなく、`auth.uid()`相当を標準機能で模した素のPostgreSQLです。ロール名と**既定の権限が実物と異なる**ため、主要な落とし穴は、のちにSupabase公式のPostgresイメージ（17.6）でも再確認しました（後述の「Supabase公式イメージでの再確認」）。ロールは `app_user ≈ authenticated`、`app_anon ≈ anon`、`app_service ≈ service_role`、`app_definer` は「RLSを回避する定義者」です。

```sql
-- Fictional B2B SaaS: two tenants, invoices. LOCAL LAB ONLY.
create role app_owner nologin;                 -- owns tables (migration role)
create role app_user  nologin;                 -- ~ Supabase "authenticated"
create role app_anon  nologin;                 -- ~ Supabase "anon"
create role app_service nologin bypassrls;     -- ~ Supabase "service_role"
create role app_definer nologin bypassrls;     -- a definer role that bypasses RLS

create schema auth;   create schema private;
create function auth.uid() returns uuid language sql stable
  as $$ select nullif(current_setting('request.jwt.claim.sub', true), '')::uuid $$;  -- 検証済みクレームを前提。SQLに直接つながるクライアントは set_config で偽装できるため、ラボ専用
grant usage on schema auth to app_owner, app_definer, app_user, app_anon, app_service;
grant execute on function auth.uid() to app_owner, app_definer, app_user, app_anon, app_service;

alter schema private owner to app_owner;
grant usage on schema public to app_owner, app_definer, app_user, app_anon, app_service;
grant create on schema public to app_owner;

set role app_owner;
create table public.tenants     (id uuid primary key, name text not null);
create table public.memberships (tenant_id uuid references public.tenants, user_id uuid not null,
                                 role text not null check (role in ('admin','member')),
                                 primary key (tenant_id, user_id));
create table public.invoices    (id uuid primary key, tenant_id uuid not null references public.tenants,
                                 customer text not null, amount_jpy integer not null check (amount_jpy >= 0),
                                 status text not null default 'draft' check (status in ('draft','sent','void')));

-- SECURITY DEFINER helper: 持ち主の権限で動く。小さく、search_path を固定し、ユーザーIDを引数に取らない。
create function private.has_role(p_tenant uuid, p_roles text[]) returns boolean
  language sql stable security definer set search_path = ''
  as $$ select exists (select 1 from public.memberships m
                        where m.tenant_id = p_tenant and m.user_id = auth.uid() and m.role = any(p_roles)) $$;
revoke all on function private.has_role(uuid, text[]) from public;
grant usage on schema private to app_user, app_definer;
grant execute on function private.has_role(uuid, text[]) to app_user, app_definer;

alter table public.tenants     enable row level security;  alter table public.tenants  force row level security;
alter table public.memberships enable row level security;   -- FORCEしない（理由は落とし穴2）
alter table public.invoices    enable row level security;  alter table public.invoices force row level security;

create policy memberships_self on public.memberships for select to app_user using (user_id = auth.uid());
create policy tenants_member   on public.tenants     for select to app_user using (private.has_role(id, array['admin','member']));
create policy inv_select on public.invoices for select to app_user
  using (private.has_role(tenant_id, array['admin','member']));
create policy inv_insert on public.invoices for insert to app_user
  with check (private.has_role(tenant_id, array['admin']));
create policy inv_update on public.invoices for update to app_user
  using (private.has_role(tenant_id, array['admin']))
  with check (private.has_role(tenant_id, array['admin']));   -- 更新後の行への要求を明示
-- DELETE のポリシーなし

grant select on public.tenants, public.memberships to app_user;
grant select, insert, update on public.invoices to app_user;     -- app_anon には何も与えない
grant select, insert, update, delete on all tables in schema public to app_service;

-- THE PITFALL: 呼び出し側のテナントを確認しない SECURITY DEFINER 関数
create function public.get_invoice_unsafe(p_id uuid) returns setof public.invoices
  language sql stable security definer set search_path = ''
  as $$ select * from public.invoices where id = p_id $$;
-- FIX: 同じ機能に、呼び出し側の所属の再確認を足す
create function public.get_invoice(p_id uuid) returns setof public.invoices
  language sql stable security definer set search_path = ''
  as $$ select i.* from public.invoices i where i.id = p_id and private.has_role(i.tenant_id, array['admin','member']) $$;
reset role;

-- (superuser) 2つの関数をRLSを回避する定義者に渡し、EXECUTEを絞る
alter function public.get_invoice_unsafe(uuid) owner to app_definer;
alter function public.get_invoice(uuid) owner to app_definer;
grant select on public.invoices to app_definer;
revoke all on function public.get_invoice_unsafe(uuid), public.get_invoice(uuid) from public;   -- 素のPostgresではこれで足りる。Supabaseではanonにも明示付与されるため from public, anon まで必要
grant execute on function public.get_invoice_unsafe(uuid), public.get_invoice(uuid) to app_user;

-- 種データ（RLSを回避するスーパーユーザーで投入）
insert into public.tenants values ('aaaaaaaa-0000-0000-0000-000000000001','Tenant A'),('bbbbbbbb-0000-0000-0000-000000000002','Tenant B');
insert into public.memberships values
 ('aaaaaaaa-0000-0000-0000-000000000001','a1111111-0000-0000-0000-000000000001','admin'),   -- alice
 ('aaaaaaaa-0000-0000-0000-000000000001','a2222222-0000-0000-0000-000000000002','member'),  -- amy
 ('bbbbbbbb-0000-0000-0000-000000000002','b3333333-0000-0000-0000-000000000003','admin');   -- bob
insert into public.invoices(id,tenant_id,customer,amount_jpy) values
 ('11111111-0000-0000-0000-0000000000a1','aaaaaaaa-0000-0000-0000-000000000001','A-Customer-1',1000),
 ('11111111-0000-0000-0000-0000000000a2','aaaaaaaa-0000-0000-0000-000000000001','A-Customer-2',2000),
 ('22222222-0000-0000-0000-0000000000b1','bbbbbbbb-0000-0000-0000-000000000002','B-Customer-1',9000);
```

ポイント：

- **`using`と`with check`。** `using`は「どの既存行が見えるか／触れるか」、`with check`は「書き込んだ**後の**行が許されるか」です。`insert`には`with check`が必須です。`update`で`with check`を省くと、PostgreSQLは`using`の式を`with check`にも使います（公式ドキュメント）。実際、`with check`を外しても、他テナントへの書き換えは拒否されました（実測）。明示する価値は、更新後の行に何を要求するかが読み取れることと、あとで`using`だけを緩めたときに更新後の検査まで緩むのを防げることです。
- **identityは引数に取らない。** `has_role`はユーザーIDを引数で受けず、`auth.uid()`から得ます。
- **ポリシーが無ければ、既定で拒否。** PostgreSQL公式の記述（default-deny）です。

### 検証の方法

各項目は、トランザクション内で実行して巻き戻す形で、独立に確認しました。核は次のとおりです（`$PSQL`は`psql -tA`）。

```bash
# 1項目 = 1トランザクション。ROLLBACKするので、項目の順序に依存しない。
printf "begin;\n%s\nset local role %s;\nselect set_config('request.jwt.claim.sub','%s',true) \\\\gset\n%s\nrollback;\n" \
  "$pre" "$role" "$sub" "$sql" | $PSQL 2>&1 | grep -v -E '^(BEGIN|SET|ROLLBACK|GRANT|REVOKE|ALTER [A-Z ]+)$'
# 先頭で「aliceが自社の2件を見る」陽性対照を取り、失敗したら全体を中止する
# （セットアップが壊れていると、すべての『拒否』が偽の成功になるため）
```

最初の実行では、セットアップの不具合で`anon`の項目だけが**偽の成功**になりました。陽性対照は、その経験から足したものです。

### 結果（29項目のうち、代表の18項目）

以下は、本編のGRANT（落とし穴3の修正前で、`update`を全列に与えた状態）での実測です。修正後は、`tenant_id`の書き換えはRLSより手前の`permission denied`で止まります。それでも`with check`の行に意味があるのは、のちに`update(tenant_id)`を誰かが与え直しても、RLSが二重目の壁として残るからです。

| 状況 | 操作 | 期待 | 実測 |
|---|---|---|---|
| alice（会社Aのadmin） | 請求書の一覧 | 会社Aの2件だけ | 2 |
| alice | 会社BのB1をIDで読む | 0件 | 0 |
| alice | 存在しないIDを読む | 0件（B1と区別できない） | 0 |
| bob（会社Bのadmin） | 一覧 | 会社Bの1件だけ | 1 |
| amy（会社Aのmember） | 一覧 | 会社Aの2件 | 2 |
| amy | A1を更新 | 0行（adminではない） | UPDATE 0 |
| alice | A1を更新 | 1行 | UPDATE 1 |
| alice | A1の`tenant_id`を会社Bに書き換える | 拒否 | `new row violates row-level security policy` |
| alice | `with check`を省いたポリシーで同じ書き換え | `using`の流用で拒否 | 同上 |
| alice | 会社Bに請求書を作成 | 拒否 | 同上 |
| alice | 会社Aに請求書を作成 | 許可 | INSERT 0 1 |
| amy | 会社Aに請求書を作成 | 拒否（adminのみ） | 同上のRLS違反 |
| alice | A1を削除（GRANT無し） | 拒否 | `permission denied for table invoices` |
| alice | A1を削除（GRANTあり、ポリシー無し） | RLSの既定拒否 | DELETE 0 |
| ログイン済みだが所属なし | 一覧 | 0件 | 0 |
| ログイン済みだがクレームが空 | 一覧 | 0件（閉じる） | 0 |
| 不正な`sub`（`not-a-uuid`） | 一覧 | エラー、データなし | `invalid input syntax for type uuid` |
| anon | 一覧 | 拒否（このラボではanonに権限を与えていない。Supabaseの既定では異なる、後述） | `permission denied for table invoices` |

残る11項目は、次の落とし穴の節で述べます。**書いた範囲のことだけが示された**、という点に注意してください。

## 検証で見つかった落とし穴

### 落とし穴1：SECURITY DEFINER関数は、RLSを素通りさせる

`SECURITY DEFINER`の関数は、呼び出した人ではなく、**関数の持ち主の権限**で動きます。PostgreSQL公式は、スーパーユーザーと`BYPASSRLS`を持つロールが常にRLSを回避すると書き、Supabaseのドキュメントも、security definer関数は呼び出し側に読ませない行を返し得ること、ダッシュボードやマイグレーションで作った関数の持ち主は`postgres`で、RLSを回避すること、**既定で`anon`を含むあらゆるロールが呼べること**を明記しています。

この検証は、それを実測で再現したものです。持ち主を`BYPASSRLS`の`app_definer`にして、同じ機能の2関数を比べました。

| 関数 | aliceが会社BのB1を指定 | aliceが自社のA1を指定 |
|---|---|---|
| `get_invoice_unsafe` | **B-Customer-1が返った（漏えい）** | 返る |
| `get_invoice`（所属を再確認） | 0件 | 1件 |

対策は2つです。(1) 関数の中で呼び出し側の所属を再確認する。(2) 実行権限を絞る。`search_path`の固定は乗っ取りへの対策であって、越境への対策ではありません。**Supabaseでは(2)に注意が要ります**。実物で確認した結果は、後述の「Supabase公式イメージでの再確認」にあります。

### 落とし穴2：FORCE RLSは防御ではなく「故障」を起こす（素のPostgresの場合）

持ち主が`BYPASSRLS`を持たず、テーブルに`FORCE ROW LEVEL SECURITY`を付けている場合、定義者の関数にもRLSが効きます。同じ`get_invoice_unsafe`の持ち主をそのようなロールに変えると、B1は返らず**0件**でした。ただし**自社のA1も0件**でした。ポリシーが`app_user`向けだけで、持ち主には適用されるポリシーが無く、既定拒否になったためです。これは、呼び出し側のテナントを見て絞っているのではなく、**関数が全員に対して壊れた**状態です。防御策として当てにしてはいけません。

同じ原因で、補助テーブル`memberships`に`FORCE RLS`を付けると、ヘルパー関数が常に偽を返し、全請求書が0件になりました（実測）。切り分けは[RLSのトラブルシューティング](/blog/supabase-rls-troubleshooting-empty-results-insert-violation-not-working-guide)にあります。

**ただし、これは素のPostgresのラボでの挙動です。** Supabaseの既定では、定義者になる`postgres`ロールが`BYPASSRLS`を持つため、`FORCE RLS`は効かず、漏えいは止まりません（後述の再確認で実測）。つまりSupabaseでは、`FORCE RLS`に防御も故障も期待できません。

### 落とし穴3：クライアント指定の主キーは、存在を漏らす

L2で「他社のIDは不存在と同じ404」にしても、**作成APIで穴が空く**ことがあります。`invoices.id`をクライアントが指定できると、aliceが会社BのB1のIDで自社の請求書を作ろうとしたとき、PostgreSQLは次を返しました（実測）。

```text
ERROR: duplicate key value violates unique constraint "invoices_pkey"
```

ランダムな未使用のIDなら成功します。つまり、**RLSがあっても、重複エラーでそのIDが存在することが分かります**。**更新でも同じ穴が空きます**。`update`の権限が全列にあると、aliceが自社のA1の`id`をB1のIDに書き換えたとき、同じ重複エラーが返り、未使用のIDなら`UPDATE 1`で通りました（実測）。修正は、IDをサーバーで生成し、**作成でも更新でも**クライアントに書かせないことです。

```sql
alter table public.invoices alter column id set default gen_random_uuid();
revoke insert on public.invoices from app_user;
grant insert (tenant_id, customer, amount_jpy) on public.invoices to app_user;   -- id を含めない
revoke update on public.invoices from app_user;
grant update (customer, amount_jpy, status) on public.invoices to app_user;      -- id と tenant_id を含めない
```

修正後は、IDを指定した作成も、`id`を書き換える更新も`permission denied`になり、IDを省いた通常の作成は`INSERT 0 1`、`status`の更新は`UPDATE 1`で通りました。`tenant_id`も更新できる列から外したので、他社への付け替えは、RLSより手前の`permission denied`で止まりました（いずれも実測。更新の3項目は、29項目とは別に、レビューの指摘を受けて追加で確かめたものです）。

**Supabaseで使うときは、ロール名を読み替えるだけでは足りません。** Supabaseの既定では、表の全権限が`anon`と`authenticated`の両方に付くため、両方から外してから、`authenticated`にだけ列を与えます。`id`の既定値（`gen_random_uuid()`）は、上と同じく必要です（後述の再確認では、この列単位の修正そのものは、Supabaseのイメージで試していません）。

```sql
alter table public.invoices alter column id set default gen_random_uuid();
revoke insert, update on public.invoices from anon, authenticated;
grant insert (tenant_id, customer, amount_jpy) on public.invoices to authenticated;
grant update (customer, amount_jpy, status) on public.invoices to authenticated;
```

### service roleと権限（GRANT）

- `BYPASSRLS`のロールは、全テナントの行が見えます。実測でも`app_service`は3件すべてを返しました。Supabaseのドキュメントは、service roleはRLSを回避するため、サーバー側に置くよう書いています。このロールで動く処理は、**アプリのコードが自分でテナントを絞る**必要があります（[anonキーとservice roleキーの露出](/blog/supabase-anon-key-service-role-key-exposure-guide)）。
- GRANTが無ければRLSの手前で`permission denied`、GRANTがあってポリシーが無ければRLSの既定拒否、という2段を確認しました。Supabaseのドキュメントも、ポリシーを足してもGRANTは取り消されないと注意しています。権限と行の可視性は、別々に設計します。

## Supabase公式イメージでの再確認

本編のラボは素のPostgreSQLなので、記事の主張がSupabaseの実物でも成り立つかを、2段階で確認しました。(1) Supabase公式のPostgresイメージ（17.6）の使い捨てコンテナで、SQLを直接実行。(2) そのイメージにPostgREST（v14.5）をつなぎ、**実際のHTTPリクエスト**で再確認。どちらも外部公開なし（HTTPは`127.0.0.1`のみ）で、確認後に削除しました。**既定の権限は変更せず**、ロールは実物の`anon`・`authenticated`・`service_role`・`postgres`を使いました。(2)のJWTは、GoTrueを起動せず、自分でHS256署名したものです。

**ロール属性（実物）**：`postgres`と`service_role`は`BYPASSRLS`、`anon`と`authenticated`は持ちません。**既定の権限（実物）**：`postgres`が作る表には`anon`・`authenticated`・`service_role`に全権限が、関数には`EXECUTE`が付きます。

| 状況 | 期待 | 実測 |
|---|---|---|
| authenticatedのalice：自社の一覧 | 2件 | 2 |
| aliceが会社BのB1を指定 | 0件 | 0 |
| **anonが一覧を読む** | 拒否ではなくRLSで0件 | 0（permission deniedにはならない） |
| anonが作成 | RLSで拒否 | `new row violates row-level security policy` |
| **aliceが`get_invoice_unsafe`でB1を指定** | 漏えい | **B-Customer-1が返った** |
| **anon（未ログイン）が同じ関数でB1を指定** | 漏えい | **B-Customer-1が返った** |
| 上記に`FORCE RLS`を付ける | 持ち主`postgres`が`BYPASSRLS`なので止まらない | 漏えいは止まらない |
| 所属を再確認する`get_invoice`（B1／自社A1／anon） | 0件／1件／0件 | 0／1／0 |
| `revoke execute … from public`**だけ**実行 | anonは呼べないはず | **anonはまだ呼べた**（B-Customer-1） |
| `revoke execute … from public, anon`を実行 | anonは拒否 | `permission denied for function` |
| service roleが一覧を読む | 全件 | 3 |
| aliceが他社のIDで作成 | 重複キーのエラー（存在オラクル） | `duplicate key value violates unique constraint` |

ここから分かることは4つです。

1. **`anon`は表を読めるが、RLSで0件になる。** 「権限が無いので拒否」という本編ラボの挙動は、Supabaseの既定とは違います。RLSが、**唯一の壁**です。
2. **`get_invoice_unsafe`は、ログインしていない`anon`にも他社の請求書を返しました。** 既定で`EXECUTE`が付くためです。Supabaseのドキュメントの記述を、実測で確認した形です。
3. **`revoke … from public`だけでは足りません。** `anon`と`authenticated`には、`PUBLIC`とは別に`EXECUTE`が明示付与されています。`from public, anon`まで外して、初めて`anon`が拒否されました。本編のSQLの`from public`は、素のPostgres向けの書き方です。
4. **`FORCE RLS`は、Supabaseでは漏えいを止めません。** 定義者の`postgres`が`BYPASSRLS`を持つためです。

### `auth.uid()`の実際の定義と、HTTP経由の結果

本編のスタブは、`request.jwt.claim.sub`だけを読みます。Supabaseの認証サーバー（supabase/auth）のマイグレーション（20220224000811）が定義する実物の`auth.uid()`は、**`request.jwt.claim.sub`を読み、空なら`request.jwt.claims`（JSON）の`sub`にフォールバックします**。PostgRESTは後者の形でクレームを渡すため、実際のHTTP経路では、フォールバック側が使われます。スタブは、この前半だけを模したものです。実物の定義に差し替えて、PostgRESTを通した結果が次です。

| HTTPリクエスト（実測） | 結果 |
|---|---|
| aliceのJWTで`GET /invoices` | 自社の2件 |
| aliceのJWTで、B1のIDを指定 | 空配列（HTTP 200） |
| **JWTなし**で`GET /invoices` | 空配列（HTTP 200）。拒否ではない |
| 署名が違うJWT | HTTP 401 |
| service roleのJWT | 全3件 |
| **JWTなし**で`POST /rpc/get_invoice_unsafe`（B1を指定） | **B-Customer-1が返った（HTTP 200）** |
| aliceのJWTで同じRPC | **B-Customer-1が返った** |
| 所属を再確認する`get_invoice`でB1 | 空配列 |
| 使用済みのIDを指定して`POST /invoices` | **HTTP 409**（重複）。未使用のIDなら**HTTP 201** |
| `revoke ... from public`**だけ**の後、JWTなしで同じRPC | **まだ漏れた** |
| `revoke ... from public, anon`の後 | `permission denied for function`（HTTP 401） |

**ログインしていない、JWTすらないHTTPリクエストが、他社の請求書を返した**ことが、この確認でいちばん重い事実です。RPCとして公開した関数の権限（`EXECUTE`）の設計が、そのまま、インターネットに面した入口の設計になります。

この確認も、**書いた範囲のことだけ**を示します。

## ログと検知：列挙を「件数」として見つける

L2で外には同じ404を返し、内部では`cross_tenant`と`missing`を区別しました。これを使い、「1つの主体が、短時間に、多数の別IDで拒否された」件数を見ます。

```sql
select principal_id,
       count(distinct invoice_id) as distinct_denied_ids,
       min(at) as first_seen, max(at) as last_seen
from audit_events
where at >= now() - interval '5 minutes'
  and detail in ('cross_tenant', 'missing')
group by principal_id
having count(distinct invoice_id) >= 20;     -- 20は例示。自社のベースラインから決める
```

合成データ（主体P1は60個の別IDを30秒ほどで拒否、主体P2は通常の利用で1回だけ越境を試みた）で実行すると、結果はP1の1行だけでした。

**限界を明記します。**

- これは**自分で作った合成データに対する、同語反復に近い確認**です。しかも、ランダムなUUIDを60個試す列挙は現実的ではありません。現実の脅威は、漏れたIDや連番のIDです。
- 閾値`20`と窓`5分`は例示です。正当な利用でも、画面の読み込みや一括処理で多数のIDに触れます。**自社の通常の分布を測ってから**決めてください（OWASP API4も、レート制限は業務に合わせて調整するよう求めます）。
- 攻撃者がアカウントや送信元を分散すれば、主体単位の集計では見えません。
- 検知と阻止は別です。検知したら**何を自動で止めるか**まで設計しないと、結果は変わりません。
- ログに請求書の本文や個人情報を入れないでください。

ログ設計の全体は[セキュリティログと検知エンジニアリング](/blog/security-logging-detection-engineering-sigma-mitre-attack-siem-guide)、事後対応は[NIST SP 800-61 Rev.3に基づくインシデント対応](/blog/incident-response-nist-800-61r3-csirt-runbook-playbook-production-guide)を参照してください。

## この検証が保証しないこと

- **本編のラボはSupabaseそのものではありません。** 主要な落とし穴はSupabase公式のPostgresイメージと、PostgREST経由のHTTPで再確認しましたが、GoTrue（実際のログインとJWTの発行）、実際のSupabaseプロジェクト（非対称鍵の署名など）、Edge Functions、Realtimeは検証していません。自分の環境で同じ種類のテストを回してください。
- **範囲は、架空のSaaSの請求書の読み書き**です。業務ロジック、管理者の経路、バッチ、外部連携は含みません。
- **タイミング差などの副チャネルは未検証**です。404に統一しても、処理時間で存在が推測できる可能性があります。
- **「RLSを有効にしたから安全」「SQLに条件を足したから安全」とは言えません。** 落とし穴1〜3がその実例です。
- 29項目と8テストは、書いた範囲のことだけを示します。

## 自力で確認できること、独立したレビューが役立つこと

私は、Webアプリのセキュリティ監査を有料で提供しています。この節には**利益相反**があるため、**自力で十分な場合を先に書きます。**

**自力で確認しやすいこと（無料）**

- 上のようなテストを自分のアプリで書き、CIで回す。他テナントのIDが拒否されること、権限のないロールが更新できないことを、変異テストで失敗することまで確かめる。
- Next.js × Supabaseなら、無料のOSS（Aegis）で現状を可視化できます。`npx @aegiskit/cli scan`、詳細は[/aegis](/aegis)。非エンジニア向けの20問の自己診断は[/vibe-coding/checkup](/vibe-coding/checkup)（回答はブラウザの外に出ません）。
- RLSのテストの書き方は[pgTAPによるRLS回帰テスト](/blog/supabase-rls-testing-pgtap-policy-regression-guide)を参照。

**独立したレビューが役立つことがある場面**

- ロールの設計が業務の実態と合っているかを、第三者の目で見たいとき。
- 管理者、運用ツール、バッチ、外部連携といった**RLSの外の経路**が、分離を壊していないかを確かめたいとき。
- 自分が書いたコードを自分でレビューする限界があり、顧客や取引先に**独立した確認**を示す必要があるとき。

**必要とは限らない場面**：ユーザーが少なく、機微なデータを扱わず、上の自己確認が一巡していて、独立した確認を求められていないときは、まず自力の確認を回すほうが合理的です。

認可・テナント分離・設定まで、コードを読んで確かめる監査の範囲と料金は[/security-audit](/security-audit)、Next.js × Supabaseに特化した監査は[/aegis/audit](/aegis/audit)にあります。「完全に安全」とは言いません。見つかった問題と、残るリスクを分けて報告します。
