The parameter that let you overwrite someone else's quiz
One internal caller needed to set a quiz's ID by hand. The public endpoint accepted the same parameter from anyone, and the storage layer used it as a primary key.
By Meet ModiA quiz-generation endpoint accepted an optional parameter for the ID the new quiz should be saved under. One internal caller genuinely needed it: a chat feature where the quiz's ID had to match an existing chat message's ID, so the frontend could look the quiz up later by that message ID.
The public version of the same endpoint accepted the exact same parameter. Nothing distinguished the two callers at the point where the parameter got used.
The bug: the storage layer used that ID as the primary key for an upsert. Anyone who knew, or guessed, another user's quiz ID could send a generation request under that ID and silently overwrite it.
Take 1: the parameter, unconditionally
async function generateQuiz(req: GenerateQuizRequest) {
const quizId = req.customId ?? generateId();
const questions = await quizGenerator.generate(req.topic, req.difficulty);
await db.quizzes.upsert(quizId, {
topic: req.topic,
questions,
ownerId: req.userId,
});
return { quizId };
}This is one function serving both the chat integration and the public API, and it has no idea which one is calling it. req.customId gets trusted either way. Send a generation request with customId set to a quiz you don't own, and upsert does exactly what upsert is supposed to do: it overwrites the row at that key.
It's not a bug in the upsert. The upsert did its job. The bug is that a public, unauthenticated-for-this-purpose caller had a path to a primary key that was never supposed to be theirs to set.
Take 2: gate the parameter by caller, not by removing it
Deleting customId outright would have broken the chat feature, which has a real invariant to preserve: quiz ID must equal message ID, or the frontend's lookup breaks. The parameter isn't the problem. Letting every caller use it is.
async function generateQuiz(req: GenerateQuizRequest) {
if (req.customId && !req.isInternalChatCaller) {
throw new BadRequestError(
"customId is not permitted on this route"
);
}
const quizId = req.customId ?? generateId();
const questions = await quizGenerator.generate(req.topic, req.difficulty);
await db.quizzes.upsert(quizId, {
topic: req.topic,
questions,
ownerId: req.userId,
});
return { quizId };
}isInternalChatCaller isn't a field a client can set. It's populated by the route layer itself, based on which route handled the request, before this shared function ever sees the payload. The public route never sets it. The chat route always does. A public caller sending customId now gets a 400 before the generator or the database does any work at all.
What I should have done first
I should have asked, the moment this endpoint got exposed publicly, which of its parameters existed for a caller that no longer applied. customId was written for exactly one caller and reviewed for exactly that caller's use case. Nobody re-reviewed it against a public audience, because it didn't feel like a new parameter, it was already there.
Anything that behaves like a primary key deserves a second look every time its audience changes, even if the parameter itself doesn't change at all.
More Posts
The vector database was innocent
I built a RAG service over the OpenTelemetry docs, then pointed OpenTelemetry back at it to find out why answers took 16 seconds. It wasn't the LLM. It wasn't the vector search either.
A query that returned nothing nested inside it
The exact same logical query returned full nested data through the REST layer and empty shells through the service layer underneath it. Same fields requested. Same backend. One shorthand parameter that only one of the two actually understood.
One field, three different envelopes, and a fix that made it worse
The same subscription-status field came back nested two levels deep, one level deep, or bare at the top, depending on account state. My fix didn't catch that. It added math on top of a check that was already wrong.