Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
118 changes: 118 additions & 0 deletions backend/src/__tests__/answerKeyLeak.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
// Unit tests for the answer-key leak fix (stripAnswerKey, utils/sanitize.js) and the reveal
// gating used by GET /api/responses/room/:roomId/student/:studentId and GET /api/questions.
// Regression coverage for security-poc/leaderboard_bot.mjs finding #7: a student must never
// receive options[].isCorrect (or explanation) for a question they have not answered yet.

const { stripAnswerKey } = require('../utils/sanitize.js')
const { secondsSinceLaunch, isWithinAnswerWindow } = require('../utils/answerWindow.js')

describe('stripAnswerKey', () => {
const question = {
_id: 'q1',
question: 'What is 2+2?',
explanation: 'Basic arithmetic',
options: [
{ text: '3', isCorrect: false },
{ text: '4', isCorrect: true }
]
}

it('removes isCorrect from every option when reveal is false', () => {
const result = stripAnswerKey(question, false)
expect(result.options).toEqual([{ text: '3' }, { text: '4' }])
result.options.forEach((opt) => expect(opt).not.toHaveProperty('isCorrect'))
})

it('removes explanation when reveal is false', () => {
const result = stripAnswerKey(question, false)
expect(result).not.toHaveProperty('explanation')
})

it('passes the document through unchanged when reveal is true', () => {
const result = stripAnswerKey(question, true)
expect(result).toBe(question) // same reference — no copy needed when revealing
expect(result.options[1].isCorrect).toBe(true)
})

it('does not mutate the original document', () => {
const original = JSON.parse(JSON.stringify(question))
stripAnswerKey(question, false)
expect(question).toEqual(original)
})

it('handles a null/undefined question gracefully', () => {
expect(stripAnswerKey(null, false)).toBeNull()
expect(stripAnswerKey(undefined, true)).toBeUndefined()
})

it('leaves non-array options untouched', () => {
const weird = { ...question, options: null }
const result = stripAnswerKey(weird, false)
expect(result.options).toBeNull()
})
})

describe('reveal gating — GET /questions (answered-only)', () => {
// Mirrors: `const reveal = isTeacher || !!studentResponse` in questions.js
function shouldReveal(isTeacher, studentResponse) {
return isTeacher || !!studentResponse
}

it('always reveals to the teacher, answered or not', () => {
expect(shouldReveal(true, null)).toBe(true)
expect(shouldReveal(true, { selectedOption: 0 })).toBe(true)
})

it('hides the key from a student who has not answered yet', () => {
expect(shouldReveal(false, null)).toBe(false)
expect(shouldReveal(false, undefined)).toBe(false)
})

it('reveals to a student once they have answered', () => {
expect(shouldReveal(false, { selectedOption: 1, isCorrect: true })).toBe(true)
})
})

describe('reveal gating — GET /responses/room/:roomId/student/:studentId (answered OR window closed)', () => {
// Mirrors responses.js: reveal if teacher, already answered, or the answer window has closed
// (so the "Missed — correct answer was X" review UI still works for genuinely past questions).
function shouldReveal(isTeacher, studentResponse, launchedAt, timeToAnswer, now) {
const windowClosed = !isWithinAnswerWindow(secondsSinceLaunch(launchedAt, now), timeToAnswer)
return isTeacher || !!studentResponse || windowClosed
}

it('hides the key for a live, unanswered question — the actual exploit path', () => {
const now = Date.now()
const launchedAt = new Date(now - 5000) // launched 5s ago, tta=30s — still live
expect(shouldReveal(false, null, launchedAt, 30, now)).toBe(false)
})

it('reveals the key once the question is answered, even while still live', () => {
const now = Date.now()
const launchedAt = new Date(now - 5000)
expect(shouldReveal(false, { selectedOption: 0 }, launchedAt, 30, now)).toBe(true)
})

it('reveals the key for a genuinely missed question once its window has closed', () => {
const now = Date.now()
const launchedAt = new Date(now - 60_000) // launched 60s ago, tta=30s — long closed
expect(shouldReveal(false, null, launchedAt, 30, now)).toBe(true)
})

it('exercises the leaderboard_bot.mjs exploit path end to end: no isCorrect on a live question', () => {
const now = Date.now()
const launchedAt = new Date(now - 2000) // just launched
const revealed = shouldReveal(false, null, launchedAt, 30, now)
const liveQuestion = {
_id: 'live-q',
options: revealed
? [{ text: 'A', isCorrect: false }, { text: 'B', isCorrect: true }]
: [{ text: 'A' }, { text: 'B' }] // stripped, as the fixed route sends for a live question
}
const idxs = liveQuestion.options
.map((opt, idx) => (opt.isCorrect ? idx : -1))
.filter((idx) => idx !== -1)
expect(revealed).toBe(false)
expect(idxs).toHaveLength(0) // bot has nothing to submit
})
})
125 changes: 125 additions & 0 deletions backend/src/__tests__/answerWindow.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
// Unit tests for the server-side answer-window enforcement (utils/answerWindow.js) used by
// POST /api/responses. Regression coverage for two exploits documented in
// security-poc/leaderboard_bot.mjs:
// 1. Answering a question long after its timeToAnswer window has closed.
// 2. Forging a near-zero responseTime to farm max points regardless of actual reaction time.

