結論:変わるのは速度と敷居。守る境界は同じ
AIエージェント型の攻撃基盤(ARTEXなど)が実際の攻撃に使われた、という公開報告が2026年秋に相次ぎました。ARTEX自体の仕組みと報告の整理はARTEXとは?AI自律型ペネトレーションテストの仕組みにまとめています。
防御の中身が、根本から変わったわけではありません。 Google Threat Intelligence Group(GTIG)は、勧める防御策を「制御プレーンの分離、短命な認証情報、最小権限、セグメンテーション、振る舞い検知。いずれも長年のベストプラクティス」と書いています。同時にGTIGは、攻撃のスケールを前提に、防御の戦略を根本的に考え直す必要があるとも述べます。
変わったのは2つです。
- 速度と並列数。 GTIGは、オペレーターが数時間かけていた調査、コードの修正、失敗の切り分け、次の手が、数分で起こり得ると述べます。
- 敷居。 GTIGは、導入の敷居の低さを、幅広い脅威アクターにとっての魅力として挙げています。
この記事の主張は、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 | 会社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での作成・更新、権限のないロールでの更新。範囲外:サーバーの乗っ取り、鍵の漏えい、運用者の内部不正、サプライチェーン。
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") };
}
設計上の要点です。
- ロールはテナントごと。 会社Aのadminは会社Bのadminではありません。
rolesは必ずリクエストごとに、検証済みのsubをキーにサーバー側のDBから引きます。 JWTに載せたロールは、失効が遅れます。この関数はその呼び出し側の責任を前提にしています。 - 他社のIDは、不存在と同じ404。 403を返すと「そのIDは存在する」と教えてしまいます。自分のテナントで権限だけ足りない場合は、すでに見えているリソースなので403にします。
- 理由は監査ログにだけ残す。
detailで「越境」と「不存在」を区別します。 - 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 |
| 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のトラブルシューティングにあります。
ただし、これは素の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つです。
anonは表を読めるが、RLSで0件になる。 「権限が無いので拒否」という本編ラボの挙動は、Supabaseの既定とは違います。RLSが、唯一の壁です。get_invoice_unsafeは、ログインしていないanonにも他社の請求書を返しました。 既定でEXECUTEが付くためです。Supabaseのドキュメントの記述を、実測で確認した形です。revoke … from publicだけでは足りません。anonとauthenticatedには、PUBLICとは別にEXECUTEが明示付与されています。from public, anonまで外して、初めてanonが拒否されました。本編のSQLのfrom publicは、素のPostgres向けの書き方です。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で拒否された」件数を見ます。
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にあります。「完全に安全」とは言いません。見つかった問題と、残るリスクを分けて報告します。