iT邦幫忙

2026 iThome 鐵人賽

DAY 4
0

把服務的規則寫成一張表,AI 寫的程式碼要一格一格對答案。

大綱

  • 情境:平台規定「驗證過的會員」才能發文,但檢查只算了數量
  • 四種「權限」:登入、身分狀態、角色、資料擁有者
  • 權限表:把規則寫下來(附範本)
  • 提示詞:請 AI 從程式碼反推權限表,再和你的規則比對
  • 確認修好:用每一種身分跑一次
  • 機制:BFLA 與商業規則
  • 我的案例:一個授權漏洞的多層修補,以及它的代價
  • 403 還是 404:錯誤訊息也會洩漏資訊
  • exists validation 規則怎麼變成 enumeration 工具
  • 並行請求:數量上限的 race condition(附實測)
  • 程式碼:Laravel 與 Supabase RLS 兩個版本
  • 測試、對應標準
  • 攔截點:該在哪個 SSDLC 階段攔下、要問的問題
  • 劃重點

【情境】

某個會員制社群平台有一條規則:只有完成身分驗證的會員,才能發布公開貼文,而且每人最多 N 篇。

原本的檢查邏輯是:「這個會員的貼文數量有沒有超過上限?沒有的話就讓他發布。」

數量檢查本身寫得沒錯,問題是它完全沒有檢查會員驗證過沒有。更糟的是,公開的貼文列表也沒有過濾,未驗證會員的內容照樣對所有人顯示。

這是我實際修過的案例(已去識別化)。這種程式碼也是 AI 很容易寫出來的:你在提示詞裡講了「每人最多 N 篇」,它就寫數量檢查;「驗證過才能發」如果只寫在需求文件的另一頁,它就不會知道。

四種不同的「權限」

大家說「有沒有權限」,其實混在一起講了四件事:

檢查 問的問題 例子
登入 你是誰? 沒登入不能發文
身分狀態 你現在是什麼狀態? 驗證過才能發文、付費會員才能匯出
角色 你是什麼角色? 管理員才能看後台
資料擁有者 這筆資料是你的嗎? 只能改自己的清單(第 19 天會教你怎麼測)

AI 最常做好的是第一種,最常漏掉的是第二種和第四種。

權限表:把規則寫下來

你心裡知道服務的規則,但 AI 不知道,除非你寫下來。換個例子,一個清單服務的規則可能長這樣:

動作 未登入 免費會員 未驗證會員 付費會員 管理員
瀏覽公開清單
建立清單 ✅(最多 3 份) ✅(無限)
使用 AI 整理 ✅(每天 3 次)
匯出 PDF
看所有會員
修改自己的「已驗證」「方案」欄位 ❌(只有驗證流程、金流 webhook 能改)

最後一列很容易被忘記:規則靠「已驗證」「付費方案」這些欄位判斷,那誰能改這些欄位本身就是一條規則。第 13 天會看到,「更新個人資料時順便把自己改成已驗證」是 AI 很常留下的洞。

這張表就是你的答案卷。之後每次請 AI 新增功能,都把這張表一起給它。

權限表範本

複製下面這段,貼進你的筆記或 AI 對話裡,把欄和列換成自己服務的身分與動作:

# 權限表:【服務名稱】(最後更新:YYYY-MM-DD)

身分定義:
- 未登入:沒有帶任何登入資訊
- 【身分 A】:【怎麼判斷,例如 profiles.identity_verified_at 不是空的】
- 【身分 B】:【怎麼判斷】

| 動作 | 未登入 | 【身分 A】 | 【身分 B】 | 管理員 |
|---|---|---|---|---|
| 【讀取類動作】 |  |  |  |  |
| 【新增類動作】 |  |  |  |  |
| 【有次數或數量上限的動作】 |  |  |  |  |
| 【只有特定方案能用的動作】 |  |  |  |  |
| 修改【判斷身分用的欄位】 |  |  |  |  |

