Description
POST /api/interview-experiences deduplicates submissions by idempotencyKey alone, with no scope to the submitting user or client key. Anyone who re-uses a key gets the first submitter's entire submission back.
backend/controllers/interviewExperienceController.js:73-80:
const existing = await InterviewExperience.findOne({ idempotencyKey });
if (existing) {
return res.status(200).json({
success: true,
message: "Interview experience already submitted",
experience: toClientShape(existing), // full submission: company, role, rounds, tips, summary...
});
}
The same unscoped lookup runs again on the unique-index race at interviewExperienceController.js:91-102.
The key is client-supplied (the frontend generates it at frontend/src/pages/InterviewExperiences/InterviewExperiences.jsx:528-530; the validator only requires 8-64 characters, see backend/Input_validators/ValidateInterviewExperience.js), and the route is public with optional auth, so the key is a predictable string like submit-<timestamp>-<random>.
Expected Behavior
Idempotency dedup should only return a match for the same author (same userId when authenticated, or same clientKey for anonymous submissions). A different user re-using a key should get their own fresh submission, not someone else's data.
Actual Behavior
- User A submits an experience with
idempotencyKey = "X". User B (or an anonymous visitor with any valid clientKey) POSTs with the same key "X" — maybe by simply copying the pattern, a stale retry from a shared browser, or a replayed request.
- The controller finds User A's document via the global
findOne({ idempotencyKey }) and returns 200 with User A's full submission (toClientShape includes company, role, experience, difficulty, rounds, summary, tips).
- User B never gets their submission created (the
11000 branch at :91 has the same flaw), so their content is silently lost.
- Because the key is guessable (
submit- + timestamp + 36-base random), this is reachable without any special privileges.
Proposed Fix
- Scope the dedup to the author in both places. For example:
const authorFilter = req.user?._id
? { userId: req.user._id }
: { clientKey: payload.clientKey };
const existing = await InterviewExperience.findOne({
idempotencyKey,
...authorFilter,
});
- Mirror the same filter in the
11000 race branch (interviewExperienceController.js:91-102).
- Optionally tighten the validator to require a format that is not predictable (e.g. at least one cryptographically random segment), since the current
submit-<Date.now()>-<Math.random> fallback is guessable.
Description
POST /api/interview-experiencesdeduplicates submissions byidempotencyKeyalone, with no scope to the submitting user or client key. Anyone who re-uses a key gets the first submitter's entire submission back.backend/controllers/interviewExperienceController.js:73-80:The same unscoped lookup runs again on the unique-index race at
interviewExperienceController.js:91-102.The key is client-supplied (the frontend generates it at
frontend/src/pages/InterviewExperiences/InterviewExperiences.jsx:528-530; the validator only requires 8-64 characters, seebackend/Input_validators/ValidateInterviewExperience.js), and the route is public with optional auth, so the key is a predictable string likesubmit-<timestamp>-<random>.Expected Behavior
Idempotency dedup should only return a match for the same author (same
userIdwhen authenticated, or sameclientKeyfor anonymous submissions). A different user re-using a key should get their own fresh submission, not someone else's data.Actual Behavior
idempotencyKey = "X". User B (or an anonymous visitor with any validclientKey) POSTs with the same key"X"— maybe by simply copying the pattern, a stale retry from a shared browser, or a replayed request.findOne({ idempotencyKey })and returns200with User A's full submission (toClientShapeincludescompany,role,experience,difficulty,rounds,summary,tips).11000branch at:91has the same flaw), so their content is silently lost.submit-+ timestamp + 36-base random), this is reachable without any special privileges.Proposed Fix
11000race branch (interviewExperienceController.js:91-102).submit-<Date.now()>-<Math.random>fallback is guessable.