Ottergram: Private Posts via Dual-Identifier Authorization Drift
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:
- The public feed
GET /api/postsomits private posts; integerid8 was absent from the feed, indicating a hidden post sat at that position. - Every post object carried both a sequential integer
id(1..16) and a UUIDpublic_id. Two handles for the same object is a prompt to test each handle independently against the same access path. GET /api/posts/:idaccepted both the integeridand thepublic_id(UUID) in the same route position.GET /api/search?q=returned post objects in JSON that included aprivate:truemarker. 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:
GET /api/searchreturns private posts in the JSON response, each markedprivate:trueand carrying itspublic_id. The SPA removes these results in the browser withresults.filter(e => !e.private), so the data leaves the server but is hidden only in rendering.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_idas 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_idread 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:truerow 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 exactpublic_idthe 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