AIDive

影片資料包

REA 自我審查資源包:確切的 prompt、分類表,以及我們送出的 Worker 修復

閱讀約 8 分鐘

TL;DR 與結論

  • 在別人動手之前,先把 REA 對準你自己已發布的 App,而且要在一次性沙盒裡做。REA 自己的安全說明就說它不是沙盒,所以放著你金鑰的筆電是錯誤的地方。
  • 我們對 aidive.dev 的審查得到 15 項發現。真正的洩漏是一個沒有煞車的訂閱端點:任何網站都能讓它的訪客的瀏覽器把任意信箱訂閱到我們的電子報名單。
  • 六條規則:在傷不到你的地方執行、只給一個確切網址並封鎖所有寫入、先分類每項發現再修、檢查你的 bundle 送出了什麼、替每個開放端點加煞車,最後在送出 pull request 之前重跑並手動檢查。
  • 資源包裡有我們用的兩個 prompt(prompt-1-audit.md、prompt-2-fix.md)、我們真實的分類表(triage-table-example.md)、Worker 的變更(worker-fix.diff)、安全標頭(_headers)以及 bundle 檢查(check-client-bundle.mjs)。
  • 只審查你擁有或獲授權檢查的軟體。這是 REA 自己的免責聲明,而把它對準自己仍在範圍之內。

有來源的綜合整理

REA 是什麼,以及如何安裝。 我們執行時的最新版本是 rea-agents 6.5.0,日期為 2026-10-14 s22。安裝指令是 npx rea-agents setup --client claude_code,需要 Node 22.19 或更新版本 s1。請固定版本:它大約一週內發了六個版本。只從官方套件安裝,因為 repo 的假冒副本已經流傳過。

它會載入很多工具。 維護者表示這個伺服器公開 172 個工具與約 520 KB 的 schema,而設定 REA_MCP_INPUT_SCHEMA_PROFILE=compact 就是解決 context 被灌爆的辦法 s7。

規則 1,在傷不到你的地方執行。 安全說明講得很直接:"This is not a sandbox" s4。免責聲明把使用限定在你擁有或獲授權檢查的軟體 s1。在我們試用的雲端沙盒裡,全新的 App 在 4 分 30 秒內就有了可用的即時預覽,而測試證據留在 session 裡,沒有進到 pull request s5。如果全新的 Next.js App 在預覽網址後面畫面出現了卻沒有任何反應,原因是開發 origin 被擋,解法是加一行 allowedDevOrigins s15。

規則 2,只給一個確切網址。 對網站來說,你要自己在 loopback 除錯埠上啟動瀏覽器,REA 透過 CDP 連上去。它從不自己啟動瀏覽器。儲存值、標頭與 JSON 本文會被遮蔽,但腳本文字與查詢字串不會 s2。如果你只說「我的 App」,skill 會問你是哪一個。

規則 3,先分類再修。 skill 要求的正是這件事:"Every conclusion must distinguish observations, inferences, and unknowns. Cite Evidence IDs" s3。我們的分類表在你讀完原始碼後再多一輪:真正的洩漏、刻意的設計,或誤報。

規則 4,由瀏覽器執行的門檻只是個建議。 OWASP 是這麼說的:"Access control is only effective in trusted server-side code" s11。對一個內容本來就在頁面裡的情況,localStorage 旗標是 UX 的選擇,不是存取控制。請明確決定它到底是洩漏,還是刻意的軟性請求。

規則 5,每個會花別人錢的開放端點都要有限制。 OWASP 把對第三方服務缺少花費上限,列為 API 有弱點的方式之一 s12。Cloudflare 的速率限制 binding 接受 10 或 60 秒的週期,文件稱它「permissive, eventually consistent」,並不是精確的計量系統 s13。對於有人提交他人信箱的情況,Brevo 的雙重確認端點是更強的答案 s14。

規則 6,手動檢查重跑的結果。 一項在桌面 App 上對 REA 的實測,回報了 0 個 preload 進入點,但該 App 其實有 2 個 s6。所以在你自己用 curl 重現每項修復之前,綠色的重跑結果證明不了多少。

我們在自己網站上發現了什麼。 這些數字來自我們自己的執行,不是來源。

  • 修復前的審查花了 12 分 41 秒,沒有送出任何寫入請求。
  • 瀏覽器守衛擋下分析腳本的 6 次嘗試,沒有任何一個到達訂閱表單。
  • 網站設定被送給每位訪客,包括電子報名單 ID 與留言機器人的限制:每日 100 則回覆的上限,以及 5 次錯誤就觸發安全開關。
  • 修復 PR 讓 /api/subscribe 在 Origin 不對時回 403,在每個用戶端 IP 每 60 秒超過 5 個請求時回 429,沒有同意時回 400。
  • 一旦只匯入名稱與語系(532 位元組),每個頁面的用戶端 JavaScript 就少了 78,953 位元組。
  • Worker 測試 9/9 通過。先前的 Worker 有 6 項失敗。
  • 免費資源包上的 email 門檻是刻意保留的。資源包本來就免費且公開,壞掉的表單絕不能把它們藏起來。

程式碼

  • prompt-1-audit.md

We own aidive.dev and this repo is its source (the Next.js site in engine/site, the Cloudflare Worker in engine/site/worker). Audit what the SHIPPED site gives away to an outsider, with REA (Reverse Engineer Anything, github.com/morluto/rea). Install it only from the official npm package, rea-agents.

