BugForge — 2026.08.14

FurHire: Operator Injection in Account Recovery

BugForge Operator Injection / Type Confusion medium

Executive Summary

FurHire is a pet themed job board on the BugForge platform, built on Node.js/Express with Socket.IO for real time notifications, JWT (HS256) bearer authentication held in localStorage, and server rendered pages carrying inline JavaScript. It exposes two self selected roles, user (job seeker) and recruiter. An unlinked account recovery endpoint, POST /api/account/recover, accepts an email address and a backup code and reports whether the code is valid for that account.

Testing confirmed four findings:

ID Title Severity CVSS CWE Endpoint
F1 Operator injection in account recovery gives verification bypass and blind extraction of stored backup codes High 7.5 CWE-943, CWE-1287, CWE-257 POST /api/account/recover
F2 No rate limiting or lockout on account recovery Medium 5.3 CWE-307 POST /api/account/recover
F3 Socket.IO delivers every notification to every connected client without authentication Medium 5.3 CWE-200, CWE-306 GET /socket.io/
F4 Registration discloses whether a username or email is already registered Low * 5.3 CWE-204 POST /api/register

* F4’s raw CVSS math is 5.3, which sits in the Medium band. The Low label reflects that the demonstrated impact is account enumeration only, with no data read and no state change.

F1 is the flag bearing finding. Neither email nor backupCode is type checked before reaching the query layer, so sending a JSON object instead of a string turns the value into a set of query operators. That yields three effects in one request shape: verification passes without a valid code, the response discloses the matched account’s username, and an ordering comparison such as {"$gt": X} answers a single yes or no question about the stored value. Binary searching that comparison per byte recovered the backup code stored on recruiter whiskers_hr (id 5), which is the lab flag: bug{iYatEGmtcOox9d15LIqkO5zogr5FRvWv}.


Objective

Recover the lab flag on the BugForge “FurHire” target.


Scope / Initial Access

# Target Application
URL: https://lab-1786726255893-tlm1li.labs-app.bugforge.io

# Auth
POST /api/register → {role, username, email, full_name, password} → JWT HS256
POST /api/login    → {username, password}{token, user}
                     payload: {"id":6,"username":"d4rk_seeker","role":"user","iat":...}

# Starting privileges
Self registered seeker account. No credentials supplied by the platform.

Registration is open and instant. The token is a bearer JWT sent in the Authorization header; no cookies were observed. Alongside the token, the client caches a user object in localStorage, and that cached object drives all role and ownership rendering in the browser.

The finding in F1 needs neither of those. POST /api/account/recover is reachable with no token at all.


Reconnaissance: Reading a Client That Guards Nothing

Route protection is implemented only in the browser. /public/js/app.js redirects to /login when no token is present in localStorage, but the server renders every page path with a 200 regardless, so the inline JavaScript on each page, and the full list of API routes it calls, can be read without an account. The API map extracted from that JavaScript was cross checked against an OPTIONS sweep for Allow headers on every path, and the two agreed exactly.

Observations that shaped the test plan:

  1. Every page returns 200 unauthenticated, so the API surface is fully readable with no account and no source map required.
  2. POST /api/account/recover is not linked from the homepage or the navigation. It appears only in the login page footer and in the client’s publicPaths array. Its body is {email, backupCode}.
  3. No route anywhere generates or issues a backup code. This was checked against the client JavaScript, the OPTIONS sweep, and ffuf over /api/FUZZ with raft-medium. The recovery feature only verifies, so any stored code has to be seeded on the server. Obtaining a valid code was therefore the whole problem.
  4. The recovery form’s placeholder advertises an expected format, A1B2C3-D4E5F6-A7B8C9: three groups of six alphanumeric characters. Not every stored code follows it, as F1 shows.
  5. GET /api/jobs/3 returns a recruiter_email field for the job’s owning recruiter, which supplied [email protected] and [email protected] as account selectors. Every captured request to this endpoint carried an Authorization header, so whether it requires one was not established.
  6. app.js receives new_application and status_update events over Socket.IO and decides whether to show each one by comparing the cached user.id against an id inside the event payload, which places the filtering in the browser.

Application Architecture

