AIDive

Gói video

Bộ kit tự audit REA: prompt chính xác, bảng phân loại và bản sửa Worker đã phát hành

8 phút đọc

TL;DR và kết luận

  • Hãy chĩa REA vào app đã phát hành của chính bạn trước khi có người khác làm, và làm từ một sandbox dùng một lần. Ghi chú bảo mật của chính REA nói nó không phải sandbox, nên chiếc laptop chứa các khóa của bạn là chỗ sai.
  • Audit aidive.dev của tụi mình cho ra 15 finding. Rò rỉ thật là một endpoint đăng ký không có phanh: website nào cũng có thể khiến trình duyệt của người truy cập đăng ký một địa chỉ bất kỳ vào danh sách email của tụi mình.
  • Sáu quy tắc: chạy ở nơi nó không gây hại được, đưa cho nó một URL chính xác với mọi write bị chặn, phân loại mọi finding trước khi sửa, kiểm tra bundle của bạn phát hành gì, đặt phanh cho mọi endpoint mở, rồi chạy lại và tự kiểm tra trước khi gửi pull request.
  • Bộ kit gồm hai prompt tụi mình đã dùng (prompt-1-audit.md, prompt-2-fix.md), bảng phân loại thật của tụi mình (triage-table-example.md), thay đổi của Worker (worker-fix.diff), các security header (_headers) và bước kiểm tra bundle (check-client-bundle.mjs).
  • Chỉ audit phần mềm bạn sở hữu hoặc được phép kiểm tra. Đó là tuyên bố miễn trừ của chính REA, và chĩa nó vào chính mình nằm trong giới hạn đó.

Tổng hợp có nguồn

REA là gì và cài thế nào. Bản phát hành mới nhất vào thời điểm tụi mình chạy là rea-agents 6.5.0, ngày 2026-10-14 s22. Cài đặt là npx rea-agents setup --client claude_code, và cần Node 22.19 trở lên s1. Hãy ghim phiên bản: nó ra sáu bản phát hành trong khoảng một tuần. Chỉ cài từ package chính thức, vì các bản sao giả của repo đã lưu hành.

Nó nạp rất nhiều tool. Những người duy trì nói server cung cấp 172 tool và khoảng 520 KB schema, và đặt REA_MCP_INPUT_SCHEMA_PROFILE=compact là cách sửa cho context bị tràn s7.

Quy tắc 1, chạy ở nơi nó không gây hại được. Ghi chú bảo mật nói thẳng: "This is not a sandbox" s4. Tuyên bố miễn trừ giới hạn việc dùng ở phần mềm bạn sở hữu hoặc được phép kiểm tra s1. Trong lần thử sandbox đám mây của tụi mình, một app mới có preview chạy sau 4 phút 30, và bằng chứng kiểm thử nằm lại trong session thay vì đi tới pull request s5. Nếu một app Next.js mới hiện ra nhưng không có gì phản hồi sau một URL preview, nguyên nhân là chặn dev origin, và cách sửa là một dòng allowedDevOrigins s15.

Quy tắc 2, một URL chính xác. Với một website, bạn tự khởi động trình duyệt trên một loopback debug port và REA gắn vào qua CDP. Nó không bao giờ tự khởi chạy trình duyệt. Giá trị storage, header và JSON body được che đi, trong khi nội dung script và query string thì không s2. Nếu bạn chỉ nói "app của tôi", skill sẽ hỏi app nào.

Quy tắc 3, phân loại trước khi sửa. Skill yêu cầu đúng điều này: "Every conclusion must distinguish observations, inferences, and unknowns. Cite Evidence IDs" s3. Bảng phân loại của tụi mình thêm một lượt nữa sau khi bạn đọc source: rò rỉ thật, chủ đích thiết kế, hay false positive.

Quy tắc 4, một cổng do trình duyệt thực thi chỉ là một lời gợi ý. OWASP diễn đạt như sau: "Access control is only effective in trusted server-side code" s11. Một cờ localStorage đặt trên nội dung vốn đã nằm trong trang là một lựa chọn UX, không phải kiểm soát truy cập. Hãy quyết định rõ ràng đó là rò rỉ hay một lời nhắc nhẹ có chủ ý.

