DEV Community

Daniel Pertu
Daniel Pertu

Posted on

Three number fields on our free tools, three different places they are clamped, and a comment promising sixteen cards from a sheet of four

PubTrivia's four free tools are the only pages on the site a stranger can type into without an account. There is no API behind them: a team name generator, a round planner, a scoresheet PDF builder and a QR code generator, all of them client-side, which means every limit they have is a limit somebody wrote in a React component.

I probed all three of the ones that take input, on the live pages, and found three different strategies. None of them is wrong. The interesting part is that nobody decided to have three.

One: clamp in the state setter

The scoresheet generator builds a printable answer sheet and score grid. Its inputs carry the bounds twice, once as HTML attributes and once in the code behind them:

const LIMITS = {
    rounds: { min: 1, max: 12 },
    questionsPerRound: { min: 1, max: 20 },
    teams: { min: 1, max: 40 },
}

function clamp(value: number, { min, max }: { min: number; max: number }) {
    if (!Number.isFinite(value)) return min
    return Math.max(min, Math.min(max, Math.round(value)))
}

const set = (key: keyof Config, value: string) => {
    setConfig((current) => {
        if (key === "quizName") return { ...current, quizName: value }
        return { ...current, [key]: clamp(Number(value), LIMITS[key]) }
    })
}
Enter fullscreen mode Exit fullscreen mode

Because the clamp runs inside the setter, it runs on every keystroke. Measured in the browser:

Typed Field ends up at
99 into rounds (max 12) 12
7 into rounds 7
500 into teams (max 40) 40
0 into questions (min 1) 1
field emptied entirely 1

Two consequences fall out of that table. The first is that you cannot type a two-digit number above the maximum, because the field rewrites you halfway through: press 9 and you have 9, press the second 9 and the input jumps to 12 under your cursor. The second is that you cannot leave the field empty, because Number("") is 0, clamp pushes it to the minimum, and the box refills itself with a 1 the moment you delete the last digit.

The reason the bounds exist at all is in the comment above them: "The bounds the inputs clamp to, so a typo cannot ask for a 900-page PDF." 12 rounds is 12 portrait pages plus one landscape score grid, so the worst document this tool will build is 13 pages. That is a real constraint, since the PDF is generated in the visitor's own tab with jspdf and an accidental 9999 would hang the machine that typed it.

The bounds also show up in the geometry. Row height in the score grid is not fixed, it is whatever fits:

const rowHeight = Math.min(10, (A4_WIDTH_MM - MARGIN_MM * 2 - 20) / (config.teams + 1))
Enter fullscreen mode Exit fullscreen mode

At 12 teams that is the full 10mm. At the maximum of 40 it works out at 3.9mm per row, which is a grid you can print but not comfortably write in with a biro. The cap stops the document being absurd; it does not stop it being tight.

Two: clamp in the change handler

The round planner turns a set of rounds into a running order with real clock times. It keeps its bounds inline, in each handler:

onChange={(event) =>
    setBreakMinutes(Math.max(0, Math.min(45, Math.round(Number(event.target.value) || 0))))
}
Enter fullscreen mode Exit fullscreen mode

Fifteen form controls on that page: a time input, two numeric settings, and then a select and a question count for each of six rounds. Measured:

Typed Result
999 into break length (max 45) 45
9 into break after round (max 5) 5
77 into a round's questions (max 20) 20
break length emptied 0

Same behaviour as the scoresheet, reached a different way, with one difference that matters: emptying the break length leaves 0 rather than 1, because this field's minimum is genuinely zero. A quiz with no break is a legitimate plan. A scoresheet with no rounds is not.

The upper bound on "break after round" is the one worth noticing, because it is not a constant:

max={Math.max(1, rounds.length - 1)}
Enter fullscreen mode Exit fullscreen mode

With six rounds it reads 5, and if you delete rounds it follows them down. A break after the last round is not a break, it is the end of the quiz, so the field refuses to describe one.

Three: do not clamp, ask the URL parser

The QR code generator has no numbers in it, and its whole validation story is five lines:

function normalise(input: string): string | null {
    const trimmed = input.trim()
    if (!trimmed) return null
    const candidate = /^https?:\/\//i.test(trimmed) ? trimmed : `https://${trimmed}`
    try {
        return new URL(candidate).toString()
    } catch {
        return null
    }
}
Enter fullscreen mode Exit fullscreen mode