Component Detail
Backend Node.js / Express (X-Powered-By: Express), server rendered HTML with inline JavaScript
Frontend Shared static client at /public/js/app.js (4KB, unminified), no build step or bundle
Real time Socket.IO, reachable over the Engine.IO v4 polling transport
Auth JWT HS256 bearer in localStorage, plus a cached user object driving role and ownership rendering in the browser. No cookies observed
Database Relational. Not directly observable from the client. The operator set honoured on /api/account/recover maps one to one onto SQL comparison operators, which points to a hand written operator to SQL translation rather than a document store. Inference, see F1

API Surface

Endpoint Method Auth Notes
/api/register POST No Body carries a hidden role field rendered from ?role=
/api/login POST No Returns {token, user}
/api/account/recover POST No Unlinked. {email, backupCode}. F1, F2
/api/profile GET, PUT Yes Returns {profile, company}
/api/company PUT Yes No GET; read through /api/profile
/api/skills GET Yes Static list
/api/jobs GET, POST Yes GET takes search, location, job_type
/api/jobs/:id GET, PUT PUT yes; GET not established GET discloses recruiter_email. Every captured GET carried a token, so whether it requires one was never tested
/api/jobs/:id/apply POST Yes {cover_letter}
/api/jobs/:id/applicants GET Yes Applicant email, bio, skills, cover letters
/api/applications/:id/status PUT Yes {status}
/api/my-applications GET Yes  
/api/my-jobs GET Yes  
/api/saved-jobs GET Yes Reader with no writer in the client or the Allow sweep
/api/notifications GET Yes Found by ffuf, absent from the client map
/api/verify-token POST Yes Found by ffuf, absent from the client map
/socket.io/ GET No Handshake accepts a connection with no token. F3

Known Users

Username ID Role How it surfaced
pawsitive_hr 4 recruiter Username disclosed by F1; id from recruiter_id on jobs 1 and 2, company “Pawsitive Ventures”
whiskers_hr 5 recruiter Username disclosed by F1; id from recruiter_id on job 3, company “Whiskers & Co”
max_retriever not observed not observed Username disclosed by F1 through an operator on the email selector
d4rk_seeker 6 user Our registered account

Attack Chain Visualization

┌────────────────────────┐   ┌────────────────────────┐   ┌────────────────────────┐
│ POST /api/account/     │   │ POST /api/account/     │   │ POST /api/account/     │
│      recover           │   │      recover           │   │      recover           │
│ backupCode:            │──▶│ email:{"$exists":true} │──▶│ {"$gte":"bug{i",       │
│   {"$ne":"x"}          │   │ backupCode:{"$gt":"a"} │   │  "$lt":"bug{j"} → 200  │
│ 200 verified           │   │ 200 whiskers_hr        │   │ "starts with" test,    │
│ + username disclosed   │   │ 401 at {"$gt":"c"}     │   │ 283 reqs for 37 bytes  │
└────────────────────────┘   └────────────────────────┘   └────────────────────────┘
                                                                      │
                                                                      ▼
                                                          ┌────────────────────────┐
                                                          │ POST /api/account/     │
                                                          │      recover           │
                                                          │ plain string, no       │
                                                          │ operators → 200        │
                                                          │ same string vs         │
                                                          │ [email protected] → 401 │
                                                          └────────────────────────┘

Findings

F1: Operator injection in account recovery gives verification bypass and blind extraction of stored backup codes

Severity: High CVSS v3.1: 7.5 (CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:N/A:N) CWE: CWE-943 (Improper Neutralization of Special Elements in Data Query Logic), CWE-1287 (Improper Validation of Specified Type of Input), CWE-257 (Storing Passwords in a Recoverable Format) Endpoint: POST /api/account/recover Authentication required: No

Description

POST /api/account/recover takes {email, backupCode} and answers 200 when the code is valid for the account and 401 when it is not. Neither field is checked for type before it reaches the query layer, so a JSON object is interpreted as query operators instead of being compared as a value. Three defects compound:

  1. No type validation on either field. {"backupCode":{"$ne":"x"}} returns 200 without any knowledge of the stored code. The same applies to email, so an account can be selected without knowing any address at all: {"email":{"$exists":true}} resolves to a single account, the first row the query returns rather than the first by email order.
  2. The success response names the account. A 200 carries "username", so the request that bypasses the check also discloses the username. Note the limit of the 200 versus 401 split: an address with no account and a registered account with no code stored both return 401, so the split separates accounts holding a provisioned backup code from everything else, not registered addresses from unregistered ones. The disclosure here is the username in the 200 body.
  3. The stored code is compared in cleartext, in order. Ordering comparisons succeed against the stored value ({"$gt":"3"} returns 200 against a code beginning with a digit), so the comparison operates on the code itself rather than on a hash of it. That makes the stored value recoverable one byte at a time.

