Security lens in review
A guard says who is calling; the review asks whose data, which input, what leaks and what repeats.
Predict first
Before you read: predict
MR !3380 adds a results route to ExamHall's NestJS API. ResultsModule lists the controller and the service, and src/app.module.ts imports it. Candidates sign in, and every request carries the token they signed in with:
Attempt a1 is candidate c1's. After the merge, candidate c2, signed in with her own valid token, sends GET /results/a1.
What comes back?
// src/results/results.controller.ts (new)import { Controller, Get, Param, UseGuards } from '@nestjs/common';import { JwtAuthGuard } from '../auth/jwt-auth.guard';import { ResultsService } from './results.service'; @Controller('results')@UseGuards(JwtAuthGuard)export class ResultsController { constructor(private readonly results: ResultsService) {} @Get(':attemptId') get(@Param('attemptId') attemptId: string) { return this.results.forAttempt(attemptId); }}// src/results/results.service.ts (new)import { Injectable, NotFoundException } from '@nestjs/common';import { PrismaService } from '../infra/prisma.service'; const PASS_MARK = 60; @Injectable()export class ResultsService { constructor(private readonly prisma: PrismaService) {} async forAttempt(attemptId: string) { const attempt = await this.prisma.attempt.findFirst({ where: { id: attemptId } }); if (!attempt) throw new NotFoundException(); return { attemptId, score: attempt.score, passed: Number(attempt.score) >= PASS_MARK }; }}// src/auth/jwt-auth.guard.ts (unchanged)import { CanActivate, ExecutionContext, Injectable, UnauthorizedException } from '@nestjs/common';import { SessionsService } from '../infra/sessions.service'; @Injectable()export class JwtAuthGuard implements CanActivate { constructor(private readonly sessions: SessionsService) {} canActivate(context: ExecutionContext): boolean { const req = context.switchToHttp().getRequest(); const session = this.sessions.verify(req.headers.authorization); if (!session) throw new UnauthorizedException(); req.user = session; return true; }}The 30-second version
Ask four questions of every MR. Who can call the new code, and on whose data? Which input is trusted, and where does it end up? What leaks? What can one caller repeat until it costs you? A guard proves who is calling; only the handler can check the record is theirs. Each finding comes with its test.
A guard says who is calling; the review asks whose data, which input, what leaks and what repeats.
How it works
Step 1 of 12
In the kitchen
- she asks the same four questions of every new segment, whatever it is about → four questions, every MR (step 1)
Analogy: A radio phone-in producer vetting a new segment before it goes on air
| At the station | In the MR |
|---|---|
| she asks the same four questions of every new segment, whatever it is about | four questions, every MR (step 1) |
| the switchboard checks a caller is a listener with a membership number | the guard proves who (step 3) |
| the new "carry on your earlier call" line puts anyone who says "I'm caller 12" into caller 12's slot | the handler must prove whose (step 4) |
| listeners' texts are read live, and the desk fires a jingle for any words in square brackets | untrusted input reaching a sink (steps 7–8) |
| the guest's screen shows every caller's full phone number | a leak to someone who never needed it (step 9) |
| the free call-back button costs the station a call every press, with no cap per number | cap what one caller repeats (step 11) |
In real terms: each new route is a new line into the studio; the four questions decide who gets on air, what is read out, what is shown and what can be pressed again.
Breaks down when: a station has one switchboard. A service has many doors, and a check at one does nothing for the others; each new one gets the four questions.
Contrasting case: a route that serves the same public page to everyone, takes no input and costs nothing extra per call leaves three questions with nothing to find; only "what leaks" still applies.
Examples, run for real
4.1 ExamHall EXH-3080: the test that settles question 1
EXH-3080: ExamHall's answers route loads an attempt with findFirst({ where: { id: attemptId, candidateId } }), the candidate taken from the session. Three callers ask for c1's attempt:
4.1 GET /attempts/a1/answers as c1 -> 200 · 1 answer(s) · stopped by: none · hops 7
4.1 GET /attempts/a1/answers as c2 -> 404 · stopped by: the lookup scoped by candidate (no row) · hops 6
4.1 GET /attempts/a1/answers as no token -> 401 · stopped by: the guard (no session) · hops 2
The guard turns away the request with no token (step 3); the scoped lookup turns away c2, whose token is valid (step 4). Only the second-user call tells the two checks apart (step 6).
4.2 Proof domain, ticketing: what an unrecognised error sends
The box office's price route throws a plain Error when a pricing rule is missing; nothing catches it:
4.2 GET /seats/s2/price -> 500 {"statusCode":500,"message":"Internal server error"}
4.2 server log: 1 line from ExceptionsHandler naming the rule "student-matinee"
4.2 GET /seats/s1/price -> 200 {"seatId":"s1","priceCents":4500}
The buyer gets Nest's generic body; the rule's name stays in the server's own log (step 10).
Try it
Answer first, then check. Nothing is saved.
Question 1 of 7
MR !3384 adds today's holds to a venue's staff dashboard, with a search box that highlights each match. buyerName is whatever each buyer typed at checkout; the dashboard renders with React 19:
Which review comment names a real defect in this MR?
// src/holds/HoldList.tsx (new)import { highlight } from './highlight';import { VENUE_NOTICE } from './venue-notice'; export interface Hold { id: string; buyerName: string; seatLabel: string;} export function HoldList({ holds, query }: { holds: Hold[]; query: string }) { return ( <section> <div className="notice" dangerouslySetInnerHTML={VENUE_NOTICE} /> <ul> {holds.map((h) => ( <li key={h.id} title={h.buyerName}> <span dangerouslySetInnerHTML={{ __html: highlight(h.buyerName, query) }} /> <small>{h.seatLabel}</small> </li> ))} </ul> </section> );}// src/holds/highlight.ts (new)// Wraps each match of the staff member's search in <mark>.export function highlight(text: string, query: string): string { if (!query) return text; return text.split(query).join(`<mark>${query}</mark>`);}// src/holds/venue-notice.ts (new)// Written by the box office team in this repository; changed only by an MR.export const VENUE_NOTICE = { __html: '<strong>Doors open 18:30.</strong> Holds lapse after 15 minutes.' };Question 2 of 7
MR !3381 wraps the box office's order call "so the buyer sees what went wrong". In this deployment the database client throws a plain Error when a seat id has no seat row, and its message names the table, the constraint, the database host and the role. A buyer's browser posts { "seatId": "s9" } for a seat taken off the plan a minute ago:
What does the buyer's browser receive?
// src/orders/orders.controller.ts (excerpt)@Controller('orders')@UseGuards(BuyerGuard)export class OrdersController { constructor(private readonly orders: OrdersService) {} @Post() async place(@Req() req: BuyerRequest, @Body() body: { seatId: string }) { try { return await this.orders.place(req.buyer.id, body.seatId); } catch (e) { throw new InternalServerErrorException((e as Error).message); } }// src/orders/orders.service.ts (excerpt) async place(buyerId: string, seatId: string) { const order = await this.prisma.order.create({ data: { buyerId, seatId } }); return { orderId: order.id }; }Question 3 of 7
MR !3382 adds two routes to ExamHall's candidate API: a status the candidate app polls every 5 s, and a button that texts the candidate a receipt. SmsClient is the SMS provider's API client, which bills ExamHall per message; ClockService is the server's clock. AttemptsModule lists the controller:
Which review comment names a real defect in this MR?
// src/attempts/receipts.controller.ts (new)import { Controller, Get, Param, Post, Req, UseGuards } from '@nestjs/common';import { JwtAuthGuard } from '../auth/jwt-auth.guard';import type { AuthedRequest } from '../auth/authed-request';import { AttemptsService } from './attempts.service'; @Controller('attempts/:attemptId')@UseGuards(JwtAuthGuard)export class ReceiptsController { constructor(private readonly attempts: AttemptsService) {} @Get('status') status(@Req() req: AuthedRequest, @Param('attemptId') attemptId: string) { return this.attempts.statusFor(req.user.id, attemptId); } @Post('receipt') receipt(@Req() req: AuthedRequest, @Param('attemptId') attemptId: string) { return this.attempts.resendReceipt(req.user.id, attemptId); }}// src/attempts/attempts.service.ts (excerpt)const STATUS_TTL_MS = 30_000; async ownedBy(candidateId: string, attemptId: string) { const attempt = await this.prisma.attempt.findFirst({ where: { id: attemptId, candidateId } }); if (!attempt) throw new NotFoundException(); return attempt; } async statusFor(candidateId: string, attemptId: string) { const key = `${candidateId}:${attemptId}`; const hit = this.statusCache.get(key); if (hit && this.clock.now() - hit.at < STATUS_TTL_MS) return hit.value; const attempt = await this.ownedBy(candidateId, attemptId); const value = { attemptId, examCode: attempt.examCode, saved: true }; this.statusCache.set(key, { at: this.clock.now(), value }); return value; } async resendReceipt(candidateId: string, attemptId: string) { const attempt = await this.ownedBy(candidateId, attemptId); await this.sms.send(String(attempt.phone), `ExamHall: your ${attempt.examCode} answers are saved.`); return { sent: true }; }Question 4 of 7
MR !3388 adds a page for each ticket to the box office's buyer API. A ticket's id is a random UUID made when the ticket is issued, and the buyer's confirmation email links to /tickets/<id>. Buyers b1 and b2 hold one ticket each. The MR's description reads "ids are random, so a buyer can only reach her own ticket". TicketsModule lists the controller and the service, and the root module imports it:
A reviewer adds one test before approving.
Which one is red on this MR?
// src/tickets/tickets.controller.ts (new)import { Controller, Get, Param, UseGuards } from '@nestjs/common';import { BuyerGuard } from '../auth/buyer.guard';import { TicketsService } from './tickets.service'; @Controller('tickets')@UseGuards(BuyerGuard)export class TicketsController { constructor(private readonly tickets: TicketsService) {} @Get(':ticketId') show(@Param('ticketId') ticketId: string) { return this.tickets.page(ticketId); }}// src/tickets/tickets.service.ts (new)import { Injectable, NotFoundException } from '@nestjs/common';import { PrismaService } from '../infra/prisma.service'; @Injectable()export class TicketsService { constructor(private readonly prisma: PrismaService) {} async page(ticketId: string) { const ticket = await this.prisma.ticket.findFirst({ where: { id: ticketId } }); if (!ticket) throw new NotFoundException(); return { seat: ticket.seatLabel, holder: ticket.holderName }; }}Question 5 of 7
MR !3383 adds two routes to the box office's order service. An order row holds id, buyerId, seatId, priceCents, email, staffNote and fraudScore; the buyer's order page shows the seat and the price. MailClient is the email provider's client, billed per email:
Ask the four questions of this MR.
Write at most four review comments; each names the caller or the input, what happens, and the test that would show it.
// src/orders/orders.controller.ts (excerpt) @Get(':orderId') find(@Param('orderId') orderId: string) { return this.orders.find(orderId); } @Post(':orderId/resend') resend(@Req() req: BuyerRequest, @Param('orderId') orderId: string) { return this.orders.resendTickets(req.buyer.id, orderId); }// src/orders/orders.service.ts (excerpt) async find(orderId: string) { const order = await this.prisma.order.findFirst({ where: { id: orderId } }); if (!order) throw new NotFoundException(); return order; } async resendTickets(buyerId: string, orderId: string) { const order = await this.prisma.order.findFirst({ where: { id: orderId, buyerId } }); if (!order) throw new NotFoundException(); await this.mail.send(String(order.email), `Your tickets for order ${orderId}`); return { resent: true }; }Question 6 of 7
MR !3386 moves a team chat's channel lookup into one helper; its description says "no behaviour change". Members sign in to one workspace; channel #release (ch-w1-release) belongs to workspace w1, and it holds one message. The controller is unchanged and passes req.member.workspaceId to both methods:
A reviewer adds one test before approving.
Which one is red on this MR?
// src/channels/channels.service.ts (after the MR)import { Injectable, NotFoundException } from '@nestjs/common';import { PrismaService } from '../infra/prisma.service'; @Injectable()export class ChannelsService { constructor(private readonly prisma: PrismaService) {} async byId(channelId: string) { const channel = await this.prisma.channel.findFirst({ where: { id: channelId } }); if (!channel) throw new NotFoundException(); return channel; } async messages(workspaceId: string, channelId: string) { const channel = await this.byId(channelId); return this.prisma.message.findMany({ where: { channelId: channel.id } }); } async post(workspaceId: string, channelId: string, text: string) { const channel = await this.byId(channelId); if (channel.workspaceId !== workspaceId) throw new NotFoundException(); return this.prisma.message.create({ data: { channelId, text } }); }}Question 7 of 7
MR !3387 adds a request log to the team chat. Members' apps send authorization: Bearer <token> on every request, and the log shipper keeps each line searchable by the whole engineering team for 30 days. The channel routes load a channel with findFirst({ where: { id: channelId, workspaceId } }), the workspace taken from the member's token:
Which review comment names a real defect in this MR?
// src/infra/request-log.middleware.ts (new)import { Injectable, NestMiddleware } from '@nestjs/common';import { LogSink } from './log.sink'; @Injectable()export class RequestLogMiddleware implements NestMiddleware { constructor(private readonly log: LogSink) {} use(req: { method: string; originalUrl: string; headers: Record<string, unknown> }, res: unknown, next: () => void) { this.log.write(JSON.stringify({ method: req.method, path: req.originalUrl, headers: req.headers })); next(); }}// src/app.module.ts (after the MR)import { MiddlewareConsumer, Module, NestModule } from '@nestjs/common';import { InfraModule } from './infra/infra.module';import { ChannelsModule } from './channels/channels.module';import { ChannelsController } from './channels/channels.controller';import { RequestLogMiddleware } from './infra/request-log.middleware'; @Module({ imports: [InfraModule, ChannelsModule] })export class AppModule implements NestModule { configure(consumer: MiddlewareConsumer) { consumer.apply(RequestLogMiddleware).forRoutes(ChannelsController); }}Go deeper
Pro tip
- Count the doors before reading any handler. Search the diff for route decorators (
@Get(,@Post(,@Patch(,@Put(,@Delete() and new consumers; each match gets all four questions, one after another. When: opening any backend MR, before the first comment (steps 1–2). Verify (RAN,tips.mjs):
Status: RAN.tip route decorators in ExamHall's src/: 5 in 4 files · routes Nest maps at startup: 5 · each gets the four questions
Source
Every behaviour this lesson grades is executed on NestJS 12.1.2 or React 19.3.0; the sources support the steps and key nothing. OWASP's API Top 10 and Code Review Guide are dated documents: valid as of 2026-10-06.
- OWASP API Security Top 10, 2023 edition (the curriculum row's first source), https://owasp.org/API-Security/editions/2023/en/0x11-t10/ (
owasp-api-top10-2023.txt), and its pages for the three risks this lesson's questions meet:- API1:2023 Broken Object Level Authorization, https://owasp.org/API-Security/editions/2023/en/0xa1-broken-object-level-authorization/ (
owasp-api1-bola.txt). Quoted: "Every API endpoint that receives an ID of an object, and performs any action on the object, should implement object-level authorization checks." Supports steps 3–4. Quoted: "Object IDs can be anything from sequential integers, UUIDs, or generic strings. Regardless of the data type, they are easy to identify in the request target". Supports step 5. Quoted: "Write tests to evaluate the vulnerability of the authorization mechanism. Do not deploy changes that make the tests fail." Supports steps 6 and 12. - API4:2023 Unrestricted Resource Consumption, https://owasp.org/API-Security/editions/2023/en/0xa4-unrestricted-resource-consumption/ (
owasp-api4-resource-consumption.txt). Quoted: "paid for per request, such as sending emails/SMS/phone calls, biometrics validation, etc." Supports step 11. - API8:2023 Security Misconfiguration, https://owasp.org/API-Security/editions/2023/en/0xa8-security-misconfiguration/ (
owasp-api8-misconfiguration.txt). Quoted: "Error messages include stack traces, or expose other sensitive information". Supports steps 9–10.
- API1:2023 Broken Object Level Authorization, https://owasp.org/API-Security/editions/2023/en/0xa1-broken-object-level-authorization/ (
- OWASP Code Review Guide (the row's second source), https://owasp.org/www-project-code-review-guide/ (
owasp-code-review-guide.txt). Quoted: "the need for manual security code reviews still needs to have a prominent place". Motivates the pack; the guide's chapters follow the 2013 Top 10, so it keys nothing here. - react.dev, Common components: dangerously setting the inner HTML (the row's third source), https://react.dev/reference/react-dom/components/common#dangerously-setting-the-inner-html (
react-dom-common.txt). Quoted: "Only usedangerouslySetInnerHTMLwith trusted and sanitized data." Supports steps 7–8. - NestJS documentation, Exception filters, https://docs.nestjs.com/exception-filters (
nestjs-exception-filters.txt). Quoted: "When an exception is unrecognized (it is neither anHttpExceptionnor a class that inherits fromHttpException), the built-in exception filter generates the following default JSON response:" Supports step 10.
Related lessons
- Read first: Broken object-level authorization: whose record is this?
- Read first: Defect taxonomy scan
- Related: Entry-point-first reading
- Related: Authorization models and enforcement
- Related: Multi-tenancy isolation and Postgres row-level security
- Related: XSS and Content Security Policy
- Related: Validation at the boundary: ValidationPipe, whitelisting, mass assignment
- Related: Rate limiting
Checked against the tools and docs as of 2026-10-06.