1. Install: npm i -g rea-agents@6.5.0, then rea setup --skill-client claude_code --yes --json, with REA_MCP_INPUT_SCHEMA_PROFILE=compact. If REA's MCP tools do not load in this session, use the rea CLI equivalent of each tool.
2. Start headless Chromium with --remote-debugging-port=9222 --remote-debugging-address=127.0.0.1 --user-data-dir=/tmp/rea-chrome, and open https://aidive.dev/club/ then one free resource page under https://aidive.dev/club/free-resources/.
3. Run REA on those pages: list_browser_targets, inspect_web_page, analyze_web_bundle, capture_web_screenshot. Export the scripts to a new directory outside the repo.
4. Write audit/rea-before.md: one row per finding with its Evidence ID, what it is, whether it is an observation, an inference or an unknown, and how bad it would be. Do not open the source before this step is done: the report is what a stranger sees.
5. Only then read the source and add a column per finding: real leak, by design, or false positive, with the file it comes from.

Rules: never send a POST or any write request to aidive.dev (it is production and the form writes to a real mailing list). Change no app code in this chat yet. Note how long each step took at the end of the report. Commit audit/rea-before.md.

prompt-1-audit.md on GitHub

:審查對話用的 prompt。一個確切網址、封鎖所有寫入、在打開原始碼之前先寫好報告。 -

prompt-2-fix.md

Good report. Now fix the real leaks from audit/rea-before.md in this branch, then prove it.

1. F06, F05, S3 in engine/site/worker/index.ts: reject a request whose Origin header is not https://aidive.dev (403); add a Cloudflare Workers Rate Limiting binding (ratelimits in wrangler.template.jsonc and in the jsonc that scripts/channel.mjs writes, keyed on the client IP, 5 requests per 60 s, 429 above); require a consent flag in the body, send it from the email gate form, and store the consent time with the contact; drop updateEnabled.
2. F08: the browser must only get what it reads. Split the config so client components (email-gate, locale-switcher) import a small client-safe object (name and locales), and keep club, autoReply, contentDir and the other locales' copy out of the client bundle.
3. U1: add security headers for the static assets (a _headers file in public/: Content-Security-Policy with frame-ancestors 'none', X-Content-Type-Options, Referrer-Policy, Strict-Transport-Security), and check the pages still render.
4. F01, F02, F03, S1, S2: the club email gate is a deliberate soft ask (the packs are free and public on GitHub, and a broken form must never hide them). Do not move content behind the server. Write that decision in audit/decisions.md, finding by finding. Same for F13 (the Cloudflare beacon is a dashboard setting, not the repo).
5. Run tsc and lint, then pnpm build. Test the Worker locally only (wrangler dev serving out/): a POST without the right Origin gets 403, a sixth POST within 60 s gets 429, a POST without consent gets 400, a valid one gets 204 with Brevo mocked. Never call aidive.dev or the real Brevo API.
6. Re-run the same REA steps against the local build and write audit/rea-after.md with the same table and a before/after column. Check each fixed finding by hand with curl too, because REA can miss things.
7. Commit and open a pull request whose body links both reports and lists what changed, what stays by design, and what is still unknown. Note the timings again.

prompt-2-fix.md on GitHub

:修復對話用的 prompt。Origin 檢查、速率限制、同意旗標、設定拆分、標頭,以及只在本機執行的測試計畫。 -

triage-table-example.md

# REA audit of aidive.dev: what the shipped site gives away

Date: 2026-10-17 (UTC). Tool: REA `rea-agents@6.5.0`, installed from npm.
Targets: `https://aidive.dev/club/` and `https://aidive.dev/club/free-resources/claude-code-limit-cut-levers/`.

The first five columns of the findings table are the outsider view. They were written from REA evidence
and the shipped scripts only, before any source file of this repository was opened
(commit `7e10d79` has that version alone). The last two columns, **Verdict** and **Source**, were added in step 5 after reading the source.

## Summary

- **The email gate does not protect the packs.** Each pack reaches every visitor in three ways without an email:
  the page HTML (F01), a raw markdown copy at `/club/free-resources/<slug>.md` (S1), and the pack files under `/files/` (S2).
  The gate is a client-side overlay over a static export (F02, F03).
- **`/api/subscribe` writes to the real Brevo list with no brake.** It has no rate limit, no CAPTCHA, no Origin check and no double opt-in, and it records no consent (F05, F06).
  Any website can make its visitors' browsers subscribe any address.
- **The whole site config ships to the browser** (F08). This includes values that are not public elsewhere, because this repository is private:
  the Brevo list ID, the YouTube auto-reply limits and the build layout.
- **No response security headers** (U1, now real): no CSP, HSTS, X-Frame-Options, nosniff or Referrer-Policy.
- **Clean:** no source maps, no secrets or API keys in the bundles, no storage, no cookies, and nothing in the console.

## Evidence