The honoured set was mapped against [email protected], whose code 3C9D25-CE8D85-5C9DA1 was extracted first by the method below, giving a control target with a known stored value. Each operator was sent twice, once in a form that should match and once in a form that should not, so that “works” stays distinguishable from “ignored” and “always true”:

Honoured (discriminates) Returns 500 Accepted and ignored
$ne $gt $gte $lt $lte $in $nin $exists $eq $regex $like $ilike $glob $regexp $match $startsWith $contains $prefix $not $all $type $mod $where $expr $size $elemMatch

Multiple operators in one object are combined with AND, so {"$gt":"3","$lt":"4"} returns 200 while {"$gt":"4","$lt":"5"} returns 401, which makes a half open interval a direct “does the stored value start with this prefix” test. No regular expression, LIKE, or prefix operator exists at all; $in is the only exact match form, accepting roughly 1000 candidates per request and returning 413 at 5000.

The honoured set maps one to one onto SQL comparison operators (!=, >, >=, <, <=, IN, NOT IN, IS NOT NULL), and $eq is absent while $ne works. Most unrecognised keys throw 500 rather than being dropped, though $size and $elemMatch are accepted and ignored, so that third signal is weaker than the other two. Read together these point to a hand written operator to SQL translation rather than a document store, which is why this is written up as operator injection and type confusion rather than as NoSQL injection. That reading is an inference from the response behaviour; the backend was not observed directly.

The endpoint verifies only. It issues no token, sets no session and resets no password, and no reset completion route exists anywhere in the application (checked against the client map, the OPTIONS sweep and ffuf). Backup codes exist to authenticate a password reset, so in a deployment carrying the second half of that flow this would be a pre authentication account takeover. That step was not present here and was not demonstrated, so it is reasoned, not tested, and the severity reflects what was proven.

Impact

Unauthenticated disclosure of any account’s stored backup code and username. The code recovered from recruiter whiskers_hr is the lab flag.

Reproduction

Step 1: Pass verification without a valid code

POST /api/account/recover HTTP/1.1
Host: lab-1786726255893-tlm1li.labs-app.bugforge.io
Content-Type: application/json

{"email":"[email protected]","backupCode":{"$ne":"x"}}

Response: 200 {"status":"verified","username":"whiskers_hr","message":"Backup code accepted. You can now reset your password."}. The object is treated as an operator rather than compared as a string, and the response names the account. Aim this at an account whose code is actually populated: the same payload against a freshly registered account returns the same 401 as a hardened endpoint would, because a new account has no code stored.

Step 2: Locate the flag bearing row without knowing an address

POST /api/account/recover HTTP/1.1
Host: lab-1786726255893-tlm1li.labs-app.bugforge.io
Content-Type: application/json

{"email": {"$exists": true}, "backupCode": {"$gt": "a"}}

