Review and clear the three security hotspots in routes/admin.ts #180

Closed
opened 2026-08-25 11:34:02 -05:00 by bermudalamb · 2 comments
Owner

The first SonarQube analysis since #154 began — recovered by #174 — reports three security hotspots, all in backend/src/routes/admin.ts.

What a hotspot is, because it changes what "fix" means here

A hotspot is not a vulnerability. SonarQube raises one where code touches something security-sensitive and a human has to decide whether the use is safe in context. It stays open until someone reviews it and marks it Safe, Fixed, or Acknowledged. Three open hotspots therefore means three unreviewed decisions, not necessarily three defects.

That matters for this file in particular, because the security-sensitive machinery in it has already been hardened deliberately — #95 added the type allowlist, magic-byte verification and the derived extension, and the size and count limits carry a comment that names S5693 by rule number. The likely outcome is a recorded review confirming the existing mitigations, with a change only where the review finds a genuine gap.

The failure mode to avoid is the opposite of the usual one: changing working, well-reasoned code to silence a marker.

Scope

admin.ts has one concentration of security-sensitive code — the multer upload path — and nothing else that fits a hotspot rule. There are no regular expressions, no dynamic execution, no child processes, no cryptography beyond randomUUID, and no string-built SQL. The eight routes are all parameterised queries.

So the review covers:

  • The multer limits: fileSize, files, fields, fieldSize (S5693 territory).
  • The destination directory, which comes from process.env.UPLOADS_DIR with an /app/uploads fallback.
  • The stored filename, derived from randomUUID() plus an extension taken from the validated content type rather than from the caller's filename.
  • The filesystem operations on those paths — fs.open in readHead, fs.unlink in discardUploads.

What this should produce

For each hotspot: the rule, what it is pointing at, whether the current code is safe and why, and either a change or a justification. The justification has to live in the repository, not only in SonarQube's UI, so that the next person to see the marker does not have to re-derive the reasoning — the existing S5693 comment is the model.

Then mark them reviewed in SonarQube, so the count reflects decisions made rather than decisions pending.

Note

The specific three rule keys are not visible from here — SONAR_HOST_URL is a CI secret, so the dashboard cannot be queried from the repository. The review below works from the code, covering every hotspot-class rule that can apply to this file. If the three named on the dashboard differ from the ones identified, that is worth knowing, since a hotspot nobody expected is more interesting than one already understood.

The first SonarQube analysis since #154 began — recovered by #174 — reports three security hotspots, all in `backend/src/routes/admin.ts`. ## What a hotspot is, because it changes what "fix" means here A hotspot is not a vulnerability. SonarQube raises one where code *touches* something security-sensitive and a human has to decide whether the use is safe in context. It stays open until someone reviews it and marks it Safe, Fixed, or Acknowledged. Three open hotspots therefore means three unreviewed decisions, not necessarily three defects. That matters for this file in particular, because the security-sensitive machinery in it has already been hardened deliberately — #95 added the type allowlist, magic-byte verification and the derived extension, and the size and count limits carry a comment that names S5693 by rule number. The likely outcome is a recorded review confirming the existing mitigations, with a change only where the review finds a genuine gap. The failure mode to avoid is the opposite of the usual one: changing working, well-reasoned code to silence a marker. ## Scope `admin.ts` has one concentration of security-sensitive code — the multer upload path — and nothing else that fits a hotspot rule. There are no regular expressions, no dynamic execution, no child processes, no cryptography beyond `randomUUID`, and no string-built SQL. The eight routes are all parameterised queries. So the review covers: - The multer limits: `fileSize`, `files`, `fields`, `fieldSize` (S5693 territory). - The destination directory, which comes from `process.env.UPLOADS_DIR` with an `/app/uploads` fallback. - The stored filename, derived from `randomUUID()` plus an extension taken from the validated content type rather than from the caller's filename. - The filesystem operations on those paths — `fs.open` in `readHead`, `fs.unlink` in `discardUploads`. ## What this should produce For each hotspot: the rule, what it is pointing at, whether the current code is safe and *why*, and either a change or a justification. The justification has to live in the repository, not only in SonarQube's UI, so that the next person to see the marker does not have to re-derive the reasoning — the existing S5693 comment is the model. Then mark them reviewed in SonarQube, so the count reflects decisions made rather than decisions pending. ## Note The specific three rule keys are not visible from here — `SONAR_HOST_URL` is a CI secret, so the dashboard cannot be queried from the repository. The review below works from the code, covering every hotspot-class rule that can apply to this file. If the three named on the dashboard differ from the ones identified, that is worth knowing, since a hotspot nobody expected is more interesting than one already understood.
Author
Owner

Reviewed, and one of the three was hiding a real gap

On feature/180-security-hotspots.

The limits — a real finding underneath