const {
ANSWER_GRACE_S,
RESPONSE_TIME_SLACK_S,
secondsSinceLaunch,
isWithinAnswerWindow,
resolveResponseTime
} = require('../utils/answerWindow.js')

describe('secondsSinceLaunch', () => {
it('returns 0 when launchedAt is missing', () => {
expect(secondsSinceLaunch(null)).toBe(0)
expect(secondsSinceLaunch(undefined)).toBe(0)
})

it('computes elapsed seconds from a Date', () => {
const now = 1_000_000
const launchedAt = new Date(now - 10_000) // 10s ago
expect(secondsSinceLaunch(launchedAt, now)).toBeCloseTo(10, 5)
})

it('computes elapsed seconds from an ISO string (as read back from Mongo)', () => {
const now = Date.now()
const launchedAt = new Date(now - 5000).toISOString()
expect(secondsSinceLaunch(launchedAt, now)).toBeCloseTo(5, 1)
})
})

describe('isWithinAnswerWindow', () => {
const tta = 30 // typical timeToAnswer

it('allows a submission within the window', () => {
expect(isWithinAnswerWindow(0, tta)).toBe(true)
expect(isWithinAnswerWindow(29, tta)).toBe(true)
})

it('allows a submission inside the grace buffer past the window', () => {
expect(isWithinAnswerWindow(tta + ANSWER_GRACE_S, tta)).toBe(true)
expect(isWithinAnswerWindow(tta + ANSWER_GRACE_S - 0.01, tta)).toBe(true)
})

it('rejects a submission past the window + grace', () => {
expect(isWithinAnswerWindow(tta + ANSWER_GRACE_S + 0.01, tta)).toBe(false)
})

it('rejects the leaderboard_bot.mjs scenario: joining an hour into a live session', () => {
const elapsedS = 60 * 60 // 1 hour after launch
expect(isWithinAnswerWindow(elapsedS, tta)).toBe(false)
})
})

describe('resolveResponseTime', () => {
it('trusts a plausible client value close to real elapsed time', () => {
// Legit client: clicked at 12s in, jitter delay adds ~1s network time before it lands.
const elapsedS = 13
expect(resolveResponseTime(12, elapsedS)).toBe(12)
})

it('floors a forged near-zero responseTime to the real elapsed time (minus the response slack)', () => {
// security-poc/leaderboard_bot.mjs default: --responseTime=1
const elapsedS = 20
const resolved = resolveResponseTime(1, elapsedS)
expect(resolved).toBe(Math.max(0, elapsedS - RESPONSE_TIME_SLACK_S))
expect(resolved).toBeGreaterThan(1)
})

it('floors a missing/zero responseTime the same way', () => {
const elapsedS = 25
expect(resolveResponseTime(undefined, elapsedS)).toBe(elapsedS - RESPONSE_TIME_SLACK_S)
expect(resolveResponseTime(0, elapsedS)).toBe(elapsedS - RESPONSE_TIME_SLACK_S)
})

it('never returns a negative responseTime under clock skew', () => {
expect(resolveResponseTime(0, -5)).toBe(0)
})

it('does not penalize a genuinely instant, honestly-reported answer', () => {
// Real click at t=0, request lands ~0.2s later — well inside the slack window.
expect(resolveResponseTime(0, 0.2)).toBe(0)
})

it('uses a slack strictly smaller than the deadline grace (they serve different purposes)', () => {
// The deadline grace tolerates a late *arrival*; the response-time slack bounds how much a
// claimed *reaction time* may undercut reality. Conflating them (using ANSWER_GRACE_S for
// both) lets every submission shave a flat multi-second discount off its score regardless of
// when it actually landed — this asserts the two stay independently tunable and separate.
expect(RESPONSE_TIME_SLACK_S).toBeLessThan(ANSWER_GRACE_S)
})
})

