TL;DR와 결론
- 남이 하기 전에 REA를 배포된 내 앱에 겨누고, 일회용 샌드박스에서 하자. REA 자신의 보안 노트도 샌드박스가 아니라고 말하니, 키가 든 노트북은 맞지 않는 장소다.
- aidive.dev 감사에서 발견이 15개 나왔다. 진짜 유출은 브레이크 없는 가입 엔드포인트였다. 어떤 웹사이트든 방문자의 브라우저가 아무 주소나 우리 메일링 리스트에 구독시키게 만들 수 있었다.
- 여섯 가지 규칙: 피해가 없는 곳에서 돌리기, 쓰기를 모두 막고 정확한 URL 하나만 주기, 고치기 전에 모든 발견 분류하기, 번들에 실린 것 확인하기, 열린 엔드포인트마다 브레이크 걸기, 그리고 풀 리퀘스트 전에 다시 돌려 직접 확인하기.
- 키트에는 우리가 쓴 프롬프트 두 개(
prompt-1-audit.md,prompt-2-fix.md), 실제 분류 표(triage-table-example.md), Worker 변경(worker-fix.diff), 보안 헤더(_headers), 번들 검사(check-client-bundle.mjs)가 들어 있다. - 내가 소유했거나 검사할 권한이 있는 소프트웨어만 감사하자. 이것이 REA 자신의 면책 조항이고, 나 자신에게 겨누는 건 그 안에 있다.
출처 기반 종합
REA가 뭔지, 어떻게 설치하는지. 우리가 돌린 시점의 최신 릴리스는 2026-10-14자 rea-agents 6.5.0이었다 s22. 설치는 npx rea-agents setup --client claude_code이고 Node 22.19 이상이 필요하다 s1. 버전을 고정하자. 일주일 남짓 동안 릴리스가 여섯 번 나왔다. 공식 패키지에서만 설치하자. 저장소의 가짜 사본이 돌아다녔다.
도구를 많이 불러온다. 메인테이너들은 서버가 도구 172개와 스키마 약 520 KB를 노출하며, 컨텍스트가 넘칠 때는 REA_MCP_INPUT_SCHEMA_PROFILE=compact를 설정하는 것이 해결책이라고 말한다 s7.
규칙 1, 피해가 없는 곳에서 돌리기. 보안 노트는 분명히 말한다. "This is not a sandbox" s4. 면책 조항은 사용을 내가 소유했거나 검사할 권한이 있는 소프트웨어로 제한한다 s1. 우리가 클라우드 샌드박스를 시험했을 때 새 앱의 라이브 프리뷰는 4분 30초 만에 떴고, 테스트 증거는 풀 리퀘스트에 닿지 않고 세션 안에 남았다 s5. 새 Next.js 앱이 프리뷰 URL 뒤에서 화면은 그려지는데 아무것도 반응하지 않는다면, 원인은 개발 origin 차단이고 해결은 allowedDevOrigins 한 줄이다 s15.
규칙 2, 정확한 URL 하나. 웹사이트라면 루프백 디버그 포트에서 브라우저를 직접 시작하고, REA는 CDP로 붙는다. REA가 브라우저를 직접 띄우는 일은 없다. 스토리지 값, 헤더, JSON 본문은 가려지지만 스크립트 텍스트와 쿼리 스트링은 가려지지 않는다 s2. "내 앱"이라고만 말하면 스킬이 어떤 앱인지 되묻는다.
규칙 3, 고치기 전에 분류하기. 스킬은 정확히 이것을 요구한다. "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 속도 제한 바인딩은 기간으로 10초 또는 60초를 받고, 문서는 이를 "permissive, eventually consistent"라고 부르며 정확한 집계 시스템이 아니라고 한다 s13. 다른 사람의 주소를 제출하는 경우에는 Brevo의 더블 옵트인 엔드포인트가 더 강력한 답이다 s14.
규칙 6, 다시 돌린 결과를 직접 확인하기. 데스크톱 앱에 대한 REA 현장 테스트는 앱에 2개 있던 preload 진입점을 0개로 보고했다 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개에서 실패했다.
- 무료 팩의 이메일 게이트는 일부러 유지한다. 팩은 무료이고 공개이며, 고장 난 폼이 팩을 가려서는 안 되기 때문이다.
코드
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.
: 감사 채팅용 프롬프트. 정확한 URL 하나, 모든 쓰기 차단, 소스를 열기 전에 보고서 작성.
- 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.
: 수정 채팅용 프롬프트. 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 변경을 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
: 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
: 서버 전용 설정 키가 배포 파일에 닿으면 실패하는 빌드 단계. -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.
: 키트에 무엇이 들었는지 한 단락으로.월요일에 할 일
- 내 저장소에 일회용 샌드박스나 로컬 가상 머신을 열고, 감사를 내 컴퓨터에서는 절대 돌리지 않는다.
- 공식 패키지에서 REA를 한 버전으로 고정해 설치하고, 컴팩트 스키마 프로필을 켠다.
- 새 프로필로 루프백 디버그 포트에서 브라우저를 시작하고, 어떤 페이지가 로드되기 전에 모든 쓰기 요청을 차단한다.
- REA에게 정확한 URL 하나를 주고
prompt-1-audit.md를 돌린다. 소스 파일을 열기 전에 보고서를 커밋한다. - 발견마다 관찰, 추론, 미확인으로 표시한 뒤 진짜 유출, 의도된 설계, 오탐으로 표시한다. 의도된 설계는 비용과 함께 적어 둔다.
- 배포된 JavaScript에서 내 설정 키를 검색하고, 빌드에
check-client-bundle.mjs를 추가한다. - 유료 서비스를 호출하는 모든 엔드포인트에 Origin 확인, 속도 제한, 서버 측 동의 플래그를 건다.
- 같은 REA 단계를 다시 돌리고, 수정마다 curl로 재현한 뒤, 두 보고서를 본문에 넣어 풀 리퀘스트를 연다.
더 나아가기
더블 옵트인. 스크립트에서 위조한 Origin에 여러 IP가 더해지면 여전히 다른 사람을 구독시킬 수 있다. Cloudflare 리미터는 위치별이고 결과적 일관성이기 때문이다 s13. Brevo의 더블 옵트인 엔드포인트에는 템플릿, 리다이렉트 URL, 리스트 ID가 필요하고, 연락처는 클릭한 뒤에야 리스트에 들어간다 s14. 이번 풀 리퀘스트에서는 배포하지 않았다.
Worker 응답의 응답 헤더. 우리 _headers 파일은 정적 에셋만 다룬다. /api/subscribe처럼 Worker가 직접 만드는 응답에는 그 헤더가 붙지 않는다. Worker 코드에서 설정하자. 우리 script-src에도 정적 내보내기에 필요한 'unsafe-inline'이 남아 있다.
개인정보 안내. 우리 개인정보 문구는 페이지 주소를 저장한다고 말하지만, 저장되는 source 필드에는 쿼리 스트링도 들어 있다. 모든 로케일 파일의 문구를 고쳐야 하는 일이고, 코드가 아닌 발견의 좋은 예다. 대시보드에서 주입하는 분석 비콘이 있다면 개인정보 문구에도 그 이름을 적자.
열어 둔 것들. HSTS는 모든 서브도메인이 HTTPS를 제공한다고 확인될 때까지 includeSubDomains나 preload 없이 돌아간다. 주입된 분석 비콘이 있는 CSP는 로컬에서 관찰할 수 없었으니, 배포 후 라이브 사이트에 감사를 다시 돌리자.
다른 글들. REA에 관한 Hacker News 스레드에는 가장 날카로운 비판과 옹호하는 쪽의 해석이 있다. 가능한 한 많은 것을 서버 쪽에 두라는 것이다 s8. 자기 앱에 대한 글 두 편은 발견을 나열하고 수정을 제안하지만, 적용하거나 다시 돌려 보지는 않았다 s9 s10.
FAQ
내 앱에 REA를 돌리는 것은 합법인가요? REA의 면책 조항은 내가 소유했거나 검사할 권한이 있는 소프트웨어에 대한 합법적 분석을 다룬다 s1. 내 사이트는 여기에 맞는다. 남의 사이트는 서면으로 허락받지 않는 한 맞지 않는다.
왜 노트북에서 돌리면 안 되나요? 프로젝트의 보안 노트는 이것이 샌드박스가 아니라고 말한다 s4. 브라우저를 몰고 내려받은 도구를 돌리므로, 일회용 머신이면 피해가 그 안에 머문다. 우리가 쓴 샌드박스 Kosuke는 최상위 플랜을 무료로 제공했지만, 로컬 가상 머신이나 컨테이너도 같은 일을 한다. Kosuke를 고른다면 가입 직후 'Promo code' 칸에 코드 AIDIVE2026을 입력하세요. 크레딧을 10달러 대신 30달러 받아요.
무료 콘텐츠의 이메일 게이트를 서버로 옮겨야 하나요? 콘텐츠가 무료로 풀려는 게 아닐 때만 그렇다. 정적 페이지 안에 있는 콘텐츠는 게이트 없이도 읽히므로, 게이트는 정중히 부탁할 뿐 아무것도 강제하지 않는다. 우리는 일부러 유지했고 이유를 발견마다 적어 두었다. 유료나 멤버 전용 콘텐츠는 절대 정적 내보내기에 들어가면 안 된다 s11.
속도 제한만으로 가입 구멍이 막히나요? 아니다. 폭주는 늦추지만, 스크립트가 Origin 헤더를 위조할 수 있고 리미터는 위치별로 센다 s13. 서버에서 확인하는 동의와 함께 쓰고, 가능하면 더블 옵트인까지 붙이자.
설정 후에 REA 도구가 보이지 않아요. 어떡하죠? 우리 실행에서는 설정이 스킬만 설치했고 도구 서버는 설치하지 않았으며, Claude Code는 각 도구의 명령줄 버전을 썼다. 재시작하지 말고 같은 단계를 터미널에서 호출하자.
AIDive