| ID | REA operation | Target | Evidence ID |
|----|---------------|--------|-------------|
| E1 | `list_browser_targets` | browser | `ev_bb5764a587b84a36cb11d40f6cd96b2acea26e1e412d0b1d114d2950dd16aab0` |
| E2 | `inspect_web_page` | /club/ | `ev_d84a8cc1436925f002108882c2472e47e744b42097ea8a1c7ff4b03748c329e3` |
| E3 | `inspect_web_page` | free resource | `ev_f08acdc2dba3b837ed1df24b57c80c9528982f9cac80eb5c042963573c743a0b` |
| E4 | `analyze_web_bundle` | /club/ | `ev_ab551792ba284eef2b91e5e43ddc618c92baadcea1050b9bf117bc9bd32ab98a` |
| E5 | `analyze_web_bundle` | free resource | `ev_9e24cc703943623efa2f31750c89ca27ccd5b95a55225bef7be0257a80cfb16f` |
| E6 | `capture_web_screenshot` | /club/ | `ev_c029b86ad2b4a92a0ef45b529691c4bc3f1d1a89b0e340cc0d05f6d51003f65e` |
| E7 | `capture_web_screenshot` | free resource | `ev_72b418d989c042d6320c5473f7d31acd660b242213fea09c97fd33a678bd756e` |
| E8 | `export_web_scripts` | /club/ capture (E2) | `ev_397383a4930fe6fbd9bf7568856f9e9f52d32b4bceba66e348e1738110e421a4` |
| E9 | `export_web_scripts` | free resource capture (E3) | `ev_7b2177f6a302c5971df051a01970d8c63d29286d8a47244fd6c4dacfc59735e3` |
| G1 | not REA: request log of the CDP write guard (see Method) | both pages | `/tmp/rea-audit/guard/requests.jsonl` |
| H1 | not REA, step 5: HEAD requests and anonymous GitHub API reads (see Method) | listed per row | , |

Chunk names below are the shipped file names under `https://aidive.dev/_next/static/chunks/`, as exported by E8 and E9.
Source paths are relative to the repository root. `engine/site/` is shortened to `site/`.

## Findings

Kind: **Observation** = seen directly in the evidence. **Inference** = concluded from what was seen.
**Unknown** = REA could not see it. Severity is "how bad it would be" for the site: High, Medium, Low, Info, None.

Verdict (step 5): **Real leak** = the source confirms it and nothing shows it is intended.
**By design** = the source shows it is intended or unavoidable. **False positive** = the source contradicts the outsider reading.

