DEV Community

Khatai Huseynzada
Khatai Huseynzada

Posted on

What a chess parser taught me about trusting user input

I build a chess diagram tool. You type a position, it draws a board, you export a nice image. Simple.

The whole thing runs on one string: the FEN. It looks like this:

rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1
Enter fullscreen mode Exit fullscreen mode

That's the starting position. Eight ranks separated by slashes, a letter per piece, a number for empty squares. Harmless.

Except that string doesn't always come from a keyboard. It comes from a URL somebody shared. From localStorage that could have been edited. From a cloud row that synced back down. From a database API I don't control. Every one of those is a door, and for a while I was leaving them open because the input looked boring.

Here's what I learned closing them.

"It's just a string" is the lie

The trap with input like a FEN is that it feels too small to be dangerous. It's 40-ish characters of chess notation. What's the worst it can do?

But your code doesn't treat it as 40 characters. It parses it. It stores it. It stringifies it into a filename. It renders it back into the DOM. Each of those steps trusts the string a little more than the last, and the string never earned that trust — you just assumed it.

So I stopped asking "is this valid chess?" and started asking "what happens if this is not what I expect, at every single place I touch it?"

Door 1: length, before anything else

The first thing a parser should do is refuse to parse.

A real FEN maxes out around 90 characters. So the cap comes first, before any splitting or looping:

const MAX_FEN_LENGTH = 93;

function parseFEN(fenString: string) {
  if (!fenString || typeof fenString !== 'string')
    throw new FENParseError('Invalid FEN string');
  if (fenString.length > MAX_FEN_LENGTH)
    throw new FENParseError('FEN string exceeds maximum length');
  // ...only now do we start reading it
}
Enter fullscreen mode Exit fullscreen mode

This isn't about chess. It's that any string arriving from outside can be a megabyte, and if the first thing I do is .split() and loop over it, I've handed a stranger the ability to make my main thread chew on garbage. The length check is a bouncer at the door. Cheap, boring, first.

The nice side effect: I enforce the same cap everywhere a FEN enters — the URL handler, the share link, the input field's maxLength. One number, checked at every door, instead of "I'm sure the parser handles it."

Door 2: parsing is validation, not decoration

My early parser was optimistic. It split on /, walked each rank, and mostly assumed the pieces were pieces. If something weird showed up, it kind of... limped along and produced a half-broken board.

That's the worst outcome. Not a crash — a quiet wrong answer that flows downstream.

The fix was to make the parser strict and loud. Every rank has to have exactly 8 squares. Every character is either a digit or a real piece letter, nothing else. Anything off the script throws:

for (const char of row) {
  if (VALID_DIGITS.has(char)) {
    const count = parseInt(char, 10);
    squareCount += count;
    for (let i = 0; i < count; i++) boardRow.push('');
  } else {
    if (!isPieceSymbol(char))
      throw new FENParseError(`Invalid piece character '${char}'`);
    squareCount++;
    boardRow.push(char);
  }
}
if (squareCount !== 8)
  throw new FENParseError(`Rank has ${squareCount} squares instead of 8`);
Enter fullscreen mode Exit fullscreen mode

The rule I follow now: a parser that returns a "kind of okay" result on bad input is a bug generator. Either you get a valid board or you get an error you can catch. There is no third thing.

Door 3: JSON.parse is not a safe way to read stored data

This one actually surprised me.

I sync positions and settings. They live in localStorage and in a cloud row, and both come back as strings I JSON.parse. Standard stuff. But JSON.parse will happily reconstruct anything in that string, including keys you never want to see:

{ "fen": "…", "__proto__": { "isAdmin": true } }
Enter fullscreen mode Exit fullscreen mode

Parse that naively and, depending on how the object flows through your code, you can end up polluting the prototype every object inherits from. This is a real, named class of bug — prototype pollution — and the entry point is as innocent as "read my saved settings."

So I don't call JSON.parse directly on anything from outside. It goes through one function that strips the poison keys with a reviver and falls back instead of throwing:

const POISON = new Set(['__proto__', 'constructor', 'prototype']);

function safeJSONParse<T>(jsonString: string | null | undefined, fallback: T): T {
  if (!jsonString || typeof jsonString !== 'string') return fallback;
  try {
    const parsed = JSON.parse(jsonString, (key, value) =>
      key !== '' && POISON.has(key) ? undefined : value
    );
    return parsed != null ? parsed : fallback;
  } catch {
    return fallback;
  }
}
Enter fullscreen mode Exit fullscreen mode

Two things I like about this. It drops the dangerous keys instead of trusting the input to be nice. And a broken string gives me the fallback, not an exception halfway through app startup. The rule became simple and absolute: raw JSON.parse on external data is banned in the codebase. Every read goes through this door.

Door 4: sanitize at the boundary, based on where it's going

Here's the part that took me longest to internalize: there isn't one "sanitize" function. Cleaning depends on the destination.

The same user-supplied string might become a filename, a color, or text on the page — and each of those has different things that hurt it.

Going into the DOM as text? Escape the HTML so a <script> stays literal characters:

function sanitizeInput(input: unknown, maxLength = 500): string {
  if (!input || typeof input !== 'string') return '';
  const s = input
    .replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
    .replace(/"/g, '&quot;').replace(/'/g, '&#x27;').replace(/\//g, '&#x2F;')
    .trim();
  return s.length > maxLength ? s.slice(0, maxLength) : s;
}
Enter fullscreen mode Exit fullscreen mode

Becoming a filename? Kill the path separators and reserved characters so a "name" can't climb out of the directory or break the download:

function sanitizeFileName(fileName?: string | null): string {
  if (!fileName || typeof fileName !== 'string') return 'chess-position';
  let s = fileName.replace(/[\\/:*?"<>|&]/g, '-');
  s = s.replace(/\s+/g, '_').replace(/^\.+/, '').replace(/\.+$/, '').trim();
  if (s.length > 100) s = s.slice(0, 100);
  return s || 'chess-position';
}
Enter fullscreen mode Exit fullscreen mode

Being used as a color? Don't "clean" it — allowlist it. Either it matches an exact hex pattern or it's replaced with a default. No trying to fix a bad value:

function isValidHexColor(color: unknown): color is string {
  return typeof color === 'string' && /^#[0-9A-Fa-f]{6}$/.test(color);
}

function sanitizeHexColor(color: unknown, fallback = '#ffffff'): string {
  return isValidHexColor(color) ? color : fallback;
}
Enter fullscreen mode Exit fullscreen mode

That last one is the pattern I trust most: allowlist, don't blocklist. A blocklist is you trying to imagine every bad input. An allowlist is you describing the one shape you accept and rejecting the infinite rest by default. You will lose the imagination game. You won't lose the allowlist game.

The mental model that stuck

I used to think of security as a thing you bolt on — a review at the end, a linter rule, a checklist.

What actually changed my code was a smaller idea: every place data crosses from "outside" to "inside" is a boundary, and boundaries have jobs. The parser's job is to reject anything that isn't a real board. The storage reader's job is to never reconstruct a poison key. The DOM writer's job is to escape. The filename builder's job is to strip paths.

None of these are clever. That's the point. They're boring, they live at the edges, and they mean that by the time a value reaches the interesting part of my app, it has already passed through the right door for where it's headed.

The chess part was incidental. The lesson is: the more harmless your input looks, the more likely you are to trust it without earning it.


This all came out of an open-source chess diagram tool I've been building. If you want to see the parser and the sanitizers in their natural habitat, the repo is chessviewer-org/chess-viewer and the utilities live in their own package, @chessviewer-org/chess-viewer.

Top comments (0)