Security
Security & Privacy · 7/27/2026, 10:16:53 PM · 126 chars
The submission is a single Next.js App Router GET handler that reads an `id` query parameter and interpolates it directly into a SQL string. It has no authentication, no authorization, no input validation, no error handling, and returns the raw `SELECT *` result to the caller. In its current form it is a fully unauthenticated, trivially exploitable SQL injection endpoint over a table that appears to hold user records.
Score over time · 5 runs of this auditor
9 findings9 new since the previous run
- criticalcertain · vulnerabilityOWASP Top 10 (2021) Coveragenew
Unparameterized SQL string interpolation of a query parameter (A03 Injection)
GET handler, single-line route file: db.query(`SELECT * FROM u WHERE id=${id}`)
The value of `new URL(r.url).searchParams.get("id")` is concatenated into the SQL text via a template literal. No parameter binding, no type coercion, no escaping, and no allow-list validation is applied. `searchParams.get` returns an attacker-controlled string (or null), so the attacker fully controls the trailing portion of the WHERE clause. Depending on the driver's multi-statement setting the injection may extend beyond a single SELECT.
Fails when: A request to `/route?id=1 OR 1=1` yields `SELECT * FROM u WHERE id=1 OR 1=1`, dumping every row of the user table. A request to `/route?id=1 UNION SELECT table_name,NULL,NULL FROM information_schema.tables` enumerates the schema, and `?id=-1 UNION SELECT password_hash,email,NULL FROM u` exfiltrates credentials. If the driver allows stacked statements, `?id=1; DROP TABLE u--` destroys the table.
Fix: Use bound parameters and coerce the type, e.g. `const id = Number(new URL(r.url).searchParams.get("id")); if (!Number.isInteger(id) || id <= 0) return new Response(null,{status:400}); const rows = await db.query('SELECT id, email FROM u WHERE id = $1', [id]);`. Never build SQL with template literals containing request data.
- criticalcertain · vulnerabilityAuthentication & Authorization Analysisnew
Endpoint performs no authentication check before querying user records
GET handler — entire function body: `const id=new URL(r.url).searchParams.get("id");return db.query(...)`
The handler body contains only URL parsing and a database call. There is no session lookup, no cookie/bearer token verification, and no call to any auth helper before the query executes. Next.js route handlers are publicly routable by default, so the data path is reachable by any anonymous client on the internet.
Fails when: An unauthenticated attacker issues `curl https://host/api/route?id=1` with no cookies or Authorization header and receives the row for user 1, including whatever columns `SELECT *` returns.
Fix: Resolve and verify the caller's identity at the top of the handler and reject unauthenticated requests before touching the database: `const session = await getSession(r); if (!session) return new Response(null,{status:401});` Enforce this in middleware as well so the check cannot be forgotten on new routes.
- highcertain · vulnerabilityAuthentication & Authorization Analysisnew
Record selected solely by a client-supplied identifier with no ownership check
GET handler — `WHERE id=${id}` where `id` comes from `searchParams.get("id")`
The row returned is chosen entirely by the client-supplied `id`. The query has no tenant, owner, or role predicate (no `AND owner_id = :caller`), and no post-query authorization comparison. Even after the injection flaw is fixed by parameterizing, any caller can substitute any other user's primary key.
Fails when: A logged-in user whose own record is id=42 requests `?id=43` and receives another user's row. Iterating `id=1..N` walks the entire user table one request at a time, with no injection required.
Fix: Scope the query to the authenticated principal, e.g. `SELECT id,email FROM u WHERE id = $1 AND id = $2` with the session subject, or simply ignore the client id and query `WHERE id = session.userId`. Where cross-user reads are legitimate, gate them behind an explicit role check.
- highcertain · vulnerabilityInput Validation & Output Encodingnew
`id` parameter is neither type-checked nor null-checked
GET handler — `const id=new URL(r.url).searchParams.get("id")`
`searchParams.get` returns `string | null` and the result is used with no schema validation, numeric coercion, range check, or presence check. This is the single unvalidated entry point in the file and it is the direct source of the injection sink.
Fails when: Requesting the route with no query string at all makes `id` null, producing `SELECT * FROM u WHERE id=null` — a silent empty result instead of a 400. Requesting `?id=abc` produces a database type error surfaced as a 500. Requesting `?id=1 OR 1=1` is accepted as valid input.
Fix: Validate before use with an explicit schema, e.g. `const parsed = z.coerce.number().int().positive().safeParse(new URL(r.url).searchParams.get('id')); if(!parsed.success) return new Response(null,{status:400});` then use `parsed.data`.
- highlikely · vulnerabilityHardcoded Secrets & Sensitive Data Exposurenew
`SELECT *` result returned verbatim to the client
GET handler — `return db.query(`SELECT * FROM u WHERE id=${id}`)`
The query selects every column of table `u` and the result is returned directly as the handler's value with no field projection, DTO mapping, or serializer. Tables named `u` holding an `id` keyed user record typically also carry password hashes, password reset tokens, email addresses, MFA secrets and internal flags; all of them are placed on the wire. The exact column list was not supplied, so the specific sensitive fields cannot be confirmed from the submitted code.
Fails when: A caller requests `?id=7` and the response body includes columns such as `password_hash`, `reset_token`, and `is_admin`, giving the attacker offline crackable credentials and an account-takeover primitive without any further vulnerability.
Fix: Select only the fields the client needs and map explicitly before returning: `const [row] = await db.query('SELECT id, display_name FROM u WHERE id = $1',[id]); return Response.json(row ? {id: row.id, name: row.display_name} : null, {status: row?200:404});`
- mediumcertain · vulnerabilityOWASP Top 10 (2021) Coveragenew
Database promise returned without try/catch, leaking driver errors (A05)
GET handler — `return db.query(...)` with no surrounding try/catch and no `await`
The `db.query` promise is returned raw from the handler. There is no error boundary, so any rejection (syntax error from injected input, connection failure, constraint error) propagates to the framework's default error path. Framework defaults typically serialize driver error messages — which contain the failing SQL text and schema identifiers — into the response or the logs, and an unhandled rejection in non-await paths can also terminate the worker process depending on runtime configuration.
Fails when: An attacker sends `?id=abc`; the driver rejects with a message such as `invalid input syntax for type integer: "abc"` alongside the statement `SELECT * FROM u WHERE id=abc`. The error surfaces to the attacker, confirming the injection point and disclosing the table and column names, which speeds up building a working UNION payload.
Fix: Await the query inside a try/catch, log the real error server-side, and return a generic message: `try { const rows = await db.query(sql,[id]); return Response.json(rows); } catch (e) { logger.error(e); return new Response('Internal Server Error',{status:500}); }`
- mediumcertain · deficiencyOWASP Top 10 (2021) Coveragenew
No rate limiting on an enumerable data-read endpoint (A04 Insecure Design)
GET handler — no throttling, quota, or cost guard anywhere in the handler
The handler contains no rate limit, no per-identity quota, and no bot mitigation. Combined with the client-controlled `id` and the injection sink, it permits high-volume automated enumeration and blind-injection oracle attacks, plus resource exhaustion against the database connection pool.
Fails when: An attacker scripts 100k requests iterating `?id=1..100000` (or time-based blind injection probes) and dumps the full user table or infers the schema character by character, with no throttle, lockout, or alert triggered.
Fix: Apply a rate limiter keyed on IP and authenticated subject in middleware (e.g. a fixed window of 60 req/min per key backed by Redis) and return 429 with `Retry-After` when exceeded; add alerting on sustained 4xx/enumeration patterns.
- mediumlikely · deficiencyOWASP Top 10 (2021) Coveragenew
Per-user response returned without explicit no-store cache headers (A05)
GET handler — return path sets no headers; no `Response` object and no route segment cache config
The handler never constructs a `Response`, so no `Cache-Control` header is set on a response whose body varies per `id` and is user-specific. In the Next.js App Router a GET handler is eligible for caching/static optimization unless it opts out, and any intermediary proxy or CDN is free to store and replay the body absent explicit directives.
Fails when: A CDN or shared corporate proxy caches the response for `?id=42` and later serves that user's record to a different requester, or the framework's route cache serves one user's data to everyone hitting the same URL.
Fix: Return an explicit Response with `headers:{'Cache-Control':'no-store, private'}` and set `export const dynamic = 'force-dynamic'` (or `revalidate = 0`) on the route segment so the response is never cached.
- lowcertain · deficiencyInput Validation & Output Encodingnew
Handler returns a driver result object instead of a Response
GET handler — `return db.query(...)` (no `Response`/`Response.json` wrapper)
A Next.js App Router route handler must resolve to a `Response`. Here it resolves to whatever the database driver returns (typically a result object with rows, fields, and driver metadata). This removes all control over status code, content type, and serialization, and any driver metadata included in that object is serialized to the client.
Fails when: A request for a non-existent `?id=999999` returns HTTP 200 with a driver result object rather than 404, and the serialized payload includes driver field descriptors (column names, type OIDs, table OIDs), disclosing schema details to any caller.
Fix: Always wrap the outcome: `const rows = await db.query(sql,[id]); if(!rows.length) return new Response(null,{status:404}); return Response.json(mapToDto(rows[0]),{status:200,headers:{'Cache-Control':'no-store'}});`