{"id":"hunt-spa-api","name":"hunt-spa-api","summary":"シングルページアプリの隠れたバックエンドAPIを公開JSバンドルから見つけ出し、そのAPIにアクセス制御の不具合や認証不足がないかテストします。","body":"## When to use this skill\n\nTrigger when:\n- A target host returns a tiny HTML shell + big `/static/js/*.js` or `/_next/static/*` bundles (React/Vue/Angular/Next/Svelte SPA)\n- You see a subdomain named `console`, `app`, `dashboard`, `portal`, `admin`, `panel`, `manage`, `internal`\n- Recon surfaces any `*api*`, `*-api*`, `api.*` host\n- A login page is OAuth/SSO-gated (the *frontend* auth tells you nothing about whether the *API* enforces auth)\n\nThe core insight: **a SPA is a client to a backend API, and it ships the full map of that API — hosts, routes, sometimes keys — to anyone who views source.** The login page being protected says nothing about whether the API behind it checks tokens. Auth is frequently enforced on the *gateway/login* and missing on a *route group* of the API.\n\nDO NOT skip this because \"the app needs login\" — that's exactly when this pays off.\n\n---\n\n## The play (5 steps)\n\n### 1. Pull the shell + enumerate the bundles\n```bash\ncurl -s https://console.target.com/ -o index.html\n# React/CRA:\ngrep -oE '/static/js/[^\"]+\\.js' index.html\n# Next.js:\ngrep -oE '/_next/static/[^\"]+\\.js' index.html\n# generic:\ngrep -oiE 'src=\"[^\"]+\\.js[^\"]*\"' index.html\n```\nDownload every bundle (they can be multi-MB — that's fine, it's all route data):\n```bash\nmkdir bundles\nfor j in $(grep -oE '/static/js/[^\"]+\\.js' index.html | sort -u); do\n  curl -s \"https://console.target.com$j\" -o \"bundles/$(echo \"$j\"|tr '/' '_')\"\ndone\n```\n\n### 2. Harvest API hosts, routes, and secrets from the bundles\n```bash\nB=bundles/*.js\n# Backend API hosts (incl. dev/beta/staging variants — often weaker auth)\ngrep -ohiE 'https://[a-z0-9.-]*(api|console|backend|service)[a-z0-9.-]*\\.target\\.com[a-z0-9/_-]*' $B | sort -u\n# Versioned API base paths\ngrep -ohiE '/api/v[0-9]+/?' $B | sort -u\n# Route literals — minified bundles store routes as STRING segments, not full URLs.\n# Grep for quoted \"resource/action\" strings:\ngrep -ohiE '\"[a-z0-9_-]+/[a-z0-9_/-]+\"' $B | tr -d '\"' \\\n  | grep -iE '(login|user|account|order|billing|invoice|payment|deal|report|token|otp|password|reset|admin|profile|auth|upload|export|role|permission|dashboard|wallet|finance|sales)' | sort -u\n# Secrets (validate before trusting — most AIza keys are Maps/analytics, not Auth)\ngrep -ohiE '(AIza[0-9A-Za-z_-]{35}|AKIA[0-9A-Z]{16}|sk_live_[0-9A-Za-z]+|eyJ[A-Za-z0-9_-]{10,}\\.[A-Za-z0-9_-]{10,}|apiKey[\"'\"'\"']?\\s*[:=]\\s*[\"'\"'\"'][^\"'\"'\"']+)' $B | sort -u\n```\n**Note:** minifiers store routes as concatenated string segments (e.g. `\"account/payment/list\"`), NOT full `/api/v2/...` URLs — so a naive `/api/v*` grep returns nothing. Grep for the **resource-word route strings** and prepend the base yourself.\n\n### 3. Establish a CONTROL — find an endpoint that IS gated\nBefore declaring anything vulnerable, send an unauthenticated request to an endpoint you expect to be protected, and capture what *correct* rejection looks like:\n```bash\ncurl -s -X POST https://api.target.com/api/users -H 'Content-Type: application/json' -d '{}'\n# secure → {\"error\":\"Missing or invalid authorization header\"} or HTTP 401\n```\nThis is your differential. A sibling API (e.g. a second API host, or a different route group on the same host) is the ideal control — same stack, so a different response = real authz gap, not a quirk.\n\n### 4. Test each route family UNAUTHENTICATED, both methods\nFor every discovered route, send it with **no `Authorization` header** and compare to the control:\n```bash\nfor r in <routes>; do\n  curl -s -o /tmp/r -w \"[%{http_code}] $r\\n\" -X POST -H 'Content-Type: application/json' -d '{}' \"https://api.target.com/api/v2/$r\"\ndone\n```\nInterpret:\n- **`401`/`\"Missing authorization\"`** → gated (correct). Move on.\n- **`200` with data** → unauthenticated data exposure. **Finding.**\n- **`400 \"field X is mandatory\"`** → the route processed your request and reached *business-logic validation* without an auth check → **auth bypass; supply the field minimally to confirm.**\n- **`200` + verbose DB/stack error** (e.g. `PROCEDURE db_x.sp_y does not exist`) → reached the data layer unauthenticated; also a SQLi-surface signal.\n- **Mandatory fields named like `is_admin` / `is_internal` / `requested_by` / `role_id` / `account_type`** → **authorization derived from client-supplied parameters** — set the privilege flag and you self-elevate. Critical-class.\n\n### 5. Pivot & prove (minimally)\n- IDs returned by one endpoint (`account_id`, `order_id`, `deal_id`) are the keys the *other* endpoints consume — they prove the whole router is reachable, not just one route.\n- Test `dev-`/`beta-`/`staging-` API variants — they frequently have weaker/disabled auth.\n- Check the response headers: `Access-Control-Allow-Origin: *` compounds the issue (any web origin reads it from a victim's browser).\n- **STOP at minimum-necessary proof.** A handful of records (or a `totalCount`) confirms the missing check. Do NOT enumerate the table — see `redteam-mindset` data-minimization boundary. The finding is the absent auth, not the data volume.\n\n---\n\n## What \"the API behind the SSO login\" really means\n\nA common, dangerous architecture:\n- `console.target.com` (the SPA) → login is **Entra/Okta/Google OAuth** (looks airtight).\n- `api.target.com` (the backend) → some route groups enforce the bearer token, **some route groups forgot the middleware.**\n\nThe frontend login is theatre if the API doesn't independently validate the token on every route. Always test the API directly, bare, regardless of how locked-down the login UI is.\n\n---\n\n## Anti-patterns\n\n- **\"The app requires login, so the API must be protected.\"** No — test the API directly, unauthenticated. The whole point.\n- **\"Minified bundle, can't read it.\"** You don't need to read it — grep it for hosts/routes/secrets. 5 minutes.\n- **\"`/api/v1/foo` returned 404, so no API here.\"** Wrong base or wrong method. Try `/api/`, `/api/v2/`, POST not GET, and the exact route strings from the bundle (Express's 404 echoes the path — use it to calibrate).\n- **\"AIza key found → critical secret.\"** Validate first — most are Maps/analytics keys (`CONFIGURATION_NOT_FOUND` on identitytoolkit = not Auth-enabled). Don't over-claim.\n- **Dumping the whole dataset once you get a 200.** Stop at PoC. (`redteam-mindset`.)\n- **Account-creation / write endpoints as \"proof\".** Read endpoints prove the auth gap without creating state. Never POST a `create`/`signup`/`upload` to \"demonstrate\" — that's a destructive write needing explicit per-action authorization.\n\n---\n\n## Related Skills & Chains\n\n- **`hunt-api-misconfig`** — once the API is mapped, run the broader misconfig matrix (method tampering, mass assignment, JWT alg confusion) per route.\n- **`hunt-idor`** — the `account_id`/`order_id` pivots feed straight into IDOR testing across discovered routes.\n- **`hunt-source-leak`** — sourcemaps (`*.js.map`) reconstruct original source for deeper route/secret extraction; same harvesting muscle.\n- **`hunt-nextjs`** — for Next.js targets, layer the middleware-bypass (`x-middleware-subrequest`) and `/_next/data` route tests on top of this.\n- **`redteam-mindset`** — the data-minimization boundary governs step 5: prove the missing check, don't exfiltrate the table.\n- **`recon-scope-triage`** — verify the API host actually belongs to the target before testing (don't pop a same-named third party's API).","author":"@elementalsouls","ownerProfile":null,"authorContacts":null,"sourceUrl":"https://github.com/elementalsouls/Claude-BugHunter/tree/main/skills/hunt-spa-api","license":"MIT","category":"testing","lang":"en","tokens":2019,"stars":0,"calls30d":1,"claimed":false,"visibility":"public","origin":"crawler","version":"0.1.0","createdAt":"2026-08-22","updatedAt":"2026-08-22","files":[],"requires":{"mcp":[],"tools":[]},"safety":{"flags":[],"scannedAt":"2026-08-22","hasScripts":false,"networkEndpoints":["api.target.com","console.target.com"]}}