填法:
- 每一格填 ✅、❌,或 ✅ 加上限制(例如「最多 3 份」「每天 3 次」「只能是自己的」)
- 「身分」要寫出程式怎麼判斷,不能只寫名稱
- 不確定的格子先填 ❌,之後有需要再開

貼給 AI 的提示詞

先讓 AI 只看程式碼,「反推」出目前實際的規則:

請只根據程式碼(不要參考任何文件或註解),整理出這個服務「實際上」的權限規則表。

欄位:動作、未登入、免費會員、未驗證會員、付費會員、管理員。
每一格填「允許」「拒絕」或「沒有檢查」,並附上檢查所在的檔案與行數。
如果某個動作在前端有檢查、伺服器端沒有,請標示為「沒有檢查」。
同一個動作如果有多個入口(例如一般新增和附檔上傳是兩支 API),請分開列出。

先不要修改程式碼。

拿到後,和你自己畫的表逐格比對。不一樣的格子,就是漏洞。

然後再請 AI 修正:

這是正確的權限規則表:【貼上你的表】
請修正所有和實際程式碼不一致的地方,檢查一律放在伺服器端。
每一條規則只在一個地方定義,其他地方呼叫它,不要複製判斷式。
每一格「拒絕」都要有一個自動化測試,證明該身分執行該動作會被拒絕。

怎麼確認真的修好了

每種身分各準備一個帳號,照著權限表一格一格操作。記得用第 3 天〈瀏覽器看得到的,全都是公開的〉的「複製為 fetch 格式」直接送請求,不要只看按鈕有沒有出現。

「讀取」也要測:用未登入的無痕視窗打開公開列表,確認不該出現的內容真的不在裡面。

機制:BFLA 與商業規則

授權檢查可以分三層來看。BOLA(Broken Object Level Authorization)問的是「這筆資料是不是你的」,第 19 天會教你用兩個帳號測它。這篇的重點是 BFLA(Broken Function Level Authorization):你這個身分能不能執行這個功能。再往上一層是「驗證過才能發布」「免費會員最多三份」這種商業規則。它們在程式碼裡長得不像資安檢查,code review 很容易放過,但它們失效時,一樣是授權漏洞。

OWASP API Security Top 10 對 BFLA 的建議很直接:要有一個一致、容易分析的授權模組,所有功能都呼叫它;而且預設拒絕,每個功能都要明確開放給特定身分。權限表就是這個模組的規格。

我的案例:多層修補,以及它的代價

那次修正,我把檢查補在三層:

  1. Controller:明確檢查驗證狀態,當第一道防線
  2. Service:「能不能發文」的判斷同時檢查驗證狀態與數量
  3. 查詢層:公開列表用 Laravel 的 whereHas() 過濾掉未驗證會員的內容

第三層最關鍵。前兩層擋的是「寫入」,第三層擋的是「讀取」:就算未來某個新功能忘了檢查,未驗證的內容也不會出現在公開列表上。

但多層檢查有代價。回頭數,同一條「驗證過才能發」的規則,最後出現在六個地方:兩個建立入口的 controller(一般發文、附檔上傳各一支 API)、service 的兩個方法、公開列表的兩個查詢(一般版和分頁版)。下次規則改了,很可能只改到其中幾個。這也是前面提示詞要 AI「多個入口分開列出」的原因:漏掉的常常不是主要那支 API,而是旁邊那支。

比較好的做法是:

  • 規則只在一個地方定義(Policy 或 Domain 物件)
  • Controller 和 Service 都呼叫同一個定義
  • 查詢層用可重用的 scope,名稱清楚表達商業規則
// app/Policies/PostPolicy.php — the single definition of the rule
public function publish(Member $member): bool
{
    return $member->identity_verified_at !== null
        && $member->posts()->count() < config('posts.max_per_member');
}

// app/Models/Post.php — the read side uses a named scope
public function scopePubliclyVisible(Builder $query): void
{
    $query->where('is_public', true)
        ->whereHas('member', fn (Builder $q) => $q->whereNotNull('identity_verified_at'));
}

