BugForge — 2026.06.10

Ottergram: Private Posts via Dual-Identifier Authorization Drift

BugForge Broken Object-Level Authorization medium

Part 1: Pentest Report

Executive Summary

Ottergram is a React single-page application backed by a Node/Express JSON API for a social photo-sharing service. Posts can be marked private, and the interface hides private posts from users who should not see them. Testing showed that this privacy is enforced only in the browser. The API returns private posts in search results and serves any private post in full when requested by its UUID handle, with no privacy or ownership check on that route.

Testing confirmed 1 finding:

ID Title Severity CVSS CWE Endpoint
F1 Private posts readable via public_id (client-side-only privacy) Medium 6.5 CWE-639, CWE-602 GET /api/posts/:id

The flag-bearing finding is a broken object-level authorization defect. Every post carries two handles: a sequential integer id and an opaque UUID public_id. The integer route enforces privacy and returns 404 for a private post, but the public_id route returns the same post in full. Because /api/search leaks the public_id of private posts, the UUID provides no protection, and any authenticated user can read another user’s private post.


Objective

Read a private post that should not be visible to the test account, and recover the flag carried in its caption.


Scope / Initial Access

# Target Application
URL: lab-1781132274037-rgt396.labs-app.bugforge.io

# Auth details
Registered/logged-in user: haxor (id 10, role=user, subscription_tier=free)
Token auth: GET /api/verify-token echoes the current user object.
All endpoints below were exercised with a valid authenticated session.

The application uses token-based authentication. GET /api/verify-token returns the current user object, confirming the session belongs to haxor, a normal free-tier user with no administrative role.


Reconnaissance: Reading the Client Bundle and Raw API Responses

The application surface was mapped from the Create React App build (/static/js/main.ef1a88f0.js) and by comparing raw API responses against what the SPA rendered. The bundle exposed the API routes and, importantly, the client-side filtering logic that decides what the user sees.

Observations that shaped the test plan:

  1. The public feed GET /api/posts omits private posts; integer id 8 was absent from the feed, indicating a hidden post sat at that position.
  2. Every post object carried both a sequential integer id (1..16) and a UUID public_id. Two handles for the same object is a prompt to test each handle independently against the same access path.
  3. GET /api/posts/:id accepted both the integer id and the public_id (UUID) in the same route position.
  4. GET /api/search?q= returned post objects in JSON that included a private:true marker. The client bundle removed these before rendering with a .filter(e => !e.private) call, meaning the privacy decision was being made in the browser, not the server.

Observations 2 and 4 together set up the test: the search response leaks the UUID of a private post, and the per-post route accepts that UUID. The remaining question was whether the UUID route enforced privacy the way the integer route did.


Application Architecture

Component Detail
Backend Node/Express JSON API under /api
Frontend React SPA (Create React App build)
Auth Token-based; GET /api/verify-token echoes current user
Database Not directly observable; posts carry integer id + UUID public_id

API Surface

