メインコンテンツへスキップ
アプリ層セキュリティ
セキュリティ
認証・認可
Supabase
RLS
PostgreSQL
OWASP
脆弱性診断
Next.js

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

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

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

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

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

防御の中身が、根本から変わったわけではありません。 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、行レベルセキュリティ):「この行は、この人にだけ見せる」とデータベース自身が判断する仕組み。

この記事の脆弱性、あなたのアプリは大丈夫ですか?

Next.js × Supabase の認可・RLS を、専門家が監査します

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

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

WAFで防げない理由は、リクエストが形式上すべて正当だからです。WAFによる多層防御で述べているとおり、WAFは追加の層であって認可の代わりではありません。Next.js × Supabaseでの生まれ方はIDOR/BOLAの発見と修正にあります。

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

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

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

題材:架空のB2B SaaS

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

登場人物所属ロール
alice会社Aadmin(請求書を作成・更新できる)
amy会社Amember(読めるが、更新できない)
bob会社Badmin
strangerどの会社にも属さないが、ログイン済みなし
anon未ログインなし

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

 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攻撃手法。
  • L2 アプリの認可:リソース単位で判定する。
  • L3 データベースのRLS:アプリが間違えても他テナントの行が返らない最後の砦。

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

L2:アプリ層の認可(TypeScript)

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

// 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件)

// 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を回避する定義者」です。

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

# 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
amyA1を更新0行(adminではない)UPDATE 0
aliceA1を更新1行UPDATE 1
aliceA1のtenant_idを会社Bに書き換える拒否new row violates row-level security policy
alicewith checkを省いたポリシーで同じ書き換えusingの流用で拒否同上
alice会社Bに請求書を作成拒否同上
alice会社Aに請求書を作成許可INSERT 0 1
amy会社Aに請求書を作成拒否(adminのみ)同上のRLS違反
aliceA1を削除(GRANT無し)拒否permission denied for table invoices
aliceA1を削除(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_unsafeB-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のトラブルシューティングにあります。

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

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

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

ERROR: duplicate key value violates unique constraint "invoices_pkey"

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

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のイメージで試していません)。

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キーの露出)。
  • 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)。拒否ではない
署名が違うJWTHTTP 401
service roleのJWT全3件
JWTなしでPOST /rpc/get_invoice_unsafe(B1を指定)B-Customer-1が返った(HTTP 200)
aliceのJWTで同じRPCB-Customer-1が返った
所属を再確認するget_invoiceでB1空配列
使用済みのIDを指定してPOST /invoicesHTTP 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で拒否された」件数を見ます。

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も、レート制限は業務に合わせて調整するよう求めます)。
  • 攻撃者がアカウントや送信元を分散すれば、主体単位の集計では見えません。
  • 検知と阻止は別です。検知したら何を自動で止めるかまで設計しないと、結果は変わりません。
  • ログに請求書の本文や個人情報を入れないでください。

ログ設計の全体はセキュリティログと検知エンジニアリング、事後対応はNIST SP 800-61 Rev.3に基づくインシデント対応を参照してください。

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

  • 本編のラボは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。非エンジニア向けの20問の自己診断は/vibe-coding/checkup(回答はブラウザの外に出ません)。
  • RLSのテストの書き方はpgTAPによるRLS回帰テストを参照。

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

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

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

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

よくある質問

WAFを入れていれば、AIによる自動攻撃は防げますか?
一部は防げますが、認可の欠陥は防げません。WAFは既知の攻撃パターンや過剰な頻度を見つけるのが得意ですが、正しいログインで正しい形のリクエストを送り、他社のIDを指定した要求は、形式上は正常に見えます。誰がどのデータに触れてよいかは、アプリとデータベースの認可が決めます。WAFは追加の層であり、代わりにはなりません。
SupabaseのRLSを有効にすれば、テナント分離は安全ですか?
有効にするだけでは足りません。この記事の検証では、RLSがあっても、RLSを回避する権限で動くSECURITY DEFINER関数が呼び出し側のテナントを確認しないと他社の請求書が返りました。service roleもRLSを素通りします。ポリシーに加えて、関数、ロール、GRANT、管理経路、回帰テストをセットで設計する必要があります。
IDをUUIDにしておけば、他人のデータは推測されませんか?
推測しにくくなる効果はありますが、認可の代わりにはなりません。OWASPも、推測しにくいIDを使うことを推奨しつつ、クライアント入力でレコードにアクセスするすべての関数で、ログイン中のユーザーに権限があるかを確認することを求めています。UUIDはURL、ログ、APIレスポンスなどから漏れることがあります。
存在しないIDと他社のIDで、エラーを変えてはいけないのですか?
外から見える応答は同じ(たとえば404)にするのが安全です。403を返すと、そのIDは存在するがあなたのものではない、と教えることになります。ただし内部の監査ログには区別して記録します。なお、クライアントが主キーを書ける作成・更新APIは、重複エラーで存在を漏らします。この記事の検証で実際に確認しました。
自社で確認できることと、外部レビューが要ることの境目は?
自力で確認しやすいのは、他テナントのIDが拒否されることをテストで再現することです。外部の独立したレビューが役立つのは、ロールの設計が業務の実態と合っているか、管理者や運用ツールの経路が分離を壊していないか、自分が書いたものを自分で検証する限界がある場合です。必須とは限りません。

参考文献

友田

友田 陽大

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

この記事の脆弱性、あなたのアプリは大丈夫ですか?

Next.js × Supabase の認可・RLS を、専門家が監査します

この記事で扱った IDOR・RLS の設計ミス・テナント越境は、ライブラリでは直せない「縦のリスク」です。認可レビューから修正設計・実装まで、セキュリティ監査として承ります。まず無料の OSS で現状を可視化してからでも構いません。

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

最短ルート:カレンダーから直接予約

相談内容が固まっている方は、フォーム送信よりその場で日程を確定する方がスムーズです。下記から空き時間をお選びください。

  • 30分のオンライン無料相談
  • Google Meet / Zoom / Microsoft Teams
  • NDA 商談前締結可・無理な営業はいたしません
無料相談の空き枠を予約する

あわせて読みたい