每個建立入口都寫 Gate::authorize('publish', Post::class),每個公開列表都從 Post::publiclyVisible() 開始查。之後規則改了,只改這兩個地方。

403 還是 404

回應 攻擊者得到的資訊
403 Forbidden 「這個編號的資料存在,只是你不能看」
404 Not Found 「查無此資料」,無法分辨存在與否

GitHub 就是這樣做的:你沒有權限的私有 repo,API 回 404 而不是 403,官方文件寫明原因是「避免證實私有 repo 存在」。

在另一個專案裡,我刻意讓「草稿狀態的資料」「已封存的資料」「不存在的編號」「別的單位的資料」四種情況,回傳逐字相同的錯誤訊息。原因是只要訊息有一個字不同,攻擊者就能逐一猜編號,拼出你的資料分布。這種回應差異能讓攻擊者確認事實,資安上叫做 oracle。

不是每個地方都要回 404。「你已經登入、但方案不夠」這種情況,回 403 並告訴使用者要升級,沒有洩漏任何別人的資料。判斷方式是:錯誤訊息會不會透露「別人的」資料存不存在、長什麼樣子

exists validation 規則怎麼變成 enumeration 工具

Laravel 的 exists validation 規則很方便,但它會讓「編號不存在」和「編號存在但不屬於你(之後由 Policy 拒絕)」產生兩種不同的錯誤:前者是 422,後者是 403。那個專案的做法是把 exists 改成帶條件的 Rule::exists(),把「屬於哪個單位、是否已發布」一起寫進同一條規則,不管哪種情況都回傳同一個錯誤:

// Before: two different failures reveal whether the id exists
'template_id' => ['required', 'exists:contract_templates,id'],

// After: one rule, one message, no existence oracle
'template_id' => [
    'required',
    Rule::exists('contract_templates', 'id')
        ->where('organization_id', $organization->id)
        ->where('status', TemplateStatus::Published->value),
],

我在 Laravel 13 實測,用同一個帳號送出五種編號:

送出的編號 改之前(exists: 改之後(Rule::exists()
自己單位、已發布 200 200
自己單位、草稿 200(交給後面的檢查) 422 The selected template id is invalid.
自己單位、已封存 200(交給後面的檢查) 422 同上
別的單位 200(交給後面的檢查) 422 同上
不存在 422 422 同上

改之前,只有「不存在」會在 validation 階段失敗,其他三種要靠後面的 Policy 擋,擋下來的回應又不一樣,差異就出現了。改之後,四種不能用的情況在 validation 階段就被擋下,回應逐字相同。

並行請求:數量上限的 race condition

第 3 天提過,額度要「檢查並扣除」一次完成。「最多 N 篇」有同樣的問題:程式通常先查數量,再新增。如果同時送出 10 個請求,它們可能都在「查數量」這一步看到還沒到上限,然後全部新增成功。這是典型的 TOCTOU(time-of-check to time-of-use)。

自己測一次

用一個已驗證的帳號登入,照第 3 天的方法複製「發文」的請求,然後在主控台貼上:

// Fire 10 identical requests at the same time
const results = await Promise.all(
  Array.from({ length: 10 }, () =>
    fetch(/* paste the arguments of the copied fetch() here */).then((res) => res.status)
  )
);
console.log(results, "成功:", results.filter((s) => s >= 200 && s < 300).length);

成功的數量加上原本已經有的篇數,超過上限就是有問題。一次沒出現不代表沒問題,race condition 看運氣,至少跑 10 輪,每輪之前把測試帳號的資料清掉。

實測結果

我用 Laravel 13.32、PostgreSQL 17 建了一個上限 3 篇的發文 API,本機伺服器開 12 個 worker,每輪同時送 10 個請求:

版本 超過上限的輪數 單輪最多寫進幾篇
先查數量再新增 18/20 7
交易內 lockForUpdate() 再計數 0/20 3

一個容易踩的坑:php artisan serve 預設一次只處理一個請求,在它上面怎麼測都不會出現 race condition,讓你以為沒問題。要加 --no-reload 並設定 PHP_CLI_SERVER_WORKERS 才會真的並行;最準的是直接測部署後的環境。

修法

只要擋住同一個會員的並行請求,用第一種;已經有計數欄位、或不想拉長交易時間,用第二種:

  • 在交易裡對會員資料列加鎖(lockForUpdate())再計數
  • 在會員資料上放一個計數欄位,用「條件成立才加一」的單一 UPDATE,讓資料庫替你擋
DB::transaction(function () use ($member, $data) {
    // Lock the member row so concurrent requests from the same member wait here
    $member = Member::whereKey($member->id)->lockForUpdate()->first();

    Gate::forUser($member)->authorize('publish', Post::class);

    Post::create([...$data, 'member_id' => $member->id]);
});
// Alternative: a counter column updated atomically
$reserved = Member::whereKey($member->id)
    ->where('posts_count', '<', config('posts.max_per_member'))
    ->increment('posts_count');

if ($reserved === 0) {
    abort(403);
}

注意:SQLite 會直接忽略 lockForUpdate()(Laravel 對 SQLite 不產生鎖定語法)。如果你的測試跑在 SQLite,這段鎖在測試裡沒有作用,並行測試要在 MySQL 或 PostgreSQL 上跑。

同一件事,用 Supabase RLS 怎麼寫

如果你的服務是 Next.js+Supabase、前端直接讀寫資料庫,規則就寫在 RLS policy 裡(第 5 天會完整談 RLS)。下面把「驗證過才能公開清單」寫成 policy,而且判斷式只定義一次:

-- The single definition of "verified", in a schema not exposed by the Data API
create function private.is_identity_verified(member_id uuid)
returns boolean
language sql
stable
security definer
set search_path = ''
as $$
  select exists (
    select 1 from public.profiles
    where id = member_id and identity_verified_at is not null
  );
$$;

-- Read: public lists are visible only if the owner is verified
create policy "read public lists of verified members"
on public.lists for select to anon, authenticated
using (
  user_id = (select auth.uid())
  or (is_public and private.is_identity_verified(user_id))
);

-- Write: only verified members may create or switch a list to public
create policy "only verified members create public lists"
on public.lists for insert to authenticated
with check (
  user_id = (select auth.uid())
  and (not is_public or private.is_identity_verified((select auth.uid())))
);

create policy "only verified members make lists public"
on public.lists for update to authenticated
using (user_id = (select auth.uid()))
with check (
  user_id = (select auth.uid())
  and (not is_public or private.is_identity_verified((select auth.uid())))
);

-- Members must not be able to mark themselves as verified
revoke update on table public.profiles from authenticated;
grant update (nickname) on table public.profiles to authenticated;

三個細節:

  1. 「修改自己的已驗證欄位」那一格。Supabase 預設把資料表的權限整張開給 authenticated,只寫 revoke update (identity_verified_at) 沒有用,因為表層級的權限還在。要先整張收回,再逐欄開放。我在本機 PostgreSQL 實測:收回之後,會員改 identity_verified_at 會得到 permission denied,改暱稱照常成功。
  2. security definer 函式不能放在對外開放的 schema。它用建立者的權限執行,放在 public 等於讓任何人透過 API 呼叫它。上面放在 private,並設定 search_path = ''。這兩點都是 Supabase 官方文件的要求。
  3. 不要把「已驗證」放在 user_metadata。使用者可以自己用 supabase.auth.updateUser() 改它。要放在 JWT 裡的話用 app_metadata,但 JWT 要等使用者重新取得 token 才會更新,撤銷驗證不會立刻生效;即時性重要的規則,照上面查資料表。

數量上限也可以寫在 policy 的 with check 裡數一數,但它有一樣的 race condition。我用 10 個連線同時新增、上限 3 份:只靠 policy 計數,10 輪都超過上限(單輪最多寫進 10 份);改用 trigger 先鎖會員資料列再計數,10 輪都停在 3 份:

create function private.enforce_list_limit()
returns trigger
language plpgsql
security definer
set search_path = ''
as $$
begin
  -- Serialize concurrent inserts from the same member
  perform 1 from public.profiles where id = new.user_id for update;

  if (select count(*) from public.lists where user_id = new.user_id) >= 3 then
    raise exception 'list limit reached';
  end if;

  return new;
end;
$$;

create trigger enforce_list_limit
before insert on public.lists
for each row execute function private.enforce_list_limit();

測試

用 dataset 把權限表直接變成測試(Pest):

dataset('publishers', [
    'unverified member' => [fn () => Member::factory()->identityUnverified()->create(), false],
    'verified member'   => [fn () => Member::factory()->identityVerified()->create(), true],
]);

it('enforces who can publish a post', function (Member $member, bool $allowed) {
    $response = $this->actingAs($member)->postJson('/api/posts', ['title' => 'hello']);

    $allowed ? $response->assertCreated() : $response->assertForbidden();
})->with('publishers');

it('hides posts of unverified members from the public list', function () {
    $hidden = Post::factory()->for(Member::factory()->identityUnverified())->public()->create();
    $shown  = Post::factory()->for(Member::factory()->identityVerified())->public()->create();

    $this->getJson('/api/posts')
        ->assertOk()
        ->assertJsonMissing(['id' => $hidden->id])
        ->assertJsonFragment(['id' => $shown->id]);
});

幾個細節:

  • dataset 裡的 closure 會在 beforeEach() 之後才執行,所以可以放 factory。Pest 官方文件要求測試函式的參數寫上型別Member $member),它才會把 closure 換成執行結果。
  • factory state 的名稱避開 unverified()。Laravel 預設的 UserFactory 已經有一個 unverified(),意思是「email 還沒驗證」,跟「身分驗證」是兩回事,混用就會測錯東西。
  • 「看不到」的測試一定要搭配一個「看得到」的斷言。只寫 assertJsonMissing,API 壞掉回空陣列時測試也會通過。

以上測試在 Laravel 13.32+Pest 4.7 實際跑過。權限表每多一格,dataset 跟著多一行,表和測試不會脫節。

對應標準

  • OWASP API Security Top 10 2023:API5 Broken Function Level Authorization
  • OWASP Top 10:2025 A01 Broken Access Control
  • OWASP ASVS 5.0:8.1.1(L1,授權文件要定義功能層級與資料層級的存取規則,也就是這篇的權限表)、8.1.2(L2,欄位層級的讀寫規則)、8.2.1(L1,功能層級的存取只開放給有明確權限的人)、8.3.1(L1,授權規則在受信任的伺服器端強制執行)、8.3.2(L3,授權依據的值改變後要立即生效)
  • CWE-285:Improper Authorization
  • CWE-204:Observable Response Discrepancy
  • CWE-367:Time-of-check Time-of-use (TOCTOU) Race Condition

【攔截點】

  • 最早該在這裡攔下:需求與設計。權限表在寫程式之前就是規格(ASVS 8.1.1 要求的授權文件),要和提示詞一起交給 AI。
  • 漏掉時的下一道網:實作②(第 13 天欄位 allowlist);驗證(第 19 天兩個帳號測試、把權限表寫成 dataset 測試、並行請求測數量上限)。
  • 要問的問題(思考模型:身分與權限):確認了是誰之後,有沒有確認他能做這件事?這條商業規則在哪一個地方被強制執行?有幾個入口會用到它?

【劃重點】

登入只回答「你是誰」。把服務的規則寫成一張表,AI 寫的程式碼要一格一格對答案;每條規則只定義一次,寫入和讀取都要擋,數量上限要在並行請求下也成立。

參考資料


上一篇
D03 瀏覽器看得到的,全都是公開的
下一篇
D05 資料庫直接開給前端的那一刻
系列文
AI 寫的程式,安全嗎?完工不是結束,是攻擊倒數的起點5
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言