| # | Evidence | Finding | Kind | Severity | Verdict (step 5) | Source |
|---|----------|---------|------|----------|------------------|--------|
| F01 | E3, E7, E9 (inline script `source-1`) | The whole gated pack ships to every visitor. The page HTML carries the article (TL;DR, measurements table, "Do this Monday" checklist, sources, FAQ) in its inline RSC payload. While the email dialog is open, the accessibility tree exposes about 11.6 KB of that text (13 headings, 60 table cells). The screenshot shows it only blurred. View source, reader mode, a screen reader, `curl` or JS turned off all read it without an email. | Observation | High (the email gate protects nothing) | **Real leak.** The site is a static export, and the pack is rendered at build time into the HTML. The gate can only hide it, not withhold it. | `site/next.config.ts` (`output: 'export'`), `site/src/lib/resources.ts` (`renderResourceHtml`), `site/src/components/pages/resource-page.tsx`, `site/src/app/(default)/club/free-resources/[slug]/page.tsx` |
| F02 | E9, chunk `1t27yr-aialmx.js` (`EmailGate`) | The gate is client-side only. It opens 1500 ms after load unless `localStorage[SITE.name.toLowerCase() + ":club:unlocked"] === "1"`. With `SITE.name = "AIDive"` (F08) the key is `aidive:club:unlocked`. One line in the console removes the gate for good. | Observation (key value: Inference) | Medium (the mechanism behind F01) | **Real leak** (same root cause as F01). Key confirmed: `aidive:club:unlocked`. | `site/src/components/email-gate.tsx` (`KEY`, `DELAY_MS`, `readUnlocked`) |
| F03 | E9, `1t27yr-aialmx.js` | The gate fails open. A network error, a 6 s timeout, or any response other than `400 {error: "invalid" or "disposable"}` unlocks the page (`console.warn("subscribe failed, unlocking")`). Blocking `/api/subscribe` in DevTools unlocks without an email. | Observation | Low (F01 already exposes the content) | **By design.** The code comment says: "a broken form must never hide a free page". The Worker's `405`, `502 upstream` and `503 disabled` answers also unlock. | `site/src/components/email-gate.tsx`, `site/worker/index.ts` |
| F04 | E4, E5 (endpoint candidate), E9 | The subscribe contract is readable: `POST /api/subscribe`, JSON `{email, lang, source}`, `source = location.pathname + location.search`. The server answers `invalid` and `disposable`, so a disposable-domain filter exists. | Observation | Low (gives a script writer the exact contract) | **By design** (a public form needs a public endpoint). Note: the disposable list has only 10 domains. | `site/worker/index.ts`, `site/worker/disposable-domains.ts` |
| F05 | E9, `1t27yr-aialmx.js` | The request has no consent field. The "I agree to receive AIDive emails" checkbox is only HTML `required`. A scripted call and a checked box look the same to the server. No consent text version or timestamp comes from the client. | Inference | Medium (proof of consent depends on server logging, see U3) | **Real leak** (a confirmed gap, not a data leak). The Worker sends Brevo only `email`, `listIds`, `LANG` and `SOURCE`. It stores no consent flag, timestamp or consent text version. | `site/worker/index.ts` |
| F06 | E9, `1t27yr-aialmx.js` | No anti-bot or anti-CSRF token goes with the subscribe call: no Turnstile, reCAPTCHA or hCaptcha, no CSRF token. Anyone can script the subscription of third-party addresses into the real mailing list. | Inference (server-side rate limits/double opt-in: U3) | Medium to High without rate limit or double opt-in, Low with them (see U3) | **Real leak; severity High.** No rate limit, no Origin check and no double opt-in: the contact is created directly with `POST /v3/contacts`. `request.json()` parses the body whatever its content type, so a cross-site `text/plain` POST needs no CORS preflight. Any website can make its visitors' browsers subscribe any address. Read from the code only; no POST was sent. | `site/worker/index.ts` |
| F07 | E9, `1t27yr-aialmx.js` | `source` takes the query string as is. Any visitor controls a free-text value stored with their subscription, for example `?x=anything`. | Inference | Low (pollution of list attributes) | **By design.** The query string carries UTM attribution: the `/r/<code>` short links add `utm_*`. The Worker cuts `SOURCE` to 120 characters. Free text is still stored. | `site/scripts/emit-md.mjs` (`_redirects`), `site/worker/index.ts` |
| F08 | E8, E9, chunk `3f4cx_6a3wsgs.js` | The whole site config ships as one `JSON.parse('…')` in a shared chunk loaded on both pages: about 59 KB of JSON, with the copy for 15 locales. Public values in it: name, URL, `email: contact@aidive.dev`, YouTube channel, founding date, timezone `Europe/Paris`. Operational values: F08a to F08d. | Observation | Low (Info for the public values) | **Real leak.** `site.ts` imports all of `generated/site.json`. Two client components, `email-gate.tsx` and `locale-switcher.tsx`, import `SITE`, so the bundler ships every key. They use only `name` and the locales. | `site/src/lib/site.ts`, `site/src/components/email-gate.tsx`, `site/src/components/locale-switcher.tsx`, generated by `site/scripts/channel.mjs` from `channel.config.json` |
| F08a | E8, E9, `3f4cx_6a3wsgs.js` | `club.repo = {owner: "aidive-yt", name: "aidive-resources", commitAs: {name: "AI Dive", email: "339050828+aidive-yt@users.noreply.github.com"}}`. It names the repository where the packs probably live, plus the bot commit identity and its GitHub numeric user ID. | Observation | Medium if the repository is public (all packs readable, see U4). Low if private (reconnaissance only) | **By design.** `aidive-yt/aidive-resources` is public (H1: anonymous GitHub API 200). Pack pages link each file "on GitHub". `commitAs` is not used by the site, but the same noreply address is on every public commit of that repo. | `site/src/lib/resources.ts` (`renderResourceHtml`), `channel.config.json` (`club.repo`) |
| F08b | E8, E9, `3f4cx_6a3wsgs.js` | `club.brevoListId = 3`. It confirms the email provider and the target list. | Observation | Low (useless without the Brevo API key) | **Real leak** (Low). Needed only at build time, to write `BREVO_LIST_ID` into `wrangler.jsonc`. The client never reads it. | `channel.config.json`, `site/scripts/channel.mjs` (`club: site.club ?? null`, verbatim) |
| F08c | E8, E9, `3f4cx_6a3wsgs.js` | `club.autoReply.youtube = {dailyCap: 100, breakerErrors: 5}`. It reveals a YouTube auto-reply bot, its daily cap and its circuit-breaker threshold. | Observation | Low (someone who can cause 5 errors knows how to trip the breaker: Inference) | **Real leak** (Low). No code in `engine/site` reads `autoReply`. It reaches the client only because `channel.mjs` copies the club block verbatim. | `channel.config.json`, `site/scripts/channel.mjs` |
| F08d | E8, E9, `3f4cx_6a3wsgs.js` | `contentDir = "../../channel/site/content"`. It shows the build-time monorepo layout. | Observation | Info | **Real leak** (Info). Used only at build time and on the server. | `site/scripts/channel.mjs`, read by `site/src/lib/content.ts` and `site/src/lib/resources.ts` |
| F08e | E4, E8, `3f4cx_6a3wsgs.js` | Every first load downloads the copy for all 15 locales (78 KB raw chunk), whatever the page language. | Observation | Low (performance, not security) | **Real** (performance, same root cause as F08). | `site/src/lib/site.ts`, `site/scripts/channel.mjs` (`copy`) |
| F09 | E4, E5 | No source maps. None of the 19 (club) and 10 (free resource) analyzed scripts declares a `sourceMappingURL`. `source_maps.requested = 0`. | Observation | None (good) | **By design.** `productionBrowserSourceMaps` is not set, and its default is off. | `site/next.config.ts` |
| F10 | E4, E5 (`vendor_fingerprint`) | Stack fingerprint: Next.js (App Router, Turbopack build, RSC payloads) and React. | Inference (REA confidence high and medium) | Info | **By design** (it cannot be hidden in practice). | `site/package.json` |
| F11 | E2, E3 (storage, workers, console) | No localStorage, sessionStorage, IndexedDB or Cache Storage entry, and no worker, before unlock. The console stayed silent during the observation window. | Observation | None (good) | **By design.** The only storage write is the unlock key, after the form (F02). No cookie code exists, and `/club/` sends no `Set-Cookie` (H1). | `site/src/components/email-gate.tsx` |
| F12 | E3 (`dom_urls`) | The gated page links 14 other locale versions (`/fr/`, `/de/`, …, `/zh-TW/`) and `/videos/<slug>/`. Each locale probably ships the same pack under the same gate. | Observation (links), Inference (same exposure) | Info (multiplies F01) | **By design** (i18n). The same exposure as F01 applies to every locale. | `site/src/app/[lang]/club/free-resources/[slug]/page.tsx` |
| F13 | G1; E2, E3 (1 script excluded as other origin) | Each page view loads `static.cloudflareinsights.com/beacon.min.js` and tries `POST /cdn-cgi/rum` (Cloudflare Web Analytics). The privacy copy shipped in `3f4cx_6a3wsgs.js` covers only the unlock flow and does not name it. | Observation (requests), Inference (privacy gap) | Low | **Real leak** (privacy notice gap), **not from the repo.** The layouts render the beacon only when `SITE.club.analyticsToken` is set, and it is not set. The beacon comes from Cloudflare's automatic Web Analytics injection on the zone (dashboard setting). | `site/src/app/(default)/layout.tsx`, `site/src/app/[lang]/layout.tsx` (tag off); notice in `channel/site/copy/<locale>.json` |
| F14 | G1 | Next.js prefetch exposes the route tree: `HEAD` and `__next._tree.txt` / `__next.!KGRlZmF1bHQp….__PAGE__.txt` requests for `/`, `/club/`, `/about/`, `/contact/`, `/club/free-resources/` and the resource slugs. The dynamic segment is `$d$slug` (`[slug]`). | Observation | Info (public routes only) | **By design** (Next.js `<Link>` prefetch, public routes only). | `site/src/app/` route tree |
| F15 | E9, `3f4cx_6a3wsgs.js` and `1t27yr-aialmx.js` | The privacy copy says it stores "the address of that page". The client sends the path and the query string (UTM tags, referral codes, anything). | Inference | Low | **Real leak** (Low, notice wording). `SOURCE` holds up to 120 characters of path and query string. | `channel/site/copy/en.json` (and the 14 other locales), `site/src/components/email-gate.tsx`, `site/worker/index.ts` |
| U1 | E2, E3 (`metadata.responses` empty, `attach_limited`) | Response headers (CSP, HSTS, X-Frame-Options, cache) were not captured, because REA attaches after the page loads. | Unknown | Not rated | **Resolved: real leak, Low to Medium.** H1 `HEAD /club/` returns only `cache-control: public, max-age=0, must-revalidate` and `server: cloudflare`: no CSP, HSTS, X-Frame-Options, X-Content-Type-Options or Referrer-Policy. The subscribe form can be framed. | No `_headers` file in the repo; `site/wrangler.jsonc` sets none |
| U2 | E3 (`dom_urls`) | `/llms.txt` and `/feed.xml` are linked from the page. REA did not fetch them. If they carry the pack bodies, they are one more way past the gate. | Unknown | High if they carry pack bodies | **Resolved: false positive** for these files. `llms.txt`, `llms-full.txt` and `feed.xml` list articles and link pack pages, but carry no pack bodies. The same build script does publish the bodies elsewhere: see S1. | `site/src/app/llms.txt/route.ts`, `site/src/app/feed.xml/route.ts`, `site/scripts/emit-md.mjs` |
| U3 | E4, E5 (limitation: "server-side behavior may remain unknown") | Server side of `/api/subscribe`: rate limit, double opt-in, consent logging, disposable list, what reaches Brevo. It cannot be tested without a POST, and POSTs to production are not allowed here. | Unknown | Decides F05 and F06 | **Resolved from source:** none of the four protections exist (F05, F06). `updateEnabled: true` lets anyone overwrite `LANG` and `SOURCE` on an existing contact (S3). | `site/worker/index.ts` |
| U4 | F08a | Whether `aidive-yt/aidive-resources` is public. | Unknown | Decides F08a | **Resolved: public**, and linked from the pack pages (F08a). | H1; `site/src/lib/resources.ts` |
| U5 | E2, E3 (limitations) | Traffic before REA attached (first document, cookies) is not in the REA evidence. REA keeps no cookie or header values. | Unknown | Not rated | **Resolved for cookies: none.** There is no cookie code in `site/src` or `site/worker`, and `/club/` sends no `Set-Cookie` (H1). The guard log G1 holds the full request list. | `site/src`, `site/worker` |

