Skip to content

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?

Code for this question
// 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;  }}
Pick one

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

Four questions, every MR. Who can call it, on whose data; which input is trusted; what leaks; what one caller can repeat.
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?

Code for this question
// 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.' };
Pick one

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?

Code for this question
// 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 };  }
Pick one

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?

Code for this question
// 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 };  }
Pick one

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?

Code for this question
// 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 };  }}
Pick one

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.

Code for this question
// 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?

Code for this question
// 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 } });  }}
Pick one

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?

Code for this question
// 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);  }}
Pick one

Go deeper

Pro tip
  1. 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):
    tip route decorators in ExamHall's src/: 5 in 4 files · routes Nest maps at startup: 5 · each gets the four questions
    Status: RAN.
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.

Checked against the tools and docs as of 2026-10-06.