DEV Community

Daniel Pertu
Daniel Pertu

Posted on

Every answer key in our product was on the CDN, with an immutable header

CogniPrep is practice for the psychometric tests employers use. The questions are authored, not generated, and there are a lot of them: 102 JSON files, about 3.9 MB, covering 165 tests across 24 providers. That content is most of the product.

Until recently all of it lived in public/game-questions/, was fetched straight off the CDN by the browser, and was served to anyone who asked with this:

Cache-Control: public, max-age=31536000, immutable
Enter fullscreen mode Exit fullscreen mode

No session. No account. No rate limit. Every question and every answer key, cached at the edge and handed to the world.

The usual comfort here is "yes, but you would have to guess the filenames". You would not. The bank ids are string literals inside the shipped game chunks. Open devtools, search the JavaScript for game-questions, and you have the index.

Step one: get them out of public/

Anything under public/ in Next.js is a static asset served by the CDN. There is no middleware hook, no per-request check, no way to make one file in there conditional. So the first move is not a header change, it is a move:

public/game-questions/*.json  ->  lib/games/questions/banks/*.json
Enter fullscreen mode Exit fullscreen mode

They are now ordinary files in the source tree, read on the server by one module:

export const BANK_DIR = path.join(process.cwd(), 'lib', 'games', 'questions', 'banks');

export async function readQuestionBank(bankId: GameQuestionBankId): Promise<string> {
  return readFile(path.join(BANK_DIR, `${bankId}.json`), 'utf8');
}
Enter fullscreen mode Exit fullscreen mode

Note what is not in that file: a server-only import. It does not need one. The node:fs/promises import already does that job, because Next does not polyfill node: builtins for the browser. A client component that pulled this in would fail the build rather than quietly ship a filesystem path to the browser. The strongest guard is often the one the toolchain enforces for free.

Step two: a union that exists at runtime

The bank ids were a TypeScript union. A union is fine right up to the moment a request arrives carrying params.bankId and you need to check it against the real list, because a union does not exist at runtime.

So the array became the source of truth and the type became derived from it:

export const GAME_QUESTION_BANK_IDS = ['analysis', 'balance', 'order', /* ... */] as const;
export type GameQuestionBankId = (typeof GAME_QUESTION_BANK_IDS)[number];
Enter fullscreen mode Exit fullscreen mode

Adding a bank is still a one-line change, and the type and the runtime allowlist can never drift.

That allowlist is then the only thing standing between a URL parameter and a readFile:

const VALID_BANK_IDS: ReadonlySet<string> = new Set(GAME_QUESTION_BANK_IDS);

export function isGameQuestionBankId(value: string): value is GameQuestionBankId {
  return VALID_BANK_IDS.has(value);
}
Enter fullscreen mode Exit fullscreen mode

An exact-match set, not a sanitising regex. The difference matters: with a regex you are reasoning about whether ..%2f..%2f.env survives your filter, and you will be reasoning about it again the next time someone finds a new encoding. With a set membership check, an id that is not one of the 102 known strings never reaches the filesystem at all. Path traversal is structurally impossible rather than filtered.

Step three: the route

export const runtime = 'nodejs';
export const dynamic = 'force-dynamic';

export const GET = withApiHandler<{ bankId: string }>(
  async ({ params }) => {
    const { bankId } = await params;

    if (!isGameQuestionBankId(bankId)) {
      return apiError('Question bank not found', 404);
    }

    let json: string;
    try {
      json = await readQuestionBank(bankId);
    } catch (error) {
      logError(`Question bank ${bankId} is listed but could not be read`, error);
      return apiError('Question bank not found', 404);
    }

    return new NextResponse(json, {
      headers: {
        'Content-Type': 'application/json',
        'Cache-Control': 'private, max-age=3600',
      },
    });
  },
  { rateLimit: 'default', errorMessage: 'Failed to load question bank' }
);
Enter fullscreen mode Exit fullscreen mode

Four decisions in there are worth pulling out.

force-dynamic is load bearing. The response depends on the caller's session. Without that line a bank could be prerendered at build time and served out of the static output, which would put us back exactly where we started, except now with a route handler that looks like it is doing something.

A 404 for an unknown id, not a 400. Probing for bank names should learn nothing. A 400 saying "that is not a valid id" and a 404 saying "no such bank" are different answers, and the difference is a free oracle telling an attacker which ids are real and which are merely unimplemented.

A listed id with no file on disk gets logged. That is a build or deploy problem, not a bad request. If it silently 404s, the game falls back to an empty question set and the player gets a broken test with nothing in the logs.

Raw text, not NextResponse.json. The bank was already valid JSON on disk. Parsing it in order to re-serialise it burns CPU on every request for a payload the server never inspects.

And private in the cache header is the whole point of the change. This is a per-user authenticated response and must never sit in a shared CDN cache, which is precisely what public, immutable was doing. The hour of browser caching covers a player replaying the same game in one sitting; an in-memory cache in the loader covers the rest.

What this does not fix, and why I am saying so

A signed-in user can still open their network tab and read the bank their game just fetched. Anything the browser renders, the browser can be made to dump. This change does not make the questions secret from the person answering them.

What it changes is who has to bother. Scraping the library now costs an account, is subject to the same rate limits as everything else, and is attributable in logs. That is a different threat model from "anonymous for loop over 102 filenames", and it is the one that was actually available for the price of a file move.

Genuinely hiding answer keys from the player would require server-side grading. We cannot do that yet, because the banks carry no stable question ids. That is a real piece of work, and pretending the current change accomplishes it would be worse than not doing it.

The access check we deliberately did not add

The tempting next line is hasGameAccess(user, provider), so a free user cannot pull a premium provider's bank. We left it out, on purpose, because no bank-to-game mapping exists in the codebase. Bank ids are hardcoded literals inside each engine, and several banks legitimately serve more than one game: one matrix bank feeds two different Matrigma games, and three ability banks back a fourth combined test.

Building that map is worth doing. Guessing at it inside an auth check would break real players' games mid test, which is a worse failure than the one it prevents. Authentication now, authorisation when the mapping exists.

See it for yourself

Open any provider hub, for example the HireVue page or the McKinsey Solve page. Both are public.

Now try the thing that used to work:

curl -i https://cogniprep.app/api/games/questions/hv-vjt
Enter fullscreen mode Exit fullscreen mode

You will not get a question bank.

Then sign up on the free tier, start a test, and watch the same URL in the Network tab: one request, Cache-Control: private, and the bank arrives. Replay the test and there is no second request, because the loader caches it in memory for the session.

Then go and look at your own public/ directory. Ours had been sitting there since the first commit, and the reason nobody noticed is that it worked perfectly.

Top comments (0)