### Step 5 additions: outsider-reachable, found by reading the source

REA did not observe these. Each was confirmed on production with a HEAD request only (H1).

| # | Finding | Severity | Verdict | Source |
|---|---------|----------|---------|--------|
| S1 | `/club/free-resources/<slug>.md` serves each published pack as markdown, with every `{{file:…}}` inlined, and no gate. `HEAD /club/free-resources/claude-code-limit-cut-levers.md` returns `200 text/markdown`. The path follows the same pattern as the `/videos/<slug>.md` alternates that `llms.txt` advertises, so it is easy to guess. | High (the whole pack in machine-readable form) | **Real leak** | `site/scripts/emit-md.mjs` (writes `out/club/free-resources/<rslug>.md`) |
| S2 | Pack files are static assets at `/club/free-resources/<pack>/files/<name>`, with no gate. The gated page lists their download links in its DOM. `HEAD …/haiku-5-5-claude-code-routing/files/README.md` returns `200`. | Medium (the same files are public on GitHub, F08a) | **Real leak** (consistent with the public repo, but it defeats the gate) | `site/scripts/sync-assets.mjs`, `site/src/components/pages/resource-page.tsx` |
| S3 | The Worker sends Brevo `updateEnabled: true`. Resubmitting someone's address overwrites their `LANG` and `SOURCE` attributes. | Low | **Real leak** | `site/worker/index.ts` |

## Method

