Skip to content

fix(security): resolve PR #1703 review findings (CSRF FPs, secret fixture) - #1711

Merged
github-actions[bot] merged 1 commit into
Smartdevs17:mainfrom
akansnoro:fix/pr1703-review-comments
Oct 16, 2026
Merged

github-actions[bot] merged 1 commit into
Smartdevs17:mainfrom
akansnoro:fix/pr1703-review-comments

Conversation

@akansnoro

Copy link
Copy Markdown
Contributor

Follow-up to #1703, which is already merged. This clears the two review
threads that were left unresolved there.

Semgrep: subtrackr.csrf.state-changing-route-without-csrf (x2)

The rule fired on these two lines in backend/server/createApiServer.ts:

app.post('/api/v1/stripe/billing/webhook', express.raw({ type: '*/*', limit: MAX_JSON_BODY }));
app.post('/api/v1/paddle/webhook', express.raw({ type: '*/*', limit: MAX_JSON_BODY }));

These are false positives. Both lines install a body parser only — they
declare no handler and change no state. The rule matches $ROUTER.post($PATH, $HANDLER) and excepts only a middleware literally named csrfProtection,
requireAuth or verifyWebhookSignature; it cannot follow the call into
createStripeBillingRouter / createPaddleRouter in other files.

The routes are genuinely protected, and they fail closed:

Condition Response
Signing secret unconfigured (verifier === null) 503
HMAC over raw bytes does not match 401
Already-delivered event id 200 { duplicate: true }, never dispatched

The digest is compared with crypto.timingSafeEqual inside a 300 s replay
window (StripeWebhookVerifier.verify / PaddleWebhookVerifier.verify).

Why this is not fixed by adding middleware to the route

The tempting fix — running verify() again at the app level — is a silent
production regression. verify() calls remember(eventId) on success, so a
second call within the same request returns fresh: false, and the router
then answers 200 { duplicate: true } without ever dispatching the event.
Every Stripe/Paddle subscription lifecycle update would be dropped.

So this lands as a documented suppression (inline nosemgrep plus an entry in
.semgrep/semgrep-suppressions.yml with a 6-month expiry), with the
double-verification trap written into the code comment so the next reviewer
does not "fix" it the obvious wrong way.

GitGuardian: hardcoded Zapier webhook URL

outboundUrlGuard.test.ts asserted on:

validateOutboundUrl('https://hooks.zapier.com/hooks/catch/1/2/')

A fixture, not a secret — but the /hooks/catch/<n>/<n>/ path shape is
indistinguishable from a live hook URL, so it raised a false incident. Replaced
with a non-/hooks/catch/ path on the same host. The assertion only needs
"public host over https", and realistic Zapier-hostname coverage is kept.

Verification

  • node --experimental-strip-types --check passes on both touched TS files.
  • The nosemgrep justification gate from security-scan.yml was reproduced
    locally and passes (every suppression line carries a reason:).
  • npm run lint, npm run typecheck and the Jest suites were not run —
    node_modules is not installed in this environment. CI will cover those.

… secret fixture)

Address the two unresolved review threads left on PR Smartdevs17#1703.

## Semgrep: state-changing-route-without-csrf (x2)

`subtrackr.csrf.state-changing-route-without-csrf` matched

    app.post('/api/v1/stripe/billing/webhook', express.raw({ ... }))

because the rule only recognises a middleware literally named
`csrfProtection`, `requireAuth` or `verifyWebhookSignature`, and it cannot
follow the call into `createStripeBillingRouter` / `createPaddleRouter`.

Both are false positives. Those two lines install a *body parser only*:
they declare no handler and change no state. The routes are mounted
further down, and both fail closed:

- 503 when the signing secret is unconfigured;
- 401 when the HMAC over the raw bytes does not match
  (`timingSafeEqual` plus a 300s replay window).

The obvious "fix" of re-running verification at the app level would be a
regression. `StripeWebhookVerifier.verify()` calls `remember(eventId)` on
success, so a second call in the same request returns `fresh: false`, and
the router would answer `200 { duplicate: true }` without ever dispatching
the event, silently dropping every subscription lifecycle update. The
suppression records that constraint so nobody repeats it.

Registered in `.semgrep/semgrep-suppressions.yml` with a 6-month expiry so
the exception stays auditable.

## GitGuardian: hardcoded Zapier webhook URL

`outboundUrlGuard.test.ts` asserted on
`https://hooks.zapier.com/hooks/catch/1/2/`. It is a fixture, not a
secret, but the path shape is indistinguishable from a live hook URL and
created a false incident. Swapped for a non-`/hooks/catch/` path on the
same host: the assertion only needs "public host over https", and the
realistic Zapier hostname coverage is kept.

Verification: `node --experimental-strip-types --check` on both touched TS
files. Lint, typecheck and tests not run, `node_modules` is not installed
here.
@github-actions
github-actions Bot merged commit 1a8c1ca into Smartdevs17:main Oct 16, 2026
12 of 30 checks passed
@akansnoro
akansnoro deployed to security-review October 16, 2026 21:41 — with GitHub Actions Active

This branch was successfully deployed

1 active deployment
security-review — 0930994f Deployed Oct 16, 2026 by akansnoro via Security Dashboard #1361
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants