A contact-form endpoint returning 200 OK with `{ ok: false, error }` on validation failure. Old convention from an internal RPC framework. HTTP status no longer signals success/failure. Mesrai flagged it.
The vulnerable diff
// api/contact.ts
contactRouter.post("/contact", async (req, res) => {
const result = ContactSchema.safeParse(req.body);
// BUG: status 200 + ok=false. Conflates success and failure.
if (!result.success) {
return res.status(200).json({ ok: false, error: result.error.message });
}
await processContact(result.data);
return res.status(200).json({ ok: true });
});What is wrong
HTTP status codes carry semantic meaning. 2xx = success (client should treat as success). 4xx = client-side error (client should fix and retry). 5xx = server-side error (client should retry with backoff). Returning 200 for a validation failure breaks the contract: clients checking the status code see success and proceed; monitoring tools count failures as successes; retry logic doesn't fire. The fix is to use the right status: 400 for validation errors, 401 for auth, 403 for permission, 404 for missing, 409 for conflict, 422 for semantic invalidity, 5xx for server failure.
The attack
Symptom:
# Client side:
const r = await fetch("/api/contact", ...);
if (r.ok) { // r.ok = true (status 200)
showSuccess(); // wrong UI — request failed validation
}
# Monitoring (Datadog APM): 0 errors despite real validation failures.
# Alerting silent. SRE team misses systematic input issues for weeks.Hard to find without auditing every handler for status discipline.
Mesrai's review comment
mesraipilot · Bot · reviewed 30 sec ago
[mesrai] [code-review] [Logic] [HTTP-Status] [medium]
Validation failure returning 200. HTTP status should reflect outcome:
- 200 — success
- 400 — client supplied invalid data
- 401 — not authenticated
- 403 — authenticated but not authorized
- 404 — resource not found
- 409 — conflict (e.g., already exists)
- 422 — semantically invalid (less common; for cases where the
request is well-formed but business rules reject)
- 5xx — server error
Fix:
if (!result.success) {
return res.status(400).json({ error: result.error.flatten() });
}
Clients that check `r.ok`, APM tooling that counts 4xx/5xx, and
retry logic all start working correctly.The fix
// api/contact.ts — fixed
contactRouter.post("/contact", async (req, res) => {
const result = ContactSchema.safeParse(req.body);
if (!result.success) {
return res.status(400).json({ error: result.error.flatten() });
}
await processContact(result.data);
return res.status(201).end(); // 201 Created for a resource creation
});400 on validation failure. 201 (or 200, depending on resource semantics) on success. Clients, monitoring, and retry logic all see the correct outcome.
Why human review missed it
Internal RPC habits (always 200, business outcome in body) sneak into HTTP APIs because the wrapper convention feels uniform. Mesrai catches handlers returning 200 with `{ ok: false }` / `{ success: false }` / `{ error: ... }` body shapes.
Related rules + further reading
Mesrai rule pack: logic/http-status-reflects-outcome — flags 200 returns with body indicating error.
RFC 9110: HTTP Semantics — status code definitions.
Common in teams that grew up with internal RPC frameworks.
Takeaway
HTTP status reflects outcome. 4xx for client error, 5xx for server. Body conveys details. Mesrai catches every 200-but-failed return.