fix: close 3 high-severity findings, gate lint + types in CI - #29
Merged
Conversation
All three high-severity TODOs from the coverage review, plus the CI gap
that let a type error reach `dev` unnoticed.
1. Unvalidated numeric fields reaching MP $filter strings
`saveFamily` now runs `HouseholdSchema.safeParse()` before the payload
reaches FamilyService. A server action is a public POST endpoint and
its TypeScript annotation is erased at runtime, so `envelopeNo: number`
guaranteed nothing. The rejection reports field PATHS only, never the
submitted values — rule 14 applies to returned error strings too.
Authorization still runs first, so an unauthorized caller learns
nothing about the schema.
Defence in depth where the string is built: `resolveUniqueEnvelopeNo`
validates `requested` and `excludeDonorId`, and re-validates
`candidate` each loop iteration so a future change to
`getNextEnvelopeNumber()` cannot reintroduce an unchecked value.
`upsertDonor` validates the id it uses to target the Donors update.
`null` and `0` stay valid as the "no donor" sentinels.
Correction to the original report: the classic injection string was
already blocked, but incidentally — `upsertDonor` guards on
`envelopeNo > 0` and JS coerces "1 OR 1=1" to NaN. What actually got
through were values surviving numeric coercion without being positive
integers (1.5, Infinity, 1e21 interpolating as the malformed "1e+21").
That is malformed filters and confusing 500s, not an injection
primitive, so the practical severity was below "high". The fix stands:
relying on an incidental coercion side effect is fragile, and a schema
change or a new call path dropping that guard would make it real.
2. removeGroup silently dropped a non-empty group's fields
The guard existed on `groupedFields` but not `groupOrder`, and
`buildSavePayload()` iterates `groupOrder` — so the fields vanished
from the save payload and from live MP page configuration, with no
error shown. The guard now sits at the top of the callback, reading
the rendered state, so both updates agree.
The obvious fix does not work and is worth not retrying: a
`let removed = false` flag set inside the `setGroupedFields` updater
is still false when `setGroupOrder` is reached, because React runs
updaters during render, not at call time. It would have looked
correct and kept the bug.
3. Raw error objects logged household PII
docxtemplater attaches the live merge scope to `err.properties.scope`
on a scope-parser failure — in this feature that scope IS the
household list, so `console.error('...', error)` wrote every
printable name and mailing address for the batch to server logs.
New `describeError()` reduces a throw to `{ name, message }` plus
docxtemplater's own `id`/`explanation`, which carry no caller data.
Applied to mergeTemplate, generateLabelPdf and generateLabelDocx.
Also fixed the identical anti-pattern one file over, in
`addeditfamily/page.tsx` (filed separately as medium). Shipping a
redaction fix that skips the file next door would leave two standards
in one codebase.
CI gates
Added `npm run typecheck` (`tsc --noEmit`) and `npm run lint` as steps
of the existing `test` job — steps, not new jobs, because branch
protection requires the check named `test` and separate jobs would be
green-but-unrequired until someone also edited the protection rules.
Two more issues found in the same file:
- `npm install` -> `npm ci`. `npm install` resolves fresh versions and
rewrites the lockfile inside CI, so CI could test a dependency tree
no developer had. Verified with `npm ci --dry-run`.
- No `concurrency` group; superseded PR runs burned minutes. Now
cancels in-flight runs per ref, except on `main` and `dev` whose
runs gate merges and releases.
Plus `permissions: contents: read` — the workflow only reads the repo.
Tests: 1,535 -> 1,563. Coverage 98.85% statements / 99.70% lines.
TODOs: 9 open -> 4 (2 medium, 2 low, all in src/components). No high or
security items remain open.
Verified: lint 0 · typecheck 0 errors · test:coverage exit 0 ·
next build exit 0.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Codecov Report✅ All modified and coverable lines are covered by tests. 📢 Thoughts on this report? Let us know! |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes all three high-severity TODOs from the coverage review, plus the CI gap that let a type error sit on
devunnoticed.1. Unvalidated numeric fields reaching MP
$filterstringssaveFamilynow runsHouseholdSchema.safeParse()before the payload reachesFamilyService. A server action is a public POST endpoint and its TypeScript annotation is erased at runtime, soenvelopeNo: numberguaranteed nothing.The rejection reports field paths only (
members.0.envelopeNo), never the submitted values — rule 14 applies to returned error strings, not just logs. Authorization still runs first, so an unauthorized caller learns nothing about the schema.Defence in depth where the string is actually built:
resolveUniqueEnvelopeNovalidatesrequestedandexcludeDonorId, and re-validatescandidateeach loop iteration so a future change togetNextEnvelopeNumber()can't reintroduce an unchecked value.upsertDonorvalidates the id it uses to target theDonorsupdate.nulland0stay valid as the "no donor" sentinels.Correction to the original report — please read before assessing risk
The classic injection string was already blocked, but incidentally rather than by design.
upsertDonorguards onenvelopeNo > 0, and JS coerces"1 OR 1=1"toNaN, so that comparison is false and the crafted string never reached the filter.What did get through: values that survive numeric coercion without being positive integers —
1.5,Infinity,1e21(interpolating as the malformed1e+21), numeric strings. That's malformed filters and confusing 500s, not a filter-injection primitive. The practical severity was below "high" as filed.The fix still stands: relying on an incidental coercion side effect to block injection is fragile, and a schema change or a new call path that drops the
> 0guard would turn it into the real thing with nothing to catch it.2.
removeGroupsilently dropped a non-empty group's fieldsThe guard existed on
groupedFieldsbut notgroupOrder, andbuildSavePayload()iteratesgroupOrder— so the fields vanished from the save payload and from live MP page configuration, with no error shown to the admin. The guard now sits at the top of the callback, reading rendered state, so both updates agree.Worth knowing for review: the obvious fix doesn't work. A
let removed = falseflag set inside thesetGroupedFieldsupdater is stillfalsewhensetGroupOrderis reached, because React runs updaters during render, not at call time. It would have read as correct and kept the bug. I wrote it that way first; it's recorded in the TODO so it isn't retried.3. Raw error objects logged household PII
docxtemplater attaches the live merge scope to
err.properties.scopeon a scope-parser failure — in this feature that scope is the household list, soconsole.error('...', error)wrote every printable name and mailing address for the batch into server logs.New
describeError()reduces a throw to{ name, message }plus docxtemplater's ownid/explanation, which carry no caller data. Applied tomergeTemplate,generateLabelPdf,generateLabelDocx.One extra beyond the three highs: I also fixed the identical anti-pattern in
addeditfamily/page.tsx(filed separately as medium, and a file this branch already touches). Shipping a redaction fix that skips the file next door would leave two standards in one codebase. Say the word if you'd rather it were split out.CI gates
npm run typecheckandnpm run lintnow run as steps of the existingtestjob — steps, not new jobs, because branch protection requires the check namedtest, and separate jobs would be green-but-unrequired until someone also edited the protection rules. A gate that doesn't gate is worse than none.Two more issues found in the same file:
npm install→npm ci.npm installresolves fresh versions and rewritespackage-lock.jsoninside CI, so CI could be testing a dependency tree no developer had. Verified withnpm ci --dry-run.concurrencygroup — superseded PR runs burned minutes. Now cancels in-flight runs per ref, except onmainanddev, whose runs gate merges and releases and must not be killed.Plus
permissions: contents: read; the workflow only reads the repo, and Codecov uses its own token.Review pointers
familyService.ts0/nullsentinel handlingaddeditfamily/actions.tsuse-field-order-state.tsgroupedFieldsadded to depsaddress-labels/actions.tsdescribeError().github/workflows/test.ymlconcurrencyexemption for the trunksEverything else is tests (+28) and TODO/doc updates.
Status
TODOs: 9 open → 4 (2 medium, 2 low, all in
src/components). No high-severity or security items remain open.npm run lintnpm run typechecknpm run test:coveragenpm run build🤖 Generated with Claude Code