A server endpoint that accepts form data without checking it, does not check who is asking, or has no limit on how often it can be called. Your forms talk to these directly, so a gap here is a gap in the UI.
Add "API route checks" from ntent to this repo.
Fetch https://ntent.app/r/f/check-api-routes as plain text. Write it verbatim to scripts/check-api-routes.mjs. Then make exactly these edits and no others: Set CONFIG.routeDirs to where this repo keeps its handlers, and add your own validation, auth and rate-limit helper names to the three matcher lists.
Then check it: Add a POST handler with no schema parse. The check names the file and the missing piece.Reads https://ntent.app/r/f/check-api-routes
Read the code
check-api-routes.mjsscripts/check-api-routes.mjs
322 lines
#!/usr/bin/env node
/**
* API contract gate: every route handler validates its input, checks who is
* calling, and declares a rate limit.
*
* Why presence checks are worth it. These three are the most common API defects
* and all three are invisible to the type system. TypeScript will happily let
* you write `const body = await req.json()` and treat the result as whatever
* you claim it is, because `json()` returns `any`. Nothing warns you that a
* handler has no auth check. Nothing counts requests.
*
* This does not verify the checks are correct. It verifies they are present.
* That is a much weaker claim and still catches the overwhelming majority of
* real cases, because the usual failure is not a subtly wrong auth check, it is
* no auth check at all in a handler somebody added at 6pm.
*
* PRESENT HAS TO MEAN PRESENT. Two things kept that claim from being true.
* A bare `.parse(` matcher counted `JSON.parse(await req.text())` as schema
* validation, and JSON.parse is the exact thing this check exists to find
* unaccompanied: it decodes, it does not validate, and TypeScript is just as
* happy with the `any` that comes out. And every matcher ran over the whole
* file, so validation in the GET handler, or in a comment, or in a string,
* satisfied the POST. Now comments and string literals are stripped first, and
* every check is attributed to the handler it is about.
*
* ATTRIBUTION APPLIES TO AUTH TOO, and that was the last leak. A file-wide auth
* search means one authenticated handler vouches for every other handler in the
* file: a route with an authed GET and an unauthenticated POST beside it passed
* --check, which is the exact shape of the 6pm handler this gate exists to
* catch. The wrapper case still works, because everything above the first
* export counts towards all of them: a shared `const session = await auth()`,
* or a `requireUser` the handlers are wrapped in, is genuinely every handler's
* code. What one handler's own body can no longer do is speak for another's.
*
* WHAT IS STILL HEURISTIC. It reads text, not types. A helper named
* validateInput that validates nothing passes, and validation reached through
* an unusual indirection is missed. It tells you a check is THERE.
*
* Four checks:
* 1. NO-INPUT-SCHEMA a body-taking method that never calls .parse/.safeParse
* 2. NO-AUTH no recognised auth call, and not declared public
* 3. NO-RATE-LIMIT no recognised rate-limit call, and not exempt
* 4. UNDECLARED-PUBLIC a route marked public with no reason given
*
* Check 4 matters more than it looks. Making a route public is a real decision
* and it should read like one. A bare marker with no sentence next to it is how
* an endpoint ends up public because someone was debugging.
*
* Declare an exemption in the route file itself, so it travels with the code
* and shows up in the diff that makes it true:
*
* // @api-public: Stripe calls this before any session exists. Signature
* // verified below via stripe.webhooks.constructEvent.
* // @api-no-rate-limit: Stripe retries with backoff and we must not drop them.
*
* Usage:
* node scripts/check-api-routes.mjs
* node scripts/check-api-routes.mjs --check
*/
import { readFileSync, readdirSync, statSync, existsSync } from 'node:fs'
import { join, relative } from 'node:path'
// ---------------------------------------------------------------- CONFIG ---
const CONFIG = {
// Next.js App Router. For Pages Router use ['src/pages/api'] and drop the
// filename filter below.
routeDirs: ['src/app'],
routeFileNames: ['route.ts', 'route.tsx', 'route.js'],
ignoreDirs: ['node_modules', '.next', 'dist', '.git'],
// Methods that carry a body and therefore need input validation.
bodyMethods: ['POST', 'PUT', 'PATCH'],
allMethods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'HEAD', 'OPTIONS'],
// Anything that counts as validating input. Add your own helper names.
//
// The lookbehind is the point of this list. `.parse(` on its own is a fine
// signal for zod, valibot and arktype, and a terrible one on its own line
// because JSON.parse shares the shape and validates nothing. Exclude it by
// name rather than dropping the generic form, which would miss every schema
// library that spells its method parse.
validationCalls: [
/(?<!\bJSON\s*)\.\s*safeParse\s*\(/,
/(?<!\bJSON\s*)\.\s*parse\s*\(/,
/\bvalidate(?:Body|Input|Request)\s*\(/,
],
// Anything that counts as establishing who is calling. Add your own.
authCalls: [
/\bauth\s*\(/,
/getServerSession\s*\(/,
/requireUser\s*\(/,
/requireSession\s*\(/,
/currentUser\s*\(/,
/getUser\s*\(/,
/verifyWebhook\s*\(/,
/constructEvent\s*\(/,
],
// Anything that counts as a rate limit.
rateLimitCalls: [/rateLimit\s*\(/, /ratelimit\s*\./, /limiter\s*\./, /checkRateLimit\s*\(/],
// In-file exemption markers.
//
// [ \t]* and [^\n]*, never \s* and .*, because \s matches a newline: an
// earlier version of this let `// @api-public` with no reason swallow the
// NEXT line's text as its reason and pass the length check. The rule this
// proves is the red gate in `03-gates.md#probe`: write a probe that must
// fail before you trust a new check, or you ship something that reads as
// coverage and is not.
markers: {
public: /@api-public:?[ \t]*([^\n]*)/,
noRateLimit: /@api-no-rate-limit:?[ \t]*([^\n]*)/,
noSchema: /@api-no-schema:?[ \t]*([^\n]*)/,
},
// A reason shorter than this reads as no reason.
minReasonLength: 20,
}
// --------------------------------------------------------------- HELPERS ---
function walk(dir, acc = []) {
let entries
try {
entries = readdirSync(dir)
} catch {
return acc
}
for (const entry of entries) {
if (CONFIG.ignoreDirs.includes(entry)) continue
const full = join(dir, entry)
if (statSync(full).isDirectory()) walk(full, acc)
else if (CONFIG.routeFileNames.includes(entry)) acc.push(full)
}
return acc
}
/** Which HTTP methods this file exports. */
function methodsIn(text) {
return CONFIG.allMethods.filter((m) =>
new RegExp(`export\\s+(?:async\\s+)?(?:function\\s+${m}\\b|const\\s+${m}\\s*[:=])`).test(text)
)
}
const anyMatch = (patterns, text) => patterns.some((p) => p.test(text))
/**
* Comments and string literals blanked out, positions preserved.
*
* A matcher that reads a file as raw text believes anything anybody wrote about
* the code. `// TODO: add schema.parse here` used to satisfy the input check,
* which is the worst possible failure for a presence check: the comment saying
* the work is outstanding was accepted as the work. Same length out as in, so
* the handler slicing below still lines up with the original offsets.
*/
function blankNoise(text) {
const blank = (m) => m.replace(/[^\n]/g, ' ')
return text
.replace(/\/\*[\s\S]*?\*\//g, blank)
.replace(/\/\/[^\n]*/g, blank)
.replace(/`(?:\\.|[^`\\])*`/g, blank)
.replace(/'(?:\\.|[^'\\\n])*'/g, blank)
.replace(/"(?:\\.|[^"\\\n])*"/g, blank)
}
/**
* The source of each exported handler, from its export to the next one.
*
* Crude on purpose: no parse, no dependency, and it only has to be right about
* where one handler stops and the next starts. What it buys is attribution. A
* file-wide search cannot tell you whether the validation it found belongs to
* the POST that needs it or the GET beside it, and "some handler in this file
* validates something" is not a claim worth gating on.
*/
function handlerBodies(text) {
const marks = []
for (const m of CONFIG.allMethods) {
const re = new RegExp(`export\\s+(?:async\\s+)?(?:function\\s+${m}\\b|const\\s+${m}\\s*[:=])`, 'g')
let hit
while ((hit = re.exec(text))) marks.push({ method: m, at: hit.index })
}
marks.sort((a, b) => a.at - b.at)
// Everything above the first export is shared: the schemas, and the helper
// that half of these files export as `export const POST = handlePost`. It
// counts towards every handler, because it genuinely is every handler's code.
// What it deliberately does NOT do is let one handler's body vouch for
// another's, which is the leak that made this check meaningless.
const preamble = marks.length ? text.slice(0, marks[0].at) : text
const bodies = new Map()
marks.forEach(({ method, at }, i) => {
const end = i + 1 < marks.length ? marks[i + 1].at : text.length
bodies.set(method, (bodies.get(method) ?? preamble) + text.slice(at, end))
})
return bodies
}
/** Read a marker and its reason. Returns null, or { reason }. */
function marker(text, re) {
const m = text.match(re)
if (!m) return null
return { reason: (m[1] || '').trim() }
}
// ------------------------------------------------------------------ MAIN ---
function main() {
const strict = process.argv.includes('--check')
const files = CONFIG.routeDirs.filter(existsSync).flatMap((d) => walk(d))
const findings = []
for (const file of files) {
const raw = readFileSync(file, 'utf8')
// Markers are read from the raw text: they live in comments by design.
const text = blankNoise(raw)
const where = relative(process.cwd(), file)
const methods = methodsIn(text)
if (!methods.length) continue
const bodies = handlerBodies(text)
const isPublic = marker(raw, CONFIG.markers.public)
const noRateLimit = marker(raw, CONFIG.markers.noRateLimit)
const noSchema = marker(raw, CONFIG.markers.noSchema)
// 1. Input validation, per body-carrying method, in that method's own body.
const unvalidated = methods
.filter((m) => CONFIG.bodyMethods.includes(m))
.filter((m) => !anyMatch(CONFIG.validationCalls, bodies.get(m) ?? ''))
if (unvalidated.length && !noSchema) {
const decodesOnly = unvalidated.some((m) => /\bJSON\s*\.\s*parse\s*\(/.test(bodies.get(m) ?? ''))
findings.push({
level: 'error',
code: 'NO-INPUT-SCHEMA',
where,
msg:
`${unvalidated.join('/')} accepts a body but nothing validates it.\n` +
(decodesOnly
? ` It calls JSON.parse, which decodes and does not validate: the result\n` +
` is any, exactly as req.json() would have been.\n`
: ` req.json() returns any, so the handler trusts whatever arrives.\n`) +
` fix: define a Zod schema and call schema.safeParse(await req.json()),\n` +
` returning 400 on failure. If this genuinely takes no structured\n` +
` body, add: // @api-no-schema: <reason>`,
})
}
// 2. Auth, per handler, in that handler's body plus the shared preamble.
const unauthed = methods.filter((m) => !anyMatch(CONFIG.authCalls, bodies.get(m) ?? ''))
if (unauthed.length && !isPublic) {
findings.push({
level: 'error',
code: 'NO-AUTH',
where,
msg:
`${unauthed.join('/')} has no recognised auth check.\n` +
` fix: call your session helper and return 401 when there is no user.\n` +
` If this route is deliberately public, add:\n` +
` // @api-public: <why, and what protects it instead>`,
})
}
// 3. Rate limit, per handler, for the same reason as auth.
const unlimited = methods.filter((m) => !anyMatch(CONFIG.rateLimitCalls, bodies.get(m) ?? ''))
if (unlimited.length && !noRateLimit) {
findings.push({
level: 'warning',
code: 'NO-RATE-LIMIT',
where,
msg:
`${unlimited.join('/')} declares no rate limit.\n` +
` fix: wrap it in your limiter, or add:\n` +
` // @api-no-rate-limit: <reason>`,
})
}
// 4. A public route with no stated reason.
for (const [name, found] of [
['@api-public', isPublic],
['@api-no-rate-limit', noRateLimit],
['@api-no-schema', noSchema],
]) {
if (found && found.reason.length < CONFIG.minReasonLength) {
findings.push({
level: 'error',
code: 'UNDECLARED-EXEMPTION',
where,
msg:
`${name} is set but says nothing useful.\n` +
` An exemption with no reason becomes permanent. Write the sentence:\n` +
` what makes this safe, or what protects it instead.`,
})
}
}
}
// -------------------------------------------------------------- REPORT ---
const errors = findings.filter((f) => f.level === 'error')
const warnings = findings.filter((f) => f.level === 'warning')
if (!findings.length) {
console.log(`api: ok. ${files.length} route file(s), all validated, authed and limited.`)
process.exit(0)
}
if (errors.length) {
console.log(`\napi: ${errors.length} error(s)\n`)
for (const f of errors) console.log(` ${f.code} ${f.where}\n ${f.msg}\n`)
}
if (warnings.length) {
console.log(`api: ${warnings.length} warning(s)\n`)
for (const f of warnings) console.log(` ${f.code} ${f.where}\n ${f.msg}\n`)
}
process.exit(strict && errors.length ? 1 : 0)
}
main()
Success check: Add a POST handler with no schema parse. The check names the file and the missing piece.