Endpoints that refuse to be oracles
An oracle is any endpoint that answers a question you didn't intend to expose. Usually it does it through a status code, and usually the status code is the correct one.
Three of these turned up in a newsletter feature, which is not where I expected to spend a day thinking about information leakage. A subscribe form, an unsubscribe link, and an admin list. All small. All leaked something.
Subscribe: the 409 that enumerates your list
The natural implementation of a mailing list signup: look for the address, create it if it's new, return a conflict if it's already there.
That conflict is a lookup service. Point a script at it with a list of email addresses and it will tell you, one 409 at a time, exactly which of them are on your list. For a general newsletter that's mildly bad. For a list that implies something about the person: a study-abroad consultancy's list implies you're planning to emigrate. It's worse, and it's the kind of thing that is nobody's business by default.
So subscribe is idempotent and says the same thing either way:
/**
* Idempotent by design: unlike the waitlist, a repeat subscribe is a no-op
* rather than a 409, and the response never reveals whether the address was
* already on the list (that would be an email-enumeration oracle).
*/
async subscribe(dto: SubscribeDto, meta: RequestMeta) {The comment names the sibling case on purpose. The waitlist does return a conflict, because "you're already on the waitlist, position 340" is information the person is entitled to and actively wants. Same shape, opposite answer, because the two lists mean different things. That's the part worth copying, not "never 409", but check what a repeated request reveals about the first one.
Unsubscribe: the 404 that validates tokens
Unsubscribe links carry a token. A token that doesn't resolve is, on the face of it, a 404.
But the endpoint is unauthenticated by necessity: the whole point is that it works from an email client, in one click, with no session. So a 404 is a free token validity check for anyone who wants to brute-force the space, and every successful guess unsubscribes a real person.
/**
* Always reports success, including for an unknown token: a 404 here would
* turn the endpoint into a token oracle.
*/
async unsubscribe(token: string) {
await this.prisma.newsletterSubscriber.updateMany({
where: { unsubscribeToken: token, status: NewsletterStatus.SUBSCRIBED },
data: {
status: NewsletterStatus.UNSUBSCRIBED,
unsubscribedAt: new Date(),
},
});
return { unsubscribed: true };
}updateMany is doing the work. update throws when nothing matches, and now the exception handler is the oracle instead of the status code. updateMany updates zero rows and returns quietly, so a valid token and a garbage token are indistinguishable from outside: same status, same body, same shape.
This looks like a bug in review. It reads as swallowing a failure. That's exactly why the comment states the threat rather than the behaviour, without it, someone helpfully "fixes" this back into a 404 within a year.
The user-facing side stays honest, incidentally: there's a separate preview endpoint that shows a masked address before you confirm, so a real unsubscribe still feels like it did something specific.
The admin list: a capability in a column
The unsubscribe token isn't an identifier. It's a capability: anyone holding it can unsubscribe that person, no login required.
Which means the admin subscriber list, an ordinary paginated table behind an admin role, must not return it. Not because admins are untrusted, but because the value ends up in a JSON response, a browser cache, a log, a support screenshot. A capability's blast radius is wherever it has ever been copied.
// The token is a capability. It never leaves the server.An explicit select rather than a default include, so adding a field to the model doesn't silently publish it. This is the one of the three that isn't really about attackers. It's about the value escaping through ordinary, well-intentioned plumbing.
Honeypots: lying on purpose
The last one goes further than staying quiet.
Public forms (newsletter, appointment booking) carry a hidden company field. Humans never see it. Bots fill everything.
The obvious response to a filled honeypot is to reject the submission. But a rejection is feedback: a script that gets a 400 knows the submission failed, and whoever wrote it will try again with different field handling until it stops failing. You've built them a test harness.
// Honeypot filled → a bot. Mimic a successful booking without persisting
// anything, so the script gets no failure signal to adapt to.
if (dto.company) {
this.logger.warn(`Honeypot tripped from ${meta.ipAddress ?? 'unknown'}`);
return {
id: null,
slotDate: dto.date,
slotTime: dto.time,
status: AppointmentStatus.PENDING,
assigned: false,
};
}It returns a booking-shaped object that was never written. The bot records a success and moves on. The id: null is the tell for anyone reading the code, and it's server-side only: the bot has no reason to check.
This is the one I'd flag as a tradeoff rather than a straight win. Silent fake success means a false positive is invisible: a browser extension autofilling that field, or an accessibility tool exposing it, produces a person who thinks they booked an appointment and didn't show up in anyone's calendar. The log line exists precisely so that's discoverable. Name the field carefully, keep it genuinely hidden, and watch the warnings.
The common thread
Every one of these started as the textbook-correct response. 409 for a duplicate. 404 for a missing resource. Reject invalid input. Return the model.
The question that changes the answer isn't "is this the right status code." It's what does an attacker learn from the difference between two responses, because an endpoint that responds differently to a hit and a miss is a lookup service for whatever distinguishes them, no matter what it was built for.
And once you've decided to answer identically in both cases, you have to write down why. Every fix here looks like a mistake: a repeat that doesn't conflict, a miss that doesn't 404, a rejection that reports success. The comments aren't documentation. They're the only thing standing between the behaviour and a well-meaning cleanup.