describe('end-to-end scoring impact (mirrors the formula in routes/responses.js)', () => {
function scoreFor(clientResponseTime, launchedAt, tta, maxPoints, now = Date.now()) {
const elapsedS = secondsSinceLaunch(launchedAt, now)
if (!isWithinAnswerWindow(elapsedS, tta)) return { rejected: true }
const respTime = resolveResponseTime(clientResponseTime, elapsedS)
const timeDecayFactor = Math.max(0.1, Math.max(0, tta - respTime) / tta)
return { rejected: false, points: Math.round(maxPoints * timeDecayFactor) }
}

it('rejects a bot answering a question launched an hour ago, regardless of forged responseTime', () => {
const launchedAt = new Date(Date.now() - 60 * 60 * 1000)
const result = scoreFor(1, launchedAt, 30, 100)
expect(result.rejected).toBe(true)
})

it('caps a forged near-zero responseTime far below what it used to earn', () => {
const now = Date.now()
const launchedAt = new Date(now - 25_000) // answered with 25s of real elapsed time
const forged = scoreFor(1, launchedAt, 30, 100, now) // bot claims responseTime=1
const honest = scoreFor(25, launchedAt, 30, 100, now) // honest student reports the true 25s

// Before this fix, a forged responseTime=1 against tta=30 scored round(100*(29/30)) = 97 —
// nearly the maximum, regardless of the 25s that actually elapsed. The floor (elapsedS - grace)
// caps that: it can still edge out an honest report by at most the grace window's worth of
// points, never back up near the old 97.
expect(forged.rejected).toBe(false)
expect(forged.points).toBeLessThanOrEqual(honest.points + 20)
expect(forged.points).toBeLessThan(50)
})
})
8 changes: 8 additions & 0 deletions backend/src/models/Question.js
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,14 @@ const questionSchema = new mongoose.Schema({
enum: ['pending', 'approved', 'rejected'],
default: 'pending'
},
// Set the moment the question is created with status 'approved' — today that IS the launch
// moment (POST /api/questions creates the doc and broadcasts it to students in the same
// request). This is the clock POST /api/responses checks the answer window against. Null on
// documents written before this field existed; those fall back to createdAt (see responses.js).
launchedAt: {
type: Date,
default: null
},
timeToAnswer: {
type: Number,
default: 30
Expand Down
24 changes: 21 additions & 3 deletions backend/src/routes/questions.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import express from 'express'
import { authenticate, authorize } from '../middleware/auth.js'
import { generateQuestions, AI_PROVIDERS } from '../services/questionService.js'
import { getGenerationQueue } from '../services/generationQueue.js'
import { stripObject } from '../utils/sanitize.js'
import { stripObject, stripAnswerKey } from '../utils/sanitize.js'

const router = express.Router()

Expand Down Expand Up @@ -131,6 +131,13 @@ router.post('/', authorize('teacher'), async (req, res) => {
// literally (e.g. ") on the student side.
const sanitizedData = stripObject({ roomId, type, question, options, timeToAnswer, points, status, segmentIndex })

// status defaults to 'approved' (see destructure above) and every caller in this app creates
// a question already-approved — that POST is the launch moment, so stamp it here. The answer
// window in POST /api/responses is measured from this timestamp.
if (sanitizedData.status === 'approved') {
sanitizedData.launchedAt = new Date()
}

const newQuestion = new Question(sanitizedData)

await newQuestion.save()
Expand Down Expand Up @@ -178,10 +185,21 @@ router.get('/', async (req, res) => {
Question.find({ roomId }).sort({ createdAt: -1 }).skip(skip).limit(limitNum).lean(),
Question.countDocuments({ roomId })
])


// Teachers always get the full doc. A student gets the answer key only for questions they
// have already answered — otherwise this listing hands them options[].isCorrect for a
// question that's still live (or hasn't been attempted), same leak as the /responses route.
let safeQuestions = questions
if (!isTeacher) {
const Response = (await import('../models/Response.js')).default
const answeredIds = await Response.distinct('questionId', { roomId, studentId: currentUser._id })
const answered = new Set(answeredIds.map((id) => id.toString()))
safeQuestions = questions.map((q) => stripAnswerKey(q, answered.has(q._id.toString())))
}

res.json({
success: true,
questions,
questions: safeQuestions,
pagination: {
page: pageNum,
limit: limitNum,
Expand Down
35 changes: 31 additions & 4 deletions backend/src/routes/responses.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ import { authenticate, authorize } from '../middleware/auth.js'
import { isBatchEnabled, bufferResponse } from '../services/responseBuffer.js'
import * as resultsSnapshot from '../services/resultsSnapshot.js'
import { debug } from '../utils/debug.js'
import { stripAnswerKey } from '../utils/sanitize.js'
import { secondsSinceLaunch, isWithinAnswerWindow, resolveResponseTime } from '../utils/answerWindow.js'
const router = express.Router()

// Apply authentication to all routes
Expand Down Expand Up @@ -115,6 +117,15 @@ router.post('/', authorize('student'), async (req, res) => {
return res.status(404).json({ error: 'Question not found' })
}

// Answer window: reject anything submitted after the question's timeToAnswer (+ grace) has
// elapsed since it launched. launchedAt is set at creation for every question created today;
// fall back to createdAt for documents written before this field existed.
const tta = question.timeToAnswer || 30
const elapsedS = secondsSinceLaunch(question.launchedAt || question.createdAt)
if (!isWithinAnswerWindow(elapsedS, tta)) {
return res.status(410).json({ error: 'This question is no longer accepting answers' })
}

// Check if answer is correct based on question type
let isCorrect = false

Expand Down Expand Up @@ -142,8 +153,10 @@ router.post('/', authorize('student'), async (req, res) => {
// Formula: earnedPoints = isCorrect ? maxPoints × max(0.1, (tta - responseTime) / tta) : 0
// Minimum 10% of max points for correct answers (even if time runs out)
const maxPoints = question.points || 100
const tta = question.timeToAnswer || 30
const respTime = responseTime || 0
// responseTime is client-reported and can be forged (see security-poc/leaderboard_bot.mjs) —
// floor it to what the server itself observed elapsed, so a claimed value can never read as
// *faster* than physically possible.
const respTime = resolveResponseTime(responseTime, elapsedS)
let points = 0

if (isCorrect) {
Expand Down Expand Up @@ -549,12 +562,26 @@ router.get('/room/:roomId/student/:studentId', async (req, res) => {
if (studentResponse) {
debug(`[responses] Matched question ${qIdStr} with response, selectedOption: ${studentResponse.selectedOption}`)
}


// Answer-key protection: this endpoint is polled by every student's client on load and on
// every poll transition. Unconditionally sending options[].isCorrect here — even for a
// question the student hasn't answered — hands out the correct answer for free (this is
// exactly what security-poc/leaderboard_bot.mjs reads to auto-answer every question). Reveal
// once the student has answered (so the "your answer was right/wrong" badges work), OR once
// the question's own answer window has genuinely closed (so the "Missed — correct answer
// was X" review still works for questions no longer live) — never while it's still live and
// unanswered. Teachers always get it.
const answerWindowClosed = !isWithinAnswerWindow(
secondsSinceLaunch(q.launchedAt || q.createdAt),
q.timeToAnswer || 30
)
const safe = stripAnswerKey(q, isTeacher || !!studentResponse || answerWindowClosed)

return {
_id: qIdStr,
question: q.question,
type: q.type,
options: q.options,
options: safe.options,
segmentIndex: q.segmentIndex,
maxPoints: q.points,
timeToAnswer: q.timeToAnswer,
Expand Down
Loading