chore(sonarqube): keep interpolation out of query call sites (#294) #295

Merged
bermudalamb merged 1 commits from chore/294-no-interpolation-at-query-sites into main 2026-09-04 09:36:40 -05:00
6 changed files with 69 additions and 26 deletions
Showing only changes of commit 71efe6ee6a - Show all commits
+8 -2
View File
@@ -72,8 +72,14 @@ async function readPhotos(itemId: number): Promise<Photo[]> {
} }
async function namesOf(table: 'categories' | 'tags'): Promise<string[]> { async function namesOf(table: 'categories' | 'tags'): Promise<string[]> {
// The table name is a closed union, never caller input — there is nothing // The one query in this codebase that cannot be parameterized, rather than one
// here to interpolate from a request. // that merely has not been. A bound parameter is a *value*: Postgres will not
// accept `SELECT name FROM $1`, because an identifier has to be part of the
// parsed statement. So the choice is interpolation or nothing.
//
// What makes it safe is the type. `table` is the closed union
// 'categories' | 'tags', so the only two strings that can reach this line are
// both written above it, and neither is derived from a request. See #294.
const { rows } = await pool.query<{ name: string }>(`SELECT name FROM ${table} ORDER BY name`); const { rows } = await pool.query<{ name: string }>(`SELECT name FROM ${table} ORDER BY name`);
return rows.map((row) => row.name); return rows.map((row) => row.name);
} }
+13
View File
@@ -58,6 +58,19 @@ export const ADMIN_ITEM_SELECT = `
${TAGS_SUBQUERY} ${TAGS_SUBQUERY}
${FROM_CLAUSE}`; ${FROM_CLAUSE}`;
/**
* One admin item, by id — the whole query, not a fragment.
*
* A named constant rather than `${ADMIN_ITEM_SELECT} WHERE i.id = $1` written
* at each call, so no query call site interpolates anything at all. The id was
* always bound as $1 and never reached the query text, but S2077 fires on the
* template literal rather than on the value, because the rule cannot tell a
* module constant from a request field — and neither, at a glance, can a
* reader. Hoisting it makes the property structural instead of an assertion
* somebody has to re-make every time the line moves. See #294.
*/
export const ADMIN_ITEM_BY_ID = `${ADMIN_ITEM_SELECT} WHERE i.id = $1`;
/** The columns every item select returns, whichever of the two it is. */ /** The columns every item select returns, whichever of the two it is. */
interface ItemRowBase { interface ItemRowBase {
id: number; id: number;
+10 -11
View File
@@ -1,7 +1,7 @@
import { Router, Request, Response } from 'express'; import { Router, Request, Response } from 'express';
import { PoolClient } from 'pg'; import { PoolClient } from 'pg';
import { pool, requireRow } from '../db'; import { pool, requireRow } from '../db';
import { ADMIN_ITEM_SELECT, AdminItemRow, ItemRecord } from '../itemSelect'; import { ADMIN_ITEM_SELECT, ADMIN_ITEM_BY_ID, AdminItemRow, ItemRecord } from '../itemSelect';
import { ItemStatus } from '../types'; import { ItemStatus } from '../types';
import { asyncRoute } from '../asyncRoute'; import { asyncRoute } from '../asyncRoute';
import { parseItemFilters, buildItemFilterSql, FilterError } from '../itemFilters'; import { parseItemFilters, buildItemFilterSql, FilterError } from '../itemFilters';
@@ -168,12 +168,11 @@ router.post('/items', uploadImages, asyncRoute(async (req: Request, res: Respons
await setItemTags(client, item.id, await resolveTagIds(client, tagNames)); await setItemTags(client, item.id, await resolveTagIds(client, tagNames));
} }
await client.query('COMMIT'); await client.query('COMMIT');
// S2077 again, and here the template is a module constant plus a literal: // No interpolation here at all now: the whole query is a constant and the id
// ADMIN_ITEM_SELECT interpolates nothing of its own, and the id is bound as // is bound as $1. It always was bound — what changed is that a reader no
// $1 rather than formatted in. Same shape as the update route below, where // longer has to check that the interpolated half carries no caller data,
// the bound value is caller-supplied — which is precisely why it is a // because there is no interpolated half. See #294.
// parameter. const { rows: full } = await pool.query<AdminItemRow>(ADMIN_ITEM_BY_ID, [item.id]);
const { rows: full } = await pool.query<AdminItemRow>(`${ADMIN_ITEM_SELECT} WHERE i.id = $1`, [item.id]);
res.json(requireRow(full, 'the item just inserted')); res.json(requireRow(full, 'the item just inserted'));
} catch (err) { } catch (err) {
await client.query('ROLLBACK'); await client.query('ROLLBACK');
@@ -224,10 +223,10 @@ router.put('/items/:id', uploadImages, asyncRoute(async (req: Request, res: Resp
await insertItemImages(client, itemId, files, nextSort); await insertItemImages(client, itemId, files, nextSort);
} }
await client.query('COMMIT'); await client.query('COMMIT');
// S2077, the same constant-plus-$1 shape as the create route above. // The same constant as the create route above. itemId is caller-controlled
// req.params.id is caller-controlled and goes through the driver as a bound // and goes through the driver as a bound parameter; it never reaches the
// parameter; it never reaches the query text. // query text.
const { rows: full } = await pool.query<AdminItemRow>(`${ADMIN_ITEM_SELECT} WHERE i.id = $1`, [itemId]); const { rows: full } = await pool.query<AdminItemRow>(ADMIN_ITEM_BY_ID, [itemId]);
// The create route beside this one has always used requireRow here. This // The create route beside this one has always used requireRow here. This
// one did not, so an UPDATE matching nothing committed happily, the SELECT // one did not, so an UPDATE matching nothing committed happily, the SELECT
// returned nothing, and the caller got 200 with an empty body — a success // returned nothing, and the caller got 200 with an empty body — a success
+12 -2
View File
@@ -46,6 +46,16 @@ const DRAFT_SELECT = `
LEFT JOIN upload_links l ON l.id = d.upload_link_id LEFT JOIN upload_links l ON l.id = d.upload_link_id
`; `;
/**
* The two shapes the queue is ever asked for, as whole queries.
*
* Named rather than assembled at the call, so neither branch of the ternary
* interpolates anything — the state is bound as $1 in the first and the second
* carries no caller data at all. See #294.
*/
const DRAFTS_BY_STATE = `${DRAFT_SELECT} WHERE d.state = $1 ORDER BY d.created_at DESC`;
const DRAFTS_NOT_DISCARDED = `${DRAFT_SELECT} WHERE d.state <> 'discarded' ORDER BY d.created_at DESC`;
/** /**
* Discarded rows are excluded by default rather than deleted. * Discarded rows are excluded by default rather than deleted.
* *
@@ -59,8 +69,8 @@ router.get(
const state = typeof req.query.state === 'string' ? req.query.state : null; const state = typeof req.query.state === 'string' ? req.query.state : null;
const { rows } = state const { rows } = state
? await pool.query(`${DRAFT_SELECT} WHERE d.state = $1 ORDER BY d.created_at DESC`, [state]) ? await pool.query(DRAFTS_BY_STATE, [state])
: await pool.query(`${DRAFT_SELECT} WHERE d.state <> 'discarded' ORDER BY d.created_at DESC`); : await pool.query(DRAFTS_NOT_DISCARDED);
// Whether the control has anything behind it, alongside the rows. A second // Whether the control has anything behind it, alongside the rows. A second
// endpoint for one boolean would be a round trip the queue already makes. // endpoint for one boolean would be a round trip the queue already makes.
+16 -7
View File
@@ -34,6 +34,9 @@ const LINK_SELECT = `
FROM upload_links FROM upload_links
`; `;
/** Every link, newest first. A whole query, so the call interpolates nothing (#294). */
const LINK_LIST = `${LINK_SELECT} ORDER BY created_at DESC`;
/** /**
* The cap a link gets when nobody chose one. * The cap a link gets when nobody chose one.
* *
@@ -69,7 +72,7 @@ function submissionsAllowed(maxSubmissions: number | null): string {
} }
router.get('/', asyncRoute(async (_req: Request, res: Response) => { router.get('/', asyncRoute(async (_req: Request, res: Response) => {
const { rows } = await pool.query<UploadLinkRow>(`${LINK_SELECT} ORDER BY created_at DESC`); const { rows } = await pool.query<UploadLinkRow>(LINK_LIST);
res.json(rows); res.json(rows);
})); }));
@@ -123,7 +126,7 @@ router.post('/', asyncRoute(async (req: Request, res: Response) => {
// A failure does not roll the link back. The token is shown exactly once, so // A failure does not roll the link back. The token is shown exactly once, so
// a rollback would leave the admin retrying and holding a different link, // a rollback would leave the admin retrying and holding a different link,
// discarding work that succeeded for the sake of tidiness. // discarding work that succeeded for the sake of tidiness.
let outcome: MailOutcome = 'skipped-unconfigured'; let outcome: MailOutcome;
try { try {
const template = renderTemplate('uploadLink', await loadStoredTemplate('uploadLink'), { const template = renderTemplate('uploadLink', await loadStoredTemplate('uploadLink'), {
submitUrl: url, submitUrl: url,
@@ -135,11 +138,17 @@ router.post('/', asyncRoute(async (req: Request, res: Response) => {
// Reported, not thrown. The link exists and is usable; the admin needs to // Reported, not thrown. The link exists and is usable; the admin needs to
// be told the mail did not go, not handed a 500 for a link that was made. // be told the mail did not go, not handed a 500 for a link that was made.
// //
// An SMTP rejection also lands here and is reported the same way as an // Assigned only here, not also at the declaration. The duplicate initialiser
// unconfigured environment, which is not strictly accurate — a fourth // was flagged as S1854 (#294), and it was worse than redundant: it made the
// outcome would be a real distinction, but nothing consumes it and the // two ways of reaching this line look like one. A template that will not
// admin's next action is identical either way: copy the link and send it // render, or a stored template that cannot be loaded, is not an SMTP
// by hand. // problem, and reporting it as "not configured" pointed the admin at the
// wrong thing entirely.
//
// An SMTP rejection landing here and being reported as unconfigured is the
// conflation that was actually agreed: a fourth outcome would be a real
// distinction, nothing consumes it, and the admin's next action is identical
// either way — copy the link and send it by hand.
console.error(`[upload-links] could not email ${email}:`, err); console.error(`[upload-links] could not email ${email}:`, err);
outcome = 'skipped-unconfigured'; outcome = 'skipped-unconfigured';
} }
+10 -4
View File
@@ -17,6 +17,15 @@ import {
// optional filter. It has to be a clause the caller cannot opt out of. // optional filter. It has to be a clause the caller cannot opt out of.
const EXCLUDE_PENDING = `i.status <> 'pending'`; const EXCLUDE_PENDING = `i.status <> 'pending'`;
/**
* One public item, by id — the whole query rather than a fragment.
*
* Built once here so the call site interpolates nothing. Both halves were
* always constants and the id was always bound as $1, but a template literal at
* a query call is a thing a reader has to verify rather than see. See #294.
*/
const PUBLIC_ITEM_BY_ID = `${PUBLIC_ITEM_SELECT} WHERE i.id = $1 AND ${EXCLUDE_PENDING}`;
const router = Router(); const router = Router();
router.get('/', asyncRoute(async (req: Request, res: Response) => { router.get('/', asyncRoute(async (req: Request, res: Response) => {
@@ -93,10 +102,7 @@ router.get('/:id', asyncRoute(async (req: Request, res: Response) => {
// Excluded here too, not only from the list. A pending item that stayed // Excluded here too, not only from the list. A pending item that stayed
// fetchable by id would be hidden from the catalogue and still reachable by // fetchable by id would be hidden from the catalogue and still reachable by
// anyone who guessed or kept a link. // anyone who guessed or kept a link.
const { rows } = await pool.query<PublicItemRow>( const { rows } = await pool.query<PublicItemRow>(PUBLIC_ITEM_BY_ID, [req.params.id]);
`${PUBLIC_ITEM_SELECT} WHERE i.id = $1 AND ${EXCLUDE_PENDING}`,
[req.params.id]
);
if (!rows.length) return res.status(404).json({ error: 'not found' }); if (!rows.length) return res.status(404).json({ error: 'not found' });
res.json(rows[0]); res.json(rows[0]);
})); }));