The limits block bounds one request properly: 8,000,000 bytes per file (chosen under S5693's threshold, as the existing comment says), six files, eight fields, 64 KB per field. Reviewing why that rule exists turned up the half it does not cover.

multer writes every file to disk before any route logic runs, and multer's own cleanup only fires for errors multer itself raised. Everything after that left the bytes behind:

  • a valid photograph with a malformed category_id → 400 after the write, file kept for ever
  • the same for a malformed tags field
  • a database error rolling the transaction back → 500, files kept
  • readHead throwing → returned no refusal message, so the existing cleanup never ran

None of these leave a database row, so there is nothing to find the files by afterwards. Bounding one request to 8 MB is not much use if every refused request keeps its bytes permanently, and POST /api/admin/items is reachable by anyone past the admin boundary.

Reproduced before fixing — a valid PNG plus category_id=not-a-number returns 400 and the file is still on the volume.

Fixed with a hook rather than a call at each return, registered as soon as multer succeeds, so a route added later inherits it instead of having to remember. The failure being prevented is exactly someone adding a fourth early return. It listens on close rather than finish so an aborted connection is covered, and checks writableEnded so a response that never completed is not read as a success whatever its status code says.

verifyUploadedImages goes back to checking only — removing the files there too would unlink twice and log an ENOENT on every refused upload, and the one mechanism covers the case it used to miss.

The other two — safe, and now say so in the file

The upload path is not caller-controlled, despite arriving from a request. multer composes it from destination, a server constant, and filename, which the storage sets to randomUUID() plus an extension looked up from the validated content type. The caller's originalname is never consulted, so nothing traverses anywhere. That reasoning now sits next to the fs.open that depends on it rather than only in SonarQube's UI, following the precedent of the comment that already names S5693 by number.

The destination directory comes from process.env.UPLOADS_DIR. UPLOADS_DIR is in ALWAYS_REQUIRED, so a deployed process that lacks it refuses to start rather than falling back — the /app/uploads default is reachable only in tests.

Verification

Three tests written first and failing first: refused sibling field, refused tags field, and the accepted case, which must not be swept up by the same cleanup. 254 integration and 278 unit tests pass.

Still needs you

The three rule keys on the dashboard. SONAR_HOST_URL is a CI secret so I cannot query it, and the review above works from the code. If the three named there are the ones I have described, the two safe ones can be marked Reviewed → Safe with the justifications above, and the third as Fixed. If any of them is something else, that one is more interesting than all of this — it would mean a sensitive path nobody has looked at.

## Reviewed, and one of the three was hiding a real gap On `feature/180-security-hotspots`. ### The limits — a real finding underneath The limits block bounds one request properly: 8,000,000 bytes per file (chosen under S5693's threshold, as the existing comment says), six files, eight fields, 64 KB per field. Reviewing *why* that rule exists turned up the half it does not cover. **multer writes every file to disk before any route logic runs**, and multer's own cleanup only fires for errors multer itself raised. Everything after that left the bytes behind: - a valid photograph with a malformed `category_id` → 400 after the write, file kept for ever - the same for a malformed `tags` field - a database error rolling the transaction back → 500, files kept - `readHead` throwing → returned no refusal message, so the existing cleanup never ran None of these leave a database row, so there is nothing to find the files by afterwards. Bounding one request to 8 MB is not much use if every refused request keeps its bytes permanently, and `POST /api/admin/items` is reachable by anyone past the admin boundary. Reproduced before fixing — a valid PNG plus `category_id=not-a-number` returns 400 and the file is still on the volume. **Fixed with a hook rather than a call at each `return`**, registered as soon as multer succeeds, so a route added later inherits it instead of having to remember. The failure being prevented is exactly someone adding a fourth early return. It listens on `close` rather than `finish` so an aborted connection is covered, and checks `writableEnded` so a response that never completed is not read as a success whatever its status code says. `verifyUploadedImages` goes back to checking only — removing the files there too would unlink twice and log an ENOENT on every refused upload, and the one mechanism covers the case it used to miss. ### The other two — safe, and now say so in the file **The upload path is not caller-controlled**, despite arriving from a request. multer composes it from `destination`, a server constant, and `filename`, which the storage sets to `randomUUID()` plus an extension looked up from the validated content type. The caller's `originalname` is never consulted, so nothing traverses anywhere. That reasoning now sits next to the `fs.open` that depends on it rather than only in SonarQube's UI, following the precedent of the comment that already names S5693 by number. **The destination directory** comes from `process.env.UPLOADS_DIR`. `UPLOADS_DIR` is in `ALWAYS_REQUIRED`, so a deployed process that lacks it refuses to start rather than falling back — the `/app/uploads` default is reachable only in tests. ### Verification Three tests written first and failing first: refused sibling field, refused tags field, and the accepted case, which must not be swept up by the same cleanup. 254 integration and 278 unit tests pass. ### Still needs you The three rule keys on the dashboard. `SONAR_HOST_URL` is a CI secret so I cannot query it, and the review above works from the code. If the three named there are the ones I have described, the two safe ones can be marked Reviewed → Safe with the justifications above, and the third as Fixed. **If any of them is something else, that one is more interesting than all of this** — it would mean a sensitive path nobody has looked at.
bermudalamb self-assigned this 2026-08-25 11:49:42 -05:00
bermudalamb added this to the Code Quality and Hardening 2 project 2026-08-25 11:49:50 -05:00
bermudalamb added reference feature/180-security-hotspots 2026-08-25 11:49:53 -05:00
bermudalamb moved this to Review in Code Quality and Hardening 2 on 2026-08-25 11:49:56 -05:00
Author
Owner

The three rule keys — and they are not the three that were reviewed

Queried the dashboard directly using SONARQUBE_URL and SONARQUBE_TOKEN from the dev machine's environment, which turns out to be reachable after all: GET /api/hotspots/search?projectKey=redefined-designs.

The answer is the interesting one the issue was braced for. All three open hotspots are typescript:S2077 — "Formatting SQL queries is security-sensitive" — in the SQL, not the upload path.

Hotspot Rule Category Status
...kvH4 typescript:S2077 sql-injection (HIGH) TO_REVIEW
...kvH5 typescript:S2077 sql-injection (HIGH) TO_REVIEW
...kvH6 typescript:S2077 sql-injection (HIGH) TO_REVIEW
AZ_9K1xD8DfNI3dztoq3 S5693 content length dos (MEDIUM) REVIEWED / SAFE

So the DoS hotspot over the multer limits is the one that was genuinely closed, and the upload review in the previous comment — the path-traversal and UPLOADS_DIR reasoning — was reviewing hotspots the dashboard was not raising. That work stands on its own (the refused-upload accumulation it uncovered was a real defect), but it did not touch these three.

The scope note in the issue body is where this went wrong. It says "no string-built SQL. The eight routes are all parameterised queries." The queries are parameterised, but three of them are assembled as template literals, and S2077 fires on the formatting, not on the binding.

Where they are

The dashboard's line numbers are against revision 4ff713a, so they do not match main. Same three call sites:

Dashboard line (4ff713a) main Code
291 345 pool.query(`${ADMIN_ITEM_SELECT} ${where} ORDER BY i.created_at DESC`, params)
326 370 pool.query(`${ADMIN_ITEM_SELECT} WHERE i.id = $1`, [item.id])
380 418 pool.query(`${ADMIN_ITEM_SELECT} WHERE i.id = $1`, [req.params.id])

The review: all three are Safe

admin.ts:370 and admin.ts:418 are a module constant concatenated with a literal containing $1. ADMIN_ITEM_SELECT is a const in itemSelect.ts with no interpolation of its own. The value is bound, not formatted — at :418 that value is req.params.id, which is caller-controlled and goes through the driver as a parameter, which is the whole point. Nothing user-supplied reaches the query text.

admin.ts:345 is the one worth the rule's attention, because where really is built at runtime: `WHERE ${clauses.join(' AND ')}`. Tracing clauses into buildItemFilterSql (itemFilters.ts:248-325) settles it. Every element pushed onto clauses is a string literal written in that file. The only interpolation into any of them is $${next} — and next is a number, seeded from the startIndex argument and incremented locally. It is never derived from a filter value. Every user-supplied value is pushed onto params and bound.

Which clauses appear is caller-influenced; what any clause says is not. An attacker controlling category_id, tags, price bounds or status can change which of six fixed fragments are joined, and can put anything they like into params — and none of it becomes SQL. parseItemFilters throws FilterError before that point for anything malformed, but that is defence in depth rather than what makes this safe; the clause literals would be safe even without it.

Worth noting the same builder backs the storefront (items.ts:75-76), so this reasoning covers both callers, and the admin route refuses favoritesOnly at :339-341 rather than passing a null customer id into the clause that would throw.

What is left

  1. Record the reasoning in admin.ts next to the three call sites, following the precedent of the S5693 comment, so the next person seeing the marker does not re-derive it. That is the repository half the issue asks for.
  2. Mark the three Reviewed → Safe on the dashboard.

Separately, and worth its own issue

The most recent analysis of the real project key was today at 09:16 against revision 4ff713a — the tip of feature/185-tag-chip-colour, not main. main is fdb5b7a. The dashboard is currently describing an old feature branch: its admin.ts differs from main's by 67 insertions and 29 deletions, which is why the line numbers do not line up.

This is exactly the hazard scripts/scan-local.sh warns about in its header — Community edition has no branch analysis, so any scan under the real key replaces CI's picture of main. The workflow also triggers on pull_request, which has the same effect. So the quality gate and the hotspot list on the dashboard describe whatever was scanned last, not main, and nothing says so.

## The three rule keys — and they are not the three that were reviewed Queried the dashboard directly using `SONARQUBE_URL` and `SONARQUBE_TOKEN` from the dev machine's environment, which turns out to be reachable after all: `GET /api/hotspots/search?projectKey=redefined-designs`. The answer is the interesting one the issue was braced for. **All three open hotspots are `typescript:S2077` — "Formatting SQL queries is security-sensitive" — in the SQL, not the upload path.** | Hotspot | Rule | Category | Status | |---|---|---|---| | `...kvH4` | `typescript:S2077` | sql-injection (HIGH) | TO_REVIEW | | `...kvH5` | `typescript:S2077` | sql-injection (HIGH) | TO_REVIEW | | `...kvH6` | `typescript:S2077` | sql-injection (HIGH) | TO_REVIEW | | `AZ_9K1xD8DfNI3dztoq3` | S5693 content length | dos (MEDIUM) | **REVIEWED / SAFE** | So the DoS hotspot over the multer limits is the one that was genuinely closed, and the upload review in the previous comment — the path-traversal and `UPLOADS_DIR` reasoning — was reviewing hotspots the dashboard was not raising. That work stands on its own (the refused-upload accumulation it uncovered was a real defect), but it did not touch these three. The scope note in the issue body is where this went wrong. It says "no string-built SQL. The eight routes are all parameterised queries." The queries are parameterised, but three of them are *assembled* as template literals, and S2077 fires on the formatting, not on the binding. ## Where they are The dashboard's line numbers are against revision `4ff713a`, so they do not match `main`. Same three call sites: | Dashboard line (`4ff713a`) | `main` | Code | |---|---|---| | 291 | 345 | `` pool.query(`${ADMIN_ITEM_SELECT} ${where} ORDER BY i.created_at DESC`, params) `` | | 326 | 370 | `` pool.query(`${ADMIN_ITEM_SELECT} WHERE i.id = $1`, [item.id]) `` | | 380 | 418 | `` pool.query(`${ADMIN_ITEM_SELECT} WHERE i.id = $1`, [req.params.id]) `` | ## The review: all three are Safe **`admin.ts:370` and `admin.ts:418`** are a module constant concatenated with a literal containing `$1`. `ADMIN_ITEM_SELECT` is a `const` in `itemSelect.ts` with no interpolation of its own. The value is bound, not formatted — at `:418` that value is `req.params.id`, which is caller-controlled and goes through the driver as a parameter, which is the whole point. Nothing user-supplied reaches the query text. **`admin.ts:345`** is the one worth the rule's attention, because `where` really is built at runtime: `` `WHERE ${clauses.join(' AND ')}` ``. Tracing `clauses` into `buildItemFilterSql` (`itemFilters.ts:248-325`) settles it. Every element pushed onto `clauses` is a string literal written in that file. The only interpolation into any of them is `$${next}` — and `next` is a number, seeded from the `startIndex` argument and incremented locally. It is never derived from a filter value. Every user-supplied value is pushed onto `params` and bound. Which clauses appear is caller-influenced; what any clause *says* is not. An attacker controlling `category_id`, `tags`, price bounds or `status` can change which of six fixed fragments are joined, and can put anything they like into `params` — and none of it becomes SQL. `parseItemFilters` throws `FilterError` before that point for anything malformed, but that is defence in depth rather than what makes this safe; the clause literals would be safe even without it. Worth noting the same builder backs the storefront (`items.ts:75-76`), so this reasoning covers both callers, and the admin route refuses `favoritesOnly` at `:339-341` rather than passing a null customer id into the clause that would throw. ## What is left 1. Record the reasoning in `admin.ts` next to the three call sites, following the precedent of the S5693 comment, so the next person seeing the marker does not re-derive it. That is the repository half the issue asks for. 2. Mark the three Reviewed → Safe on the dashboard. ## Separately, and worth its own issue The most recent analysis of the **real** project key was today at 09:16 against revision `4ff713a` — the tip of `feature/185-tag-chip-colour`, not `main`. `main` is `fdb5b7a`. The dashboard is currently describing an old feature branch: its `admin.ts` differs from `main`'s by 67 insertions and 29 deletions, which is why the line numbers do not line up. This is exactly the hazard `scripts/scan-local.sh` warns about in its header — Community edition has no branch analysis, so any scan under the real key replaces CI's picture of `main`. The workflow also triggers on `pull_request`, which has the same effect. So the quality gate and the hotspot list on the dashboard describe whatever was scanned last, not `main`, and nothing says so.
bermudalamb moved this to Released in Code Quality and Hardening 2 on 2026-08-27 11:29:59 -05:00
Sign in to join this conversation.
No labels
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: bermudalamb/redefined-designs#180