Endpoint Method Auth Notes
/api/login, /api/register POST No Authentication
/api/verify-token GET Yes Echoes current user object
/api/posts GET Yes Public feed; omits private posts
/api/posts/:id GET Yes Accepts integer id AND public_id (UUID)
/api/posts/:id/like POST Yes  
/api/search?q= GET Yes Returns private posts in JSON with private:true
/api/profile/:username GET Yes Profile + that user’s posts; exposes private_account
/api/follow/:username POST Yes  
/api/admin/* various ? Not assessed
/api/insider/stats, /api/profile/avatar/import, /api/messages, /api/subscribe, /api/posts/schedule(d) various ? Not assessed

Known Users

Username ID Role
haxor (test account) 10 user
kelp_forest 5 user (owner of private post 8)

Attack Chain Visualization

┌──────────────────────────┐     ┌──────────────────────────────┐     ┌──────────────────────────────┐
│ GET /api/search?q=otter   │     │ GET /api/posts/<public_id>    │     │ Private post 8 returned in     │
│ Raw JSON leaks private    │ ──▶ │ No privacy / ownership check  │ ──▶ │ full; caption carries the flag │
│ post: public_id +         │     │ on the UUID route             │     │ bug{...}                       │
│ private:true (UI hides it)│     │                               │     │                                │
└──────────────────────────┘     └──────────────────────────────┘     └──────────────────────────────┘

Findings

F1: Private posts readable via public_id (client-side-only privacy)

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), CWE-602 (Client-Side Enforcement of Server-Side Security) Endpoint: GET /api/posts/:id Authentication required: Yes

Description

Privacy of posts is enforced only in the React client, not in the API. Two defects compound:

  1. GET /api/search returns private posts in the JSON response, each marked private:true and carrying its public_id. The SPA removes these results in the browser with results.filter(e => !e.private), so the data leaves the server but is hidden only in rendering.
  2. GET /api/posts/<public_id> performs no privacy or ownership check and returns the full private post, including its caption.

The per-post route accepts two identifiers. The integer route is guarded (GET /api/posts/8 returns 404 “Post not found” for the private post), but the public_id (UUID) route is not. The UUID was intended as an unguessable external handle, but because /api/search (and the feed) leak it, its secrecy provides no protection.

Impact

Any authenticated user can read another user’s private posts. Cross-user disclosure of private content.

Reproduction

Step 1: Search and read the raw JSON response

GET /api/search?q=otter HTTP/1.1
Host: lab-1781132274037-rgt396.labs-app.bugforge.io
Authorization: Bearer <session token>

Response (excerpt):

[
  {
    "id": "945549f9-095e-4ab9-9cf3-30c836b384f1",
    "username": "kelp_forest",
    "private": true
  }
]

The response includes a private post with its public_id. The browser would drop this entry before rendering; the API has already disclosed it.

Step 2: Fetch the private post by its public_id

GET /api/posts/945549f9-095e-4ab9-9cf3-30c836b384f1 HTTP/1.1
Host: lab-1781132274037-rgt396.labs-app.bugforge.io
Authorization: Bearer <session token>

Response: 200 OK with the full private post (integer id 8, owner user_id 5 / kelp_forest), including its caption. The caption contained the flag.

Remediation

Fix 1: Enforce privacy server-side on the per-post read path, for every identifier form

// BEFORE (Vulnerable): the public_id route returns the post with no check
app.get('/api/posts/:id', async (req, res) => {
  const post = await Post.findByIdentifier(req.params.id); // matches int id OR public_id
  if (!post) return res.status(404).json({ error: 'Post not found' });
  res.json(post);
});

// AFTER (Secure): apply the same visibility check regardless of identifier form
app.get('/api/posts/:id', async (req, res) => {
  const post = await Post.findByIdentifier(req.params.id);
  if (!post) return res.status(404).json({ error: 'Post not found' });

  const isOwner = post.user_id === req.user.id;
  const canView = !post.private || isOwner || (await viewerFollows(req.user.id, post.user_id));
  if (!canView) return res.status(404).json({ error: 'Post not found' });

  res.json(post);
});

Fix 2: Filter private posts in the search query, not in the client

// BEFORE (Vulnerable): search returns private rows; the client hides them
const results = await Post.search(q);
res.json(results); // includes { private: true } rows

// AFTER (Secure): exclude posts the viewer is not allowed to see in the query
const results = await Post.search(q, {
  visibleTo: req.user.id, // WHERE private = false OR user_id = :viewer OR followed
});
res.json(results);

Additional recommendations:

  • Treat the public_id as a non-secret identifier. It is already exposed by the feed and search, so it must not be relied on as an access-control boundary.
  • Apply the visibility check in a shared authorization function used by every read path (/api/posts/:id, /api/search, /api/profile/:username) so the rule cannot drift between routes.
  • Audit any other route that resolves an object by more than one identifier; the access check must live below the identifier resolution, not beside one branch of it.

OWASP Top 10 Coverage

  • A01:2021 Broken Access Control: The public_id read path serves private posts to a user who is neither the owner nor a permitted viewer; the integer path enforces the rule and the UUID path does not.
  • A04:2021 Insecure Design: Privacy is implemented as a client-side .filter() over data the API has already disclosed, placing a server-side security decision in the browser.

Tools Used

Tool Purpose
Caido Request interception and replay (reqs 72/73 search, 79 private post fetch)
Browser dev tools Reading the Create React App bundle and client-side filter logic

References

  • CWE-639: Authorization Bypass Through User-Controlled Key: https://cwe.mitre.org/data/definitions/639.html
  • CWE-602: Client-Side Enforcement of Server-Side Security: https://cwe.mitre.org/data/definitions/602.html
  • OWASP API Security Top 10, API1:2023 Broken Object Level Authorization
  • OWASP Top 10 2021 A01: Broken Access Control: https://owasp.org/Top10/A01_2021-Broken_Access_Control/

Part 2: Notes / Knowledge

Key Learnings

  • Re-test every per-object access path against each identifier form. When an object is addressable by two handles, a sequential integer primary key and an opaque UUID, authorization is frequently enforced on one and not the other. Here the integer route returned 404 for a private post while the UUID route returned it in full: same object, two doors, one locked. Identical handler output is not evidence of identical access checks. For any object that exposes more than one identifier, fire a read against each form independently; the cost is one extra request and the failure mode is common in ORM-backed apps that expose both a primary key and an external UUID.

  • Read the raw JSON, not the rendered DOM; a client-side filter is a disclosure. A search or list endpoint that returns a private:true row and then hides it in the browser has already leaked the record. The same response usually carries the handle needed to dereference the object directly; here the search result handed over the exact public_id the next request needed. Diff what the API returns against what the UI paints. The gap is the finding, and it often hands you the key to the next request.


Failed Approaches

Approach Result Why It Failed
GET /api/posts/8 (integer id) for the private post 404 “Post not found” The integer route enforces the privacy check; only the public_id route is unguarded.

Tags: #broken-access-control #idor #bola #client-side-security #dual-identifier #bugforge Document Version: 1.0 Last Updated: 2026-06-10

#idor #broken-access-control #bola #client-side-security #dual-identifier #webapp #bugforge