Response: 200 {"status":"verified","username":"whiskers_hr",...}. The comparison doubles as a search filter across accounts. Repeating it for bounds in 9 A F G Z _ a b returns 200 and for c z returns 401, placing the stored value between b and c. The only ordinary code recovered from this application, 3C9D25-CE8D85-5C9DA1, sorts far below that, so the row this bound selects is an outlier worth extracting. Note the limit of this step: $gt compares the whole string, so a 200 on $gt "bug{" does not by itself prove a bug{ prefix. The same 200 comes back for bog, beg or bacon, which all diverge earlier. This step identifies which row is worth extracting, nothing more.

Step 3: Turn the comparison into a per character test

POST /api/account/recover HTTP/1.1
Host: lab-1786726255893-tlm1li.labs-app.bugforge.io
Content-Type: application/json

{"email": "[email protected]", "backupCode": {"$gte": "bug{i", "$lt": "bug{j"}}

Response: 200. The two operators are ANDed into a range, so the half open interval [P+c, P+next(c)) returns 200 only when the stored value starts with P+c. The same probe for h, j, Y and z returns 401.

Step 4: Extract the value

python3 tools/blind_extract_v2.py \
  --email [email protected] \
  --url https://lab-1786726255893-tlm1li.labs-app.bugforge.io/api/account/recover

Binary searching the range bounds per position recovers the 37 character value in 283 requests: bug{iYatEGmtcOox9d15LIqkO5zogr5FRvWv}. The value terminates naturally when the whole remaining range returns 401.

Step 5: Confirm the value is byte exact and account bound

POST /api/account/recover HTTP/1.1
Host: lab-1786726255893-tlm1li.labs-app.bugforge.io
Content-Type: application/json

{"email": "[email protected]", "backupCode": "bug{iYatEGmtcOox9d15LIqkO5zogr5FRvWv}"}

Response: 200 {"status":"verified","username":"whiskers_hr",...}, from a plain string with no operators in it. The same string against [email protected] returns 401 {"status":"invalid","message":"That backup code is not valid for this account."}, which rules out the endpoint simply answering 200 to everything.

Remediation

Fix 1: Reject non string values before they reach the query layer

// BEFORE (Vulnerable)
app.post('/api/account/recover', async (req, res) => {
  const { email, backupCode } = req.body;
  if (!email || !backupCode) {
    return res.status(400).json({ error: 'Email and backup code are required' });
  }
  const row = await db.findUser({ email, backup_code: backupCode });
  ...
});

// AFTER (Secure)
app.post('/api/account/recover', async (req, res) => {
  const { email, backupCode } = req.body;
  if (typeof email !== 'string' || typeof backupCode !== 'string') {
    return res.status(400).json({ error: 'Email and backup code are required' });
  }
  const row = await db.findUser({ email, backup_code: backupCode });
  ...
});

Type validation belongs ahead of the operator translation layer, not inside it. A schema validator applied to the whole body (zod, joi, express-validator) is preferable to per field checks, because it closes the same hole on every other route at once.

Fix 2: Store a hash of the backup code and compare it in constant time

// BEFORE (Vulnerable)
const row = await db.get(
  'SELECT username FROM users WHERE email = ? AND backup_code = ?',
  [email, backupCode]
);

// AFTER (Secure)
const row = await db.get('SELECT username, backup_code_hash FROM users WHERE email = ?', [email]);
const ok = row && (await argon2.verify(row.backup_code_hash, backupCode));

Hashing removes the ordering comparison that made extraction possible: a comparison against a hash tells an attacker nothing about the code. Verifying the hash in the application also keeps the secret out of any query the operator layer can influence. Note the ordering in the fixed version: skipping argon2.verify when no row matches leaves an unregistered address measurably faster than a registered one, so verify against a dummy hash on the miss path and pair this with Fix 3.

Fix 3: Return one generic result that does not name the account

// BEFORE (Vulnerable)
return res.json({
  status: 'verified',
  username: row.username,
  message: 'Backup code accepted. You can now reset your password.'
});

// AFTER (Secure)
return res.json({
  status: 'ok',
  message: 'If the details are correct, a reset link has been sent.'
});

The same generic body should be returned for a wrong code and an unregistered email, with the same status code and comparable timing, so the endpoint stops distinguishing registered accounts from unregistered ones.

Additional recommendations:

  • Do not store a high value secret as a recoverable cleartext credential. Here the flag itself was the stored backup code.
  • Issue backup codes single use and expire them on use.
  • Apply the same type validation to POST /api/login and any other route that forwards request body values into a query.
  • Rate limit and lock out this endpoint per account and per source address (see F2).

F2: No rate limiting or lockout on account recovery

Severity: Medium CVSS v3.1: 5.3 (CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:L/I:N/A:N) CWE: CWE-307 (Improper Restriction of Excessive Authentication Attempts) Endpoint: POST /api/account/recover Authentication required: No

Description

The recovery endpoint applies no throttling, no lockout, no CAPTCHA and no backoff. Over 1,100 verification attempts were sent to it across the engagement with no change in response behaviour. Request tempo was reconstructed from the Date headers on the proxy captured pairs rather than from the tooling’s own counters: 363 requests between 17:20:38 and 17:21:37 GMT, roughly 6.1 requests per second sustained with no gaps, zero 429 responses anywhere in the record, and no CAPTCHA markup in the recovery page. A control request sent after the burst still returned the ordinary 200 and 401 shapes, which is the positive evidence an absence claim needs.

The only ordinary code recovered here, 3C9D25-CE8D85-5C9DA1, is 18 hexadecimal characters, a space of 16^18 or roughly 4.7 x 10^21. Guessing a code of that shape is not feasible at any throttling setting, and the target account’s stored value is not even that shape: it is the 37 character string recovered in F1, from a wider character set again. So the missing limiter buys nothing against guessing the credential. What it buys is F1, whose extraction needs several hundred sequential requests with nothing on this endpoint to slow them down. Credential stuffing against this flow is also unconstrained, though that was reasoned rather than tested.

Two scoring notes. The C:L metric reflects that contribution rather than any disclosure F2 performs on its own; the extracted credential is scored once, at C:H, on F1 where it is actually disclosed. And F2 and F4 compute to the same 5.3 from the same vector while carrying different labels on purpose: F2 sits at Medium because its absence is the precondition that makes F1 practical, while F4 stays at Low because account existence is the whole of what it yields and nothing else here depends on it.

Impact

Removes a defence in depth layer on an unauthenticated endpoint and makes the several hundred request extraction in F1 practical.

Reproduction

Step 1: Send a sustained burst of verification attempts

for i in $(seq 1 400); do
  curl -s -o /dev/null -w '%{http_code}\n' \
    -X POST https://lab-1786726255893-tlm1li.labs-app.bugforge.io/api/account/recover \
    -H 'Content-Type: application/json' \
    -d '{"email":"[email protected]","backupCode":"AAAAAA-AAAAAA-AAAAAA"}'
done | sort | uniq -c

Response: every request returns 401. No 429, no Retry-After, no lockout, and response latency is flat across the run. The loop above is illustrative; the burst captured through the proxy and cited above was 363 requests.

Step 2: Confirm the account still behaves normally afterwards

POST /api/account/recover HTTP/1.1
Host: lab-1786726255893-tlm1li.labs-app.bugforge.io
Content-Type: application/json

{"email":"[email protected]","backupCode":"bug{iYatEGmtcOox9d15LIqkO5zogr5FRvWv}"}

Response: 200 verified. The account was never locked and the correct value is still accepted, so the uniform 401s in step 1 were genuine rejections rather than a silent block.

Remediation

Fix 1: Throttle and lock out on repeated failures

// BEFORE (Vulnerable)
app.post('/api/account/recover', recoverHandler);

// AFTER (Secure)
const recoverLimiter = rateLimit({
  windowMs: 15 * 60 * 1000,
  max: 5,
  keyGenerator: (req) => `${req.ip}:${String(req.body?.email ?? '')}`,
  standardHeaders: true,
  handler: (req, res) => res.status(429).json({ error: 'Too many attempts. Try again later.' })
});

app.post('/api/account/recover', recoverLimiter, recoverHandler);

Additional recommendations:

  • Key the limit on both source address and target account, so one address cannot walk the user table and one account cannot be attacked from many addresses.
  • Lock the account’s recovery flow after a small number of consecutive failures and require an out of band step to unlock it.
  • Alert on bursts of failed recovery attempts. A run of several hundred against one account in under a minute is a strong signal on its own.

F3: Socket.IO delivers every notification to every connected client without authentication

Severity: Medium CVSS v3.1: 5.3 (CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:L/I:N/A:N) CWE: CWE-200 (Exposure of Sensitive Information to an Unauthorized Actor), CWE-306 (Missing Authentication for Critical Function) Endpoint: GET /socket.io/ Authentication required: No

Description

The Socket.IO handshake accepts a connection that carries no token, no auth payload and no cookie. The server then emits new_application and status_update to every connected socket with no per user room scoping, so a client that never authenticated receives the notifications generated for every other account on the platform. The only thing separating one user’s notifications from another’s is the comparison in /public/js/app.js, which runs in the browser:

socket.on('new_application', (data) => {
  if (user && user.id === data.recruiterId) { showToast(data.message); }
});

A raw Engine.IO v4 polling client with no credentials received job titles, internal numeric user and job ids, and the fact and timing of applications and hiring decisions belonging to other accounts. The leaked ids and titles match server produced data elsewhere in the application (job id 2 is “Senior Fetch Specialist” owned by recruiter_id 4), so the events carry live data rather than placeholders. Both captured events were generated by test accounts under our control, so the platform wide reach of the leak follows from the absent scoping on the emit, not from observed third party traffic.

Impact

An unauthenticated observer sees applications and hiring decisions belonging to other accounts in real time, including job titles and internal account ids. No credentials appear in the payloads.

Reproduction

Step 1: Complete the handshake with no credentials

curl -s 'https://lab-1786726255893-tlm1li.labs-app.bugforge.io/socket.io/?EIO=4&transport=polling'

Response: 0{"sid":"...","upgrades":["websocket"],"pingInterval":25000,"pingTimeout":20000}. No token was sent and the session was still issued.

Step 2: Join the default namespace and poll for events

python3 tools/sio_listen.py \
  --url https://lab-1786726255893-tlm1li.labs-app.bugforge.io

The tool sends the bare namespace connect frame 40 with no auth object and no cookie jar, then polls. While a second browser session holding a registered seeker account submitted an application, and a registered recruiter account changed an application’s status, the client holding no account received:

42["new_application",{"recruiterId":4,"jobId":"2","message":"New application received for Senior Fetch Specialist"}]
42["status_update",{"userId":6,"message":"Your application status has been updated to: accepted"}]

Neither event was addressed to the listening client, which held no account at all.

Remediation

Fix 1: Authenticate the handshake and emit into a per user room

// BEFORE (Vulnerable)
io.on('connection', (socket) => { /* no auth, no rooms */ });

io.emit('new_application', { recruiterId, jobId, message });

// AFTER (Secure)
io.use((socket, next) => {
  try {
    const payload = jwt.verify(socket.handshake.auth?.token, process.env.JWT_SECRET);
    socket.data.userId = payload.id;
    socket.join(`user:${payload.id}`);
    next();
  } catch (err) {
    next(new Error('unauthorized'));
  }
});

io.to(`user:${recruiterId}`).emit('new_application', { jobId, message });

Additional recommendations:

  • Drop the recipient id from the payload once delivery is scoped. It is only there to support the check in the browser, and it discloses internal account ids.
  • Treat a check that runs in the browser as presentation logic, never as an access control.
  • Re-verify the token on reconnect, not only on the first handshake.

F4: Registration discloses whether a username or email is already registered

Severity: Low (raw CVSS math is 5.3, in the Medium band; labelled Low because the demonstrated impact is enumeration only, with no data read and no state change) CVSS v3.1: 5.3 (CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:L/I:N/A:N) CWE: CWE-204 (Observable Response Discrepancy) Endpoint: POST /api/register Authentication required: No

Description

Registration answers 400 {"error":"Username or email already exists"} for a taken username or email and 200 with a token for a free one, so the endpoint distinguishes registered accounts from unregistered ones without authentication. A follow up login using the original password confirmed that a 400 leaves the existing account untouched, so the 400 reports a genuine pre existing account rather than an overwrite.

Impact

Unauthenticated confirmation of whether a given username or email holds an account.

Reproduction

Step 1: Register a name that is free

POST /api/register HTTP/1.1
Host: lab-1786726255893-tlm1li.labs-app.bugforge.io
Content-Type: application/json

{"username":"d4rk_seeker","email":"[email protected]","full_name":"Darko Seeker","password":"Passw0rd!23","role":"user"}

Response: 200 with a JWT and {"user":{"id":6,"username":"d4rk_seeker",...},"needsOnboarding":true}.

Step 2: Repeat with a name that is taken

POST /api/register HTTP/1.1
Host: lab-1786726255893-tlm1li.labs-app.bugforge.io
Content-Type: application/json

{"username":"d4rk_seeker","email":"[email protected]","full_name":"Darko Seeker","password":"Passw0rd!23","role":"user"}

Response: 400 {"error":"Username or email already exists"}. The two branches are distinguishable on status code and body.

Remediation

Fix 1: Separate the collision check from the response

// BEFORE (Vulnerable)
if (existing) {
  return res.status(400).json({ error: 'Username or email already exists' });
}

// AFTER (Secure)
if (existing) {
  await mailer.sendAccountExistsNotice(email);
  return res.status(202).json({ message: 'Check your email to finish signing up.' });
}

Additional recommendations:

  • Where a username has to be checked live for usability, put that check behind its own rate limited endpoint rather than deriving it from the registration result.
  • Keep the response shape and timing identical on both branches.

OWASP Top 10 Coverage

  • A01:2021 Broken Access Control: Socket.IO notifications intended for one account are delivered to every connected socket, with the only recipient check running in the browser (F3).
  • A03:2021 Injection: Untyped JSON reaches the query layer on /api/account/recover, so attacker supplied structure is interpreted as query operators rather than compared as a value (F1).
  • A04:2021 Insecure Design: The recovery flow verifies a code that is stored in a form allowing ordering comparison, names the matched account on success, and has no reset completion step for the code to protect (F1).
  • A07:2021 Identification and Authentication Failures: Backup code verification is bypassable without a valid code, and the endpoint has no rate limiting or lockout (F1, F2).

Tools Used

Tool Purpose
Caido Proxy capture of request and response pairs, request tempo evidence from Date headers
curl Manual probes, handshake checks, negative controls
tools/operator_map.py Operator support matrix, one should match and one should not match form per operator
tools/blind_extract_v2.py Range predicate extraction, deterministic per character
tools/blind_extract.py First extraction method, kept because it documents the terminal byte failure the range method removes
tools/sio_listen.py Raw Engine.IO v4 polling client with no dependencies and no credentials
ffuf /api/FUZZ route discovery with raft-medium
hashcat JWT HS256 secret attempt against rockyou (-m 16500)

References


Failed Approaches

Approach Result Why It Failed
Object payloads ($ne, $regex) against our own registered account Uniform 401, identical to a rejected string A fresh account has no backup code stored, so the comparison never runs. The negative described the account’s emptiness, not the endpoint. The same payload against a seeded account returned 200
Role self assignment at registration All ten values fell back to user role is whitelisted to {user, recruiter} server side, and no third role exists in the application
GET /api/jobs/:id/applicants against another recruiter’s job [] with a live application present on the target job Row level ownership scoping is applied. The control confirmed the empty array was scoping, not an empty table
PUT /api/applications/:id/status against another account’s application 404 not found or unauthorized, re-fired against a real row Ownership checked server side
PUT /api/jobs/:id against another recruiter’s job 404 not found or unauthorized Ownership checked server side
Profile mass assignment (role, backup_codes, id, email, resume_url) Values silently dropped, profile unchanged Explicit field whitelist on the update
SQL injection in /api/jobs filters (search, location, job_type) Bare ' matched literally, zero rows, no error Queries are parameterized
/api/notifications scope break, 12 parameter variants Byte identical to baseline Hard scoped to the JWT subject
JWT forgery (alg=none, None, NONE, empty signature, payload tampering) 403 on every variant. Secret not in rockyou Signature verification is correct and the algorithm is pinned
Re-registering an existing username as a password reset 400, original password still valid Registration does not overwrite an existing account
Backup code as a hash of a known field (189 md5/sha1/sha256 derivations of username, email, id, name) All invalid The code is not derived from any observable account field
Wildcards and partial matches in the backup code (%, _, *, .*) as strings All invalid The string comparison is exact. Wildcards only work through the operator form, and no LIKE or regex operator exists
Route discovery with ffuf raft-medium over /api/FUZZ plus an OPTIONS Allow sweep Only /api/verify-token and /api/notifications beyond the client map No hidden admin surface, and no backup code generation route anywhere

Untested, not cleared: mass assignment on POST /api/jobs, POST /api/jobs/:id/apply and PUT /api/company; arbitrary status values on PUT /api/applications/:id/status; injection in path parameters and on POST /api/login; server side socket.on() handlers; /api/saved-jobs scope breaks.

Tags: #operator-injection #type-confusion #blind-extraction #account-recovery #socket-io #bugforge Document Version: 1.0 Last Updated: 2026-08-14

#operator-injection #type-confusion #blind-extraction #account-recovery #socket-io #bugforge