1. `npm i -g rea-agents@6.5.0`. The npm metadata points at `github.com/morluto/rea` and has no install hooks.
   `REA_MCP_INPUT_SCHEMA_PROFILE=compact rea setup --skill-client claude_code --yes --json` installed only the
   `reverse-engineer-anything` skill. It registers no MCP server, so REA's MCP tools did not load in this session.
   Every tool ran as its CLI equivalent: `rea list-browser-targets`, `rea inspect-web-page`, `rea analyze-web-bundle`,
   `rea capture-web-screenshot` and `rea export-web-scripts`.
2. Headless Chromium 156 ran with `--remote-debugging-port=9222 --remote-debugging-address=127.0.0.1 --user-data-dir=/tmp/rea-chrome`.
3. **No write reached aidive.dev.** A small CDP guard attached to every tab before it navigated.
   It let only GET, HEAD and OPTIONS through and failed every other method. It blocked 6 `POST /cdn-cgi/rum`
   attempts (the Cloudflare beacon) over two browser runs. No request to `/api/subscribe` was made,
   and no non-GET/HEAD/OPTIONS request reached aidive.dev.
   The first run had a guard bug and opened `/club/` three times (GET only). Chromium was restarted from a clean profile for the evidence above.
4. REA runs: `inspect-web-page` with `--observation-ms 3000 --include-accessibility-text --include-console-text --include-json-body-shapes --include-script-sources --include-storage-keys`.
   `analyze-web-bundle` with `--observation-ms 5000 --fetch-source-maps`. All runs were limited to `--allowed-origins https://aidive.dev`.
5. Scripts were exported to `/tmp/rea-audit/scripts-club/` and `/tmp/rea-audit/scripts-free/`, outside the repository.
   They were read with `grep` and a parser only, never executed.
6. Repository contact before step 5: one directory listing at the start of the session showed file names
   in `engine/site/` and `engine/site/worker/` (`disposable-domains.ts`, `index.ts`). No file content was opened.
7. H1 checks in step 5, all read-only: `HEAD` on `/club/`, `/club/free-resources/claude-code-limit-cut-levers.md`,
   `/club/free-resources/haiku-5-5-claude-code-routing/files/README.md` and `/llms-full.txt`.
   Anonymous `GET` on `api.github.com/repos/aidive-yt/aidive-resources` returned 200 (public).
   Anonymous `GET` on `api.github.com/repos/aidive-yt/aidive-site` returned 404 (private to outsiders).
   Because this repository is private, the F08 values are not public anywhere else.
8. No application code was changed.

## Timings

| Step | What | Time |
|------|------|------|
| 1 | Check the npm package, install REA, `rea setup` | 36 s |
| 2 | Write the CDP write guard, start Chromium, open both pages (includes one restart after the guard bug) | 1 min 47 s |
| 3 | REA: targets, inspect ×2, bundle ×2, screenshot ×2, export ×2, plus reading the evidence | 4 min 16 s |
| 4 | Write the outsider report | 1 min 32 s |
| 5 | Read the source, H1 checks, add the Verdict and Source columns | 4 min 30 s |
| | **Total** | **12 min 41 s** |

triage-table-example.md on GitHub

:我們真實的分類表,含證據 ID、觀察或推論或未知,以及每項發現的判定。 -

worker-fix.diff

diff --git a/engine/site/worker/index.ts b/engine/site/worker/index.ts
index 994f12fc..e68444e7 100644
--- a/engine/site/worker/index.ts
+++ b/engine/site/worker/index.ts
@@ -1,13 +1,19 @@
 import { DISPOSABLE } from './disposable-domains';
 
 // Minimal local Workers types: no @cloudflare/workers-types dependency.
