Code Review
15 common bugs to recognize. Click any title to see the code + the fix.
@RestController
public class OrderController {
@Autowired private OrderRepository orderRepo;
@GetMapping("/api/orders")
public List<OrderDTO> listOrders(@RequestParam Long userId) {
List<Order> orders = orderRepo.findByUserId(userId);
List<OrderDTO> out = new ArrayList<>();
for (Order o : orders) {
OrderDTO dto = new OrderDTO();
dto.id = o.getId();
dto.total = o.getTotal();
// Order has @OneToMany(fetch = LAZY) List<LineItem> items;
dto.itemCount = o.getItems().size();
out.add(dto);
}
return out;
}
}This endpoint lists a user's orders. What's the most serious issue?
Each iteration triggers a separate query for line items, producing N+1 queries.
Because items is LAZY, calling getItems().size() inside the loop issues one extra SELECT per order — classic N+1. Field injection is a style smell but not a correctness or performance issue at this scale.
Fix with a JPQL fetch join (SELECT o FROM Order o LEFT JOIN FETCH o.items WHERE o.userId = :userId), an @EntityGraph, or by projecting the count directly in SQL. The pattern generalizes: any time a hot loop touches a lazy association, audit for N+1 — they hide easily in dev with 3 rows and explode at production scale.
import threading
class Counter:
def __init__(self):
self.value = 0
def increment(self):
self.value += 1
def run():
c = Counter()
threads = [threading.Thread(target=lambda: [c.increment() for _ in range(100000)])
for _ in range(10)]
for t in threads: t.start()
for t in threads: t.join()
assert c.value == 1000000, c.valueThe assertion fails intermittently with a value less than 1000000. Why?
self.value += 1 is a read-modify-write that is not atomic across threads, so updates are lost.
x += 1 compiles to LOAD, ADD, STORE — three bytecodes. A thread switch between LOAD and STORE drops the other thread's increment. The GIL guarantees one bytecode at a time, not that += is atomic. Wrap in threading.Lock or use itertools.count / atomic primitives.
This is the canonical lost-update race. The GIL is widely misunderstood: it prevents true parallel bytecode execution but it does NOT make multi-step operations atomic. Anything that reads then writes shared state needs an explicit lock, or you need to use a structure designed for concurrent use (queue.Queue, multiprocessing primitives, or an atomic counter from a C extension).
public List<String> loadUserEmails(long userId) throws SQLException {
Connection conn = dataSource.getConnection();
PreparedStatement ps = conn.prepareStatement(
"SELECT email FROM users WHERE org_id = ?");
ps.setLong(1, userId);
ResultSet rs = ps.executeQuery();
List<String> out = new ArrayList<>();
while (rs.next()) {
out.add(rs.getString("email"));
}
rs.close();
ps.close();
conn.close();
return out;
}This runs fine in dev but the connection pool exhausts in production. What's wrong?
ResultSet.next() can throw, and any exception skips every close() call so connections leak.
Manual close() calls don't run if executeQuery or rs.next() throws — the connection leaks back to nothing. Use try-with-resources so JDBC resources are closed even on exception. The parameter naming (d) is a real code smell but not what exhausts the pool.
Every JDBC resource (Connection, Statement, ResultSet) implements AutoCloseable. try (Connection c = ...; PreparedStatement ps = ...; ResultSet rs = ...) guarantees close in reverse order with suppressed exception handling. Pool exhaustion bugs are almost always missing finally/try-with-resources on an exception path that never fires in dev.
import express from "express";
import { pool } from "./db";
const app = express();
app.use(express.json());
app.get("/api/users/search", async (req, res) => {
const q = String(req.query.q ?? "");
const sql = "SELECT id, email FROM users WHERE email LIKE '%" + q + "%' LIMIT 50";
const { rows } = await pool.query(sql);
res.json(rows);
});What's the most serious issue with this search endpoint?
User input is concatenated directly into SQL, enabling SQL injection.
q comes straight from the query string into the SQL (Structured Query Language) string. A request like ?q=%27 OR 1=1-- dumps every row, and worse payloads can drop tables or exfiltrate data. Use parameterized queries: pool.query('... LIKE $1 ...', ['%' + q + '%']). Index usage (a) is a perf concern, not a vuln.
Parameterized queries send the SQL template and the values on separate channels — the database never parses user input as SQL. ORMs and query builders do this by default; the bug almost always appears when someone reaches for string concatenation for a 'dynamic' clause like ORDER BY. For identifiers you can't parameterize, use an allowlist.
async function renderComments(postId) {
const res = await fetch("/api/comments?post=" + postId);
const comments = await res.json();
const container = document.getElementById("comments");
container.innerHTML = comments
.map(c => "<div class='comment'>" +
"<b>" + c.author + "</b>: " + c.body +
"</div>")
.join("");
}What's the most serious issue here?
innerHTML with unsanitized author and body strings allows stored XSS.
A comment body of <img src=x onerror=fetch('/steal?c='+document.cookie)> executes the moment it's painted. Use textContent for plain text, or build elements with createElement and assign properties — never interpolate user data into an HTML string. URLSearchParams (a) is cleaner code but not a vuln in this shape.
The DOM (Document Object Model) has two parsing modes: textContent treats input as text, innerHTML parses it as HTML. Any user-controlled value going through innerHTML is a stored or reflected XSS (Cross-Site Scripting) unless explicitly sanitized (e.g., DOMPurify). React, Vue, and friends escape by default — the bug usually appears when someone reaches for dangerouslySetInnerHTML or v-html.
from fastapi import FastAPI
import psycopg2
app = FastAPI()
conn = psycopg2.connect("dbname=app user=app")
@app.get("/users/{uid}")
async def get_user(uid: int):
cur = conn.cursor()
cur.execute("SELECT id, email FROM users WHERE id = %s", (uid,))
row = cur.fetchone()
cur.close()
if row is None:
return {"error": "not found"}
return {"id": row[0], "email": row[1]}This endpoint serves fine under low load but tail latency explodes under concurrency. Why?
psycopg2 is synchronous; calling it from async def blocks the event loop for every request.
FastAPI runs async def handlers directly on the event loop. A blocking call there freezes every other in-flight request until the query returns, so latency snowballs under concurrency. Use an async driver (asyncpg, psycopg3 async) or define the handler as def so FastAPI runs it in a threadpool. The shared connection (a) is also wrong but the event-loop block is the load-bearing bug.
The async event loop is cooperative — every coroutine must yield (await something) to let others run. A synchronous blocking call doesn't yield, so the whole process stalls. Rule of thumb: inside async def, every I/O call must be awaited; if your library doesn't have an async API, push it to a thread (asyncio.to_thread) or use a sync def handler.
public void writeReport(String userPath, byte[] data) throws IOException {
File f = new File("/var/reports/" + userPath);
if (f.exists()) {
throw new IOException("Report already exists: " + userPath);
}
try (FileOutputStream out = new FileOutputStream(f)) {
out.write(data);
}
}What is the most serious correctness issue (assume userPath is already validated to be a safe filename)?
Between exists() and the FileOutputStream open, another thread or process can create the file — a TOCTOU race.
exists() and the open() are two separate syscalls. Between them, anything can create the file — including a malicious symlink — and FileOutputStream will happily overwrite it. The atomic fix is Files.newOutputStream(path, StandardOpenOption.CREATE_NEW), which fails atomically if the file exists.
TOCTOU (Time-of-Check Time-of-Use) bugs come from splitting a check and an action that needs to be one atomic step. The kernel exposes atomic primitives (O_CREAT|O_EXCL on POSIX, CREATE_NEW on Java NIO) precisely for this. Same pattern appears with chmod-then-open, stat-then-unlink, and any 'is this still valid?' check followed by a use.
from flask import Flask, request, jsonify
app = Flask(__name__)
def flatten(node):
out = []
if isinstance(node, list):
for child in node:
out.extend(flatten(child))
else:
out.append(node)
return out
@app.post("/flatten")
def handler():
body = request.get_json()
return jsonify(flatten(body))An attacker can crash this worker with a single small request. What's the issue?
flatten recurses on attacker-controlled nesting depth and blows the Python stack with a deeply nested payload.
A payload like [[[[...[1]...]]]] with ~1000 levels overflows the default recursion limit and kills the worker with RecursionError. Convert to an explicit stack-based iterative flatten, or cap depth before recursing. (c) is wrong — list.extend is amortized linear.
Any recursion whose depth is driven by untrusted input is a denial-of-service primitive. Python's default recursion limit (~1000) is shallow precisely because each frame costs real C stack. Rewriting to an explicit stack (work = [root]; while work: ...) makes depth bounded by heap memory instead of C stack, and you can cap it.
const sessions = new Map<string, { lastSeen: number }>();
function reapExpired(now: number, ttlMs: number) {
for (const [id, session] of sessions) {
if (now - session.lastSeen > ttlMs) {
sessions.delete(id);
}
}
}
function touch(id: string) {
sessions.set(id, { lastSeen: Date.now() });
}What's the subtle bug in reapExpired?
There's no synchronization with touch(); a concurrent set during the loop can be lost.
JS Map iteration explicitly supports delete-during-iteration — option (a) is wrong. The real hazard is concurrency: in Node, an await or a callback during reaping (or, more concretely, code that hands the Map to multiple async contexts) can race touch() against delete(), losing live sessions. Snapshot keys or guard the section.
Node is single-threaded but not single-tasked: await yields the event loop and any other handler can mutate the Map. The classic fix is to take a snapshot of keys (Array.from(sessions.keys())) before iterating, or to mark candidates first and delete in a second pass after re-checking lastSeen. The same pattern shows up in worker_threads with shared SharedArrayBuffer state.
import java.math.BigDecimal;
public class PriceCheck {
public static boolean isFreeShipping(BigDecimal subtotal) {
BigDecimal threshold = new BigDecimal("50.00");
return subtotal.equals(threshold) || subtotal.compareTo(threshold) > 0;
}
public static void main(String[] args) {
BigDecimal s = new BigDecimal("50");
System.out.println(isFreeShipping(s));
}
}main prints true — but a similar-looking input prints false. Where's the bug?
BigDecimal.equals returns false for 50 vs 50.00 because it compares scale; use compareTo == 0 for value equality.
BigDecimal.equals considers both value AND scale: new BigDecimal("50").equals(new BigDecimal("50.00")) is false. Use compareTo(other) == 0 for numeric equality. The compareTo > 0 branch in this code happens to mask the bug when subtotal is greater, but the equals call itself is unreliable.
BigDecimal is designed for financial math where scale carries meaning (50.00 USD vs 50 USD might display differently), so equals being scale-sensitive is intentional. The takeaway: for any value-comparison type, check whether equals is value or value+representation. Same trap exists in Python's Decimal with __eq__ across different contexts.
def withdraw(balance: float, amount: float) -> float:
if balance == amount:
return 0.0
if balance < amount:
raise ValueError("insufficient funds")
return balance - amount
balance = 0.1 + 0.2
print(withdraw(balance, 0.3))Why does this raise ValueError instead of returning 0.0?
0.1 + 0.2 is 0.30000000000000004 in IEEE-754, so == 0.3 is false and the < branch runs.
0.1 and 0.2 have no exact binary representation; their sum is slightly greater than 0.3. == on floats almost never does what you want. Use math.isclose(a, b) for tolerance-based comparison, or switch to decimal.Decimal for money. Annotations (d) don't coerce at runtime.
IEEE-754 binary64 represents floats as sign * mantissa * 2^exp; values like 0.1 are non-terminating in binary just as 1/3 is in decimal. For currency, never use float — use Decimal or store integer cents. For general comparisons, use math.isclose(a, b, rel_tol=1e-9, abs_tol=1e-12) and think carefully about what tolerance means in the domain.
@RestController
public class AuthController {
@Value("${admin.token}") private String adminToken;
@PostMapping("/admin/login")
public ResponseEntity<String> login(@RequestBody LoginReq req) {
if (req.token != null && req.token.equals(adminToken)) {
return ResponseEntity.ok(issueSession());
}
return ResponseEntity.status(401).body("nope");
}
private String issueSession() { /* ... */ return "session-cookie"; }
}What's the security flaw here?
String.equals short-circuits at the first mismatched byte, leaking token bytes via a timing side channel.
String.equals returns at the first byte that differs, so comparing 'a*****' takes measurably less time than 'admin1***'. An attacker who can measure response time can recover the token byte by byte. Use MessageDigest.isEqual(a.getBytes(), b.getBytes()) which is constant-time. Rate limiting (d) is good defense in depth but doesn't fix the side channel.
Any comparison of secrets — tokens, HMAC (Hash-based Message Authentication Code) signatures, password hashes — must be constant-time, meaning the runtime depends only on input length, not content. Languages and libraries ship explicit primitives: MessageDigest.isEqual (Java), hmac.compare_digest (Python), crypto.timingSafeEqual (Node). Over a LAN you can typically measure microsecond differences; over the internet, attackers amortize with statistics.
const userCache = new Map<string, User>();
export async function getUser(id: string): Promise<User> {
const cached = userCache.get(id);
if (cached) return cached;
const user = await db.users.findById(id);
userCache.set(id, user);
return user;
}
export async function updateUserEmail(id: string, email: string): Promise<void> {
await db.users.update(id, { email });
}Users report their email change doesn't show up until the process restarts. Why?
updateUserEmail writes to the DB but never invalidates or updates userCache, so stale Users are served indefinitely.
Classic cache-invalidation bug: writes go to the source of truth, reads come from a stale snapshot. Either userCache.delete(id) after the update, or write-through (userCache.set with the new value). WeakMap (d) keys by object identity, which doesn't help here.
Cache invalidation is one of 'the two hard problems' for a reason: the cache and the source of truth are independent state, and every write path is a potential invalidation site. Three common strategies — write-through (update both on write), write-around (delete cache on write, populate on next read), and TTL (Time To Live; eventual consistency). For multi-process deployments, an in-memory Map is also wrong because each process has its own — use Redis with a pub/sub invalidation channel.
const express = require("express");
const cookieParser = require("cookie-parser");
const app = express();
app.use(cookieParser());
app.use(express.json());
function requireSession(req, res, next) {
const sid = req.cookies.sid;
if (!sid || !sessions.has(sid)) return res.status(401).end();
req.user = sessions.get(sid);
next();
}
app.post("/account/transfer", requireSession, async (req, res) => {
const { toAccount, amount } = req.body;
await bank.transfer(req.user.id, toAccount, amount);
res.json({ ok: true });
});A logged-in user visits evil.com and money leaves their account. What's missing?
There's no CSRF protection: the browser auto-sends the session cookie on cross-origin POSTs, so evil.com can forge the transfer.
Cookie auth alone identifies the user but doesn't prove the request was intentional. evil.com can submit a form (or fetch with credentials) to /account/transfer; the browser attaches the session cookie. Mitigations: SameSite=Lax/Strict on the cookie (modern default), a CSRF (Cross-Site Request Forgery) token the server checks, or requiring a custom header that browsers won't add cross-origin without a preflight.
CSRF exploits ambient authority: the browser sends cookies on any request to the origin, even when initiated by a different site. SameSite cookies are the cheapest fix and now default to Lax in Chrome, blocking cross-site POSTs entirely. For deeper defense, use the synchronizer-token pattern (server-issued token in a header/body that doesn't ride on cookies) or double-submit-cookie. HttpOnly (a) defends against XSS-driven cookie theft, not CSRF.