No maximum length, no pattern attribute, type="text" with inputMode="url". There are two failure messages and they come from two different places, which the probe shows clearly:

Typed Result
example.com a code for https://example.com/
pub-trivia.app/play a code
javascript:alert(1) "That does not look like a web address."
3,000 characters "Could not generate a code for that address."
2,000 characters a code, with a 54 KB data URL instead of the usual 11 KB

The first refusal is normalise returning null. The second is the qrcode library throwing, because a QR code has a finite capacity and somewhere between 2,000 and 3,000 bytes of input you run out of version 40.

The javascript: refusal is my favourite line in the file, because nothing in it is about security. The regex only recognises http and https, so anything else gets https:// glued to the front of it, and https://javascript:alert(1) is not a URL any parser will accept. A scheme allowlist nobody wrote, produced as a side effect of being friendly to people who type bare domains.

What the parser does accept is broader than you would guess, and it is engine-dependent:

// Chrome 156
new URL('https://' + 'not a url at all').toString()  // "https://not%20a%20url%20at%20all/"
new URL('https://' + '//evil.com').toString()        // "https://evil.com/"

// Node 20, same two strings
// TypeError: Invalid URL
Enter fullscreen mode Exit fullscreen mode

So typing a sentence into our QR generator gives you a working code for a percent-encoded nonsense host, and typing //evil.com gives you a code for evil.com. In the browser, where this code actually runs, that is simply what new URL means. Neither case is dangerous, because the code encodes exactly the string we show you and nothing is sent anywhere, but "defer to the platform" is a validation decision with a different answer in every platform.

The comment that says sixteen

While reading that file I found a sentence that is wrong:

 * The PDF sheet is the version people actually want for a quiz night: one code
 * is a download, sixteen identical table cards is a print job.
Enter fullscreen mode Exit fullscreen mode

The sheet is a 2 by 2 grid:

const cols = 2
const rows = 2
Enter fullscreen mode Exit fullscreen mode

Four cards per A4 page, one page. The visible copy on the page gets it right, "an A4 sheet of four table cards ready to cut up", and so does the button, which says "A4 sheet of four". It is only the comment that promises sixteen, and it has promised sixteen since the file was written.

I like this one as an example of where comment rot actually happens. Nobody mis-set a number: the user-facing string and the loop agree, and the only copy of the claim that drifted is the one with no test, no reader and no renderer.

What I am changing

  1. One shared clamp. Both numeric tools have written the same three lines twice, with a different story about empty fields. One helper taking { min, max }, where emptiness resolves to the minimum because the minimum is where the component author already said the floor is.
  2. Fix the sixteen. One word.
  3. A maximum length on the QR field. Not for safety, for honesty: if the encoder gives up somewhere above 2,000 characters, the input should stop accepting them instead of letting you paste 3,000 and then saying no.
  4. Leave the clamp-on-keystroke behaviour alone. It is slightly rude to type against, and it is the only approach where the state can never hold a value the PDF generator cannot draw. A tool whose output is a document is a tool where invalid intermediate state is a real cost.

Check it yourself

No account, no card, nothing to install, and nothing you type leaves your machine:

  1. Open the scoresheet generator, put 99 in Rounds and watch it land on 12, then delete the contents of the field and watch a 1 appear.
  2. Open the round planner, put 999 in the break length for 45, and empty the same field to get 0 rather than 1.
  3. Open the QR generator and try example.com, then javascript:alert(1), then 3,000 characters of anything.

And the claim that nothing is uploaded is the one worth verifying rather than believing. Open the Network tab, clear it, type an address and press Generate:

// before pressing Generate
performance.getEntriesByType('resource').length   // 49
// after
performance.getEntriesByType('resource').length   // 50
// the one addition
"/_next/static/chunks/09hr4vzc25-d3.js"  // the qrcode library, same origin, from cache
Enter fullscreen mode Exit fullscreen mode

One request, to our own origin, for the library itself. Zero cross-origin requests, and the address you typed appears in none of them.

If you want the paid half of the product rather than the free half, the pricing page quotes in your own currency and the first session is free with no card.

Top comments (0)