-type Env = {
+type RateLimiter = { limit: (options: { key: string }) => Promise<{ success: boolean }> };
+
+export type Env = {
   ASSETS: { fetch: (req: Request) => Promise<Response> };
   BREVO_API_KEY: string;
   BREVO_LIST_ID: string;
+  // The only origin allowed to POST to /api/subscribe, e.g. "https://example.com".
+  SITE_ORIGIN: string;
+  // 5 requests per 60 s per client IP (wrangler `ratelimits`).
+  SUBSCRIBE_LIMITER: RateLimiter;
 };
 
-type SubscribeBody = { email?: string; lang?: string; source?: string };
+type SubscribeBody = { email?: unknown; lang?: unknown; source?: unknown; consent?: unknown };
 
 const EMAIL = /^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/;
 
@@ -20,6 +26,12 @@ const worker = {
     const url = new URL(request.url);
     if (url.pathname !== '/api/subscribe') return env.ASSETS.fetch(request);
     if (request.method !== 'POST') return new Response(null, { status: 405 });
+    // Browsers always send Origin on a POST: this stops other sites from making
+    // their visitors subscribe addresses. A script can forge it; the rate limit is for that.
+    if (!env.SITE_ORIGIN || request.headers.get('origin') !== env.SITE_ORIGIN) return json(403, { error: 'origin' });
+    const ip = request.headers.get('cf-connecting-ip') ?? 'unknown';
+    const { success } = await env.SUBSCRIBE_LIMITER.limit({ key: ip });
+    if (!success) return json(429, { error: 'rate' });
     // No list id means the club is off for this channel.
     if (!env.BREVO_LIST_ID) return json(503, { error: 'disabled' });
     let body: SubscribeBody;
@@ -30,6 +42,8 @@ const worker = {
     } catch {
       return json(400, { error: 'invalid' });
     }
+    // The form's checkbox must be ticked: only a literal `true` counts as consent.
+    if (body.consent !== true) return json(400, { error: 'consent' });
     const email = (typeof body.email === 'string' ? body.email : '').trim().toLowerCase();
     const source = typeof body.source === 'string' ? body.source : '';
     if (!EMAIL.test(email)) return json(400, { error: 'invalid' });
@@ -37,14 +51,14 @@ const worker = {
     const lang = typeof body.lang === 'string' && /^[a-z]{2}(-[A-Za-z]{2,4})?$/.test(body.lang) ? body.lang : 'en';
     let res: Response;
     try {
+      // No updateEnabled: a request can create a contact, never rewrite an existing one.
       res = await fetch('https://api.brevo.com/v3/contacts', {
         method: 'POST',
         headers: { 'api-key': env.BREVO_API_KEY, 'content-type': 'application/json', accept: 'application/json' },
         body: JSON.stringify({
           email,
           listIds: [Number(env.BREVO_LIST_ID)],
-          updateEnabled: true,
-          attributes: { LANG: lang, SOURCE: source.slice(0, 120) },
+          attributes: { LANG: lang, SOURCE: source.slice(0, 120), CONSENT_AT: new Date().toISOString() },
         }),
         signal: AbortSignal.timeout(5000),
       });
@@ -52,12 +66,14 @@ const worker = {
       console.error('brevo fetch', err);
       return json(502, { error: 'upstream' });
     }
-    // 201 created, 204 updated; anything else is Brevo's problem, logged for the dashboard.
-    if (res.status !== 201 && res.status !== 204) {
-      console.error('brevo', res.status, await res.text());
-      return json(502, { error: 'upstream' });
-    }
-    return new Response(null, { status: 204 });
+    // 201 created. An existing contact answers 400 duplicate_parameter: it is left
+    // untouched and the visitor gets the same 204, so the answer never tells whether
+    // an address is already on the list.
+    if (res.status === 201) return new Response(null, { status: 204 });
+    const detail = await res.text();
+    if (res.status === 400 && detail.includes('duplicate_parameter')) return new Response(null, { status: 204 });
+    console.error('brevo', res.status, detail);
+    return json(502, { error: 'upstream' });
   },
 };
 
diff --git a/engine/site/wrangler.template.jsonc b/engine/site/wrangler.template.jsonc
index e132f254..b007fd8e 100644
--- a/engine/site/wrangler.template.jsonc
+++ b/engine/site/wrangler.template.jsonc
@@ -3,7 +3,11 @@
   "main": "worker/index.ts",
   "compatibility_date": "2026-08-13",
   "compatibility_flags": ["nodejs_compat"],
-  "vars": { "BREVO_LIST_ID": "{{brevoListId}}" },
+  "vars": { "BREVO_LIST_ID": "{{brevoListId}}", "SITE_ORIGIN": "{{origin}}" },
+  // /api/subscribe: 5 requests per 60 s per client IP, 429 above. The counter is
+  // per Cloudflare location and eventually consistent. namespace_id is unique to
+  // this limiter in the account.
+  "ratelimits": [{ "name": "SUBSCRIBE_LIMITER", "namespace_id": "1001", "simple": { "limit": 5, "period": 60 } }],
   "assets": {
     "directory": "./out/",
     "binding": "ASSETS",

worker-fix.diff on GitHub

:我們送出的 Worker 變更,以 diff 呈現。 -

_headers

# Security headers for every static asset (Cloudflare Workers static assets).
# They do not cover responses the Worker builds itself (/api/subscribe).
# script-src needs 'unsafe-inline': the static export inlines Next's RSC payload
# scripts, and a static site cannot send per-request nonces.
# www.youtube.com: the iframe API and the embedded player (video pages).
# static.cloudflareinsights.com / cloudflareinsights.com: the Web Analytics
# beacon that Cloudflare injects from the dashboard setting (audit F13).
/*
  Content-Security-Policy: default-src 'self'; script-src 'self' 'unsafe-inline' https://www.youtube.com https://static.cloudflareinsights.com; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self'; connect-src 'self' https://cloudflareinsights.com; frame-src https://www.youtube.com; frame-ancestors 'none'; base-uri 'self'; form-action 'self'; object-src 'none'
  X-Content-Type-Options: nosniff
  Referrer-Policy: strict-origin-when-cross-origin
  Strict-Transport-Security: max-age=31536000

_headers on GitHub

:Cloudflare Workers 靜態資源用的安全標頭檔。 -

check-client-bundle.mjs

// Fails the build when the server-only channel config reaches the browser.
// Client components must import @/lib/site-client, never @/lib/site: one client
// import of @/lib/site ships the whole generated config (club settings, the
// build layout and every locale's copy) in a JS chunk. Run after `next build`.
import fs from 'node:fs';
import path from 'node:path';

const STATIC_DIR = path.resolve('out', '_next', 'static');
// Keys that exist only in src/generated/site.json, never in site-client.json.
const SERVER_ONLY_KEYS = ['"contentDir"', '"brevoListId"', '"autoReply"', '"commitAs"', '"spokenName"', '"foundingDate"'];

function* jsFiles(dir) {
  for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
    const full = path.join(dir, entry.name);
    if (entry.isDirectory()) yield* jsFiles(full);
    else if (entry.name.endsWith('.js')) yield full;
  }
}

if (!fs.existsSync(STATIC_DIR)) {
  console.error(`[check-client-bundle] ${STATIC_DIR} is missing: run next build first`);
  process.exit(1);
}
const leaks = [];
let files = 0;
for (const file of jsFiles(STATIC_DIR)) {
  files += 1;
  const text = fs.readFileSync(file, 'utf8');
  const found = SERVER_ONLY_KEYS.filter((key) => text.includes(key));
  if (found.length) leaks.push(`${path.relative(process.cwd(), file)}: ${found.join(', ')}`);
}
if (leaks.length) {
  console.error('[check-client-bundle] server-only config reached the client bundle:');
  for (const leak of leaks) console.error(`  ${leak}`);
  console.error('A client component imports @/lib/site (directly or through @/lib/i18n). Use @/lib/site-client and @/lib/locales.');
  process.exit(1);
}
console.log(`[check-client-bundle] ${files} client scripts, no server-only config`);

check-client-bundle.mjs on GitHub

:當僅限伺服器的設定 key 進入任何送出的檔案時,就讓建置失敗的步驟。 -

README.md

The REA self-audit kit: the two prompts we gave Claude Code in the sandbox (audit, then fix), our real triage table, and the Worker, header and bundle-check changes we shipped.

README.md on GitHub

:用一段話說明資源包裡有什麼。

週一就做

  • 在你的 repo 上開一個一次性沙盒或本機虛擬機,絕不要在自己的機器上執行審查。
  • 從官方套件安裝 REA,固定在單一版本,並開啟精簡 schema profile。
  • 用全新的 profile 在 loopback 除錯埠上啟動瀏覽器,並在任何頁面載入之前封鎖所有寫入請求。
  • 給 REA 一個確切網址並執行 prompt-1-audit.md。在打開任何原始碼之前先 commit 報告。
  • 把每項發現標成觀察、推論或未知,再標成真正的洩漏、刻意的設計或誤報。把刻意的設計連同其代價寫下來。
  • 在你送出的 JavaScript 裡搜尋你自己的設定 key,並把 check-client-bundle.mjs 加進建置。
  • 在每個呼叫付費服務的端點上加 Origin 檢查、速率限制與伺服器端的同意旗標。
  • 重跑同樣的 REA 步驟,用 curl 重現每項修復,然後把兩份報告放進本文後再開 pull request。

進一步了解

雙重確認。 從腳本偽造的 Origin 加上許多 IP,仍然可能訂閱其他人,因為 Cloudflare 的限流器是依地點計算,而且是最終一致 s13。Brevo 的雙重確認端點需要範本、重新導向網址與名單 ID,聯絡人要等到點擊之後才會加入名單 s14。我們沒有把它放進這次的 pull request。

Worker 回應上的回應標頭。 我們的 _headers 檔只涵蓋靜態資源。由 Worker 自己產生的回應,例如 /api/subscribe,不會套用那些標頭。請在 Worker 程式碼裡設定。我們的 script-src 也保留了 'unsafe-inline',這是靜態匯出所需要的。

隱私聲明。 我們的隱私文案說會儲存頁面的位址,但實際儲存的來源欄位也包含查詢字串。這需要修改每個語系檔的文字,也是一個不屬於程式碼的發現的好例子。如果你有從儀表板注入的分析 beacon,也要在隱私文案裡點名它。

我們留著沒處理的事。 在確認所有子網域都提供 HTTPS 之前,HSTS 不帶 includeSubDomains 或 preload。含有注入的分析 beacon 的 CSP 在本機無法觀察到,所以部署後請對線上網站再跑一次審查。

其他文章。 Hacker News 上關於 REA 的討論串有最尖銳的批評,也有為它辯護者的解讀:盡量把東西放在伺服器端 s8。有兩篇針對個人自己 App 的文章列出發現並提出修復,但沒有實際套用或重跑 s9 s10。

FAQ

對我自己的 App 執行 REA 合法嗎? REA 的免責聲明涵蓋對你擁有或獲授權檢查的軟體進行的合法分析 s1。你自己的網站符合。別人的不符合,除非對方以書面授權你。

為什麼不在我的筆電上執行? 專案的安全說明表示它不是沙盒 s4。它會操控瀏覽器並執行下載來的工具,所以用完即丟的機器能把任何損害留在裡面。我們用的沙盒 Kosuke 讓我們免費使用其最高階方案,但本機虛擬機或容器也能做同樣的事。如果你選 Kosuke,註冊後立刻在「Promo code」欄位輸入優惠碼 AIDIVE2026,就能獲得 30 美元的額度,而不是 10 美元。

我的免費內容上的 email 門檻該搬到伺服器嗎? 只有當內容原本不打算免費時才要。靜態頁面裡的內容不用門檻也讀得到,所以門檻只是客氣地請求,什麼也沒強制。我們刻意保留,並逐項發現寫下原因。付費或會員專屬的內容絕不能進入靜態匯出 s11。

單靠速率限制能堵住訂閱漏洞嗎? 不能。它能減緩洪水般的請求,但腳本可以偽造 Origin 標頭,而且限流器是依地點計算 s13。請搭配在伺服器檢查的同意,最好再加上雙重確認。

setup 之後看不到 REA 的工具,怎麼辦? 在我們的執行中,setup 只安裝了 skill,沒有安裝工具伺服器,Claude Code 用了每個工具的命令列版本。不要重新啟動,改在終端機呼叫同樣的步驟。

檔案