Quy tắc 5, mọi endpoint mở tiêu tiền của người khác đều cần một giới hạn. OWASP liệt kê việc thiếu giới hạn chi tiêu trên dịch vụ bên thứ ba là một cách khiến API dễ bị tấn công s12. Binding rate limit của Cloudflare nhận một chu kỳ 10 hoặc 60 giây, và tài liệu gọi nó là "permissive, eventually consistent" và không phải một hệ thống kế toán chính xác s13. Endpoint double opt-in của Brevo là câu trả lời mạnh hơn cho việc ai đó gửi địa chỉ của người khác s14.

Quy tắc 6, kiểm tra lần chạy lại bằng tay. Một thử nghiệm thực tế của REA trên app desktop báo 0 preload entrypoint trong khi app có 2 s6. Vì vậy lần chạy lại toàn xanh chứng minh rất ít cho tới khi bạn tự phát lại từng bản sửa bằng curl.

Những gì tụi mình tìm thấy trên site của mình. Các con số này đến từ lần chạy của chính tụi mình, không phải từ một nguồn.

  • Audit trước khi sửa mất 12 phút 41 giây và không gửi write request nào.
  • Guard của trình duyệt đã chặn 6 lần thử từ script analytics, và không cái nào chạm tới form đăng ký.
  • Config của site được phát hành tới mọi người truy cập, gồm ID danh sách email và các giới hạn của bot trả lời bình luận: mức trần hằng ngày 100 câu trả lời và 5 lỗi để ngắt công tắc an toàn.
  • PR sửa lỗi làm /api/subscribe trả 403 nếu thiếu Origin đúng, 429 khi vượt 5 request mỗi 60 giây cho mỗi IP client, và 400 khi thiếu consent.
  • Code client bớt đi 78,953 byte JavaScript mỗi trang khi chỉ import tên và locale (532 byte).
  • Test của Worker đạt 9/9. Worker trước đó thất bại 6 test.
  • Cổng email trên các gói miễn phí được giữ có chủ đích. Các gói này miễn phí và công khai, và một form hỏng không bao giờ được che chúng.

Code

  • 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 cho cuộc chat audit. Một URL chính xác, mọi write bị chặn, báo cáo được viết trước khi mở source. -

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 cho cuộc chat sửa lỗi. Kiểm tra Origin, rate limit, cờ consent, tách config, header và một kế hoạch kiểm thử chỉ chạy cục bộ. -

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

: bảng phân loại thật của tụi mình, với evidence ID, quan sát hoặc suy luận hoặc unknown, và kết luận cho từng finding. -

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

: thay đổi Worker tụi mình đã phát hành, dưới dạng 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

: file security header cho static asset trên 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

: một bước build thất bại khi một config key chỉ dành cho server lọt vào file đã phát hành. -

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

: bộ kit gồm những gì, trong một đoạn.

Làm vào thứ Hai

  • Mở một sandbox dùng một lần trên repo của bạn, hoặc một máy ảo cục bộ, và đừng bao giờ chạy audit trên máy của bạn.
  • Cài REA từ package chính thức, ghim một phiên bản, với schema profile compact.
  • Khởi động trình duyệt trên một loopback debug port với profile mới và chặn mọi write request trước khi bất kỳ trang nào tải.
  • Đưa cho REA một URL chính xác và chạy prompt-1-audit.md. Commit báo cáo trước khi mở bất kỳ file source nào.
  • Đánh dấu từng finding là quan sát, suy luận hoặc unknown, rồi rò rỉ thật, chủ đích thiết kế hoặc false positive. Ghi lại các mục chủ đích thiết kế cùng cái giá của chúng.
  • Tìm các config key của chính bạn trong JavaScript đã phát hành, và thêm check-client-bundle.mjs vào bước build.
  • Đặt kiểm tra Origin, rate limit và cờ consent phía server cho mọi endpoint gọi một dịch vụ trả phí.
  • Chạy lại cùng các bước REA, phát lại từng bản sửa bằng curl, rồi mở pull request với cả hai báo cáo trong phần mô tả.

Đi xa hơn

Double opt-in. Một Origin giả từ script cộng với nhiều IP vẫn có thể đăng ký người khác, vì bộ giới hạn của Cloudflare tính theo vị trí và eventually consistent s13. Endpoint double opt-in của Brevo cần một template, một URL chuyển hướng và một ID danh sách, và liên hệ chỉ vào danh sách sau khi bấm xác nhận s14. Tụi mình đã không phát hành nó trong pull request này.

Response header trên phản hồi của Worker. File _headers của tụi mình chỉ bao phủ static asset. Các phản hồi do chính Worker tạo ra, chẳng hạn /api/subscribe, không nhận các header đó. Hãy đặt chúng trong code của Worker. script-src của tụi mình cũng vẫn giữ 'unsafe-inline', thứ mà bản xuất tĩnh cần.

Thông báo quyền riêng tư. Nội dung quyền riêng tư của tụi mình nói nó lưu địa chỉ của trang, nhưng trường nguồn được lưu còn chứa cả query string. Đó là một thay đổi câu chữ trong mọi file locale, và là ví dụ tốt về một finding không phải code. Nếu bạn giữ một analytics beacon được chèn từ dashboard, hãy nêu tên nó trong nội dung quyền riêng tư nữa.

Những thứ tụi mình để ngỏ. HSTS chạy không có includeSubDomains hay preload cho tới khi biết mọi subdomain đều phục vụ HTTPS. CSP với analytics beacon được chèn không thể quan sát cục bộ, nên hãy chạy lại audit trên site thật sau khi deploy.

Các bài viết khác. Thread Hacker News về REA có những lời phê bình sắc nhất và một cách đọc từ phía người bênh vực: giữ càng nhiều càng tốt ở phía server s8. Hai bài viết thử trên app của chính tác giả liệt kê finding và đề xuất bản sửa mà không áp dụng hay chạy lại s9 s10.

FAQ

Chạy REA trên app của chính tôi có hợp pháp không? Tuyên bố miễn trừ của REA bao phủ việc phân tích hợp pháp phần mềm bạn sở hữu hoặc được phép kiểm tra s1. Site của chính bạn thì phù hợp. Site của người khác thì không, trừ khi họ cho phép bạn bằng văn bản.

Tại sao không chạy trên laptop của tôi? Ghi chú bảo mật của dự án nói nó không phải sandbox s4. Nó điều khiển một trình duyệt và chạy các tool tải về, nên một máy dùng một lần giữ mọi thiệt hại bên trong nó. Kosuke, sandbox tụi mình đã dùng, cho tụi mình dùng miễn phí gói cao nhất, nhưng một máy ảo cục bộ hoặc một container làm việc y hệt. Nếu bạn chọn Kosuke, hãy nhập mã AIDIVE2026 vào ô "Promo code" ngay sau khi đăng ký để nhận 30 đô la credit thay vì 10 đô la.

Có nên chuyển cổng email trên nội dung miễn phí của tôi sang server không? Chỉ khi nội dung đó không có ý miễn phí. Nội dung nằm sẵn trong trang tĩnh có thể đọc mà không cần cổng, nên cổng chỉ hỏi lịch sự và không thực thi gì. Tụi mình giữ cổng của mình có chủ đích và ghi lý do từng finding một. Nội dung trả phí hoặc dành riêng cho thành viên tuyệt đối không được lọt vào bản xuất tĩnh s11.

Chỉ rate limit có đóng được lỗ hổng đăng ký không? Không. Nó làm chậm một đợt tràn request, nhưng một script có thể giả header Origin và bộ giới hạn đếm theo vị trí s13. Hãy kết hợp nó với consent được kiểm tra trên server và, lý tưởng nhất, double opt-in.

Tool của REA không hiện ra sau khi setup. Làm gì bây giờ? Trong lần chạy của tụi mình, setup cài skill nhưng không cài tool server, và Claude Code dùng phiên bản dòng lệnh của từng tool. Hãy gọi các bước tương tự từ terminal thay vì khởi động lại.

Tệp