Skip to content

test(api): lock security-critical behaviour, then small guarded fixes#243

Merged
vernu merged 7 commits into
devfrom
feat/api-hardening
Jul 19, 2026
Merged

test(api): lock security-critical behaviour, then small guarded fixes#243
vernu merged 7 commits into
devfrom
feat/api-hardening

Conversation

@vernu

@vernu vernu commented Jul 19, 2026

Copy link
Copy Markdown
Owner

Summary

Adds test coverage for the untested, security-critical parts of the backend, then makes three small fixes that were each written test-first. The goal is to make it safe to add features quickly without regressing what works today. Test count 64 to 121.

Tests land first and lock current behaviour; the fixes follow, each guarded by a test that was seen to fail against the pre-change code and pass after.

Tests (behaviour locks, no code change)

  • Authorization guards (previously zero tests): owner passes, non-owner is rejected, admin bypass, invalid id, and missing record, across AuthGuard, CanModifyDevice, and CanModifyApiKey.
  • auth.service: email/password validators, the api-key lookup (masked hit and prefix fallback, with the revoked-key exclusion), key generation (only a masked value plus a hash are stored), change-password, and reset-password.
  • webhook.service: the delivery-URL host check accepts normal https and rejects loopback/private hosts; the signing-secret length rule; the X-Signature header equals an independently computed HMAC of the payload; delivery is aborted when the subscription is inactive or deleted.
  • support.service: rate limiting, and the account-deletion request flow.

Fixes (small, each guarded)

  1. Enforce email and password validation. The validators are async and throw, but three callers invoked them without await, so the checks never took effect (register accepted a malformed email or out-of-range password; change-password accepted a too-short one). Adding await makes the existing rules apply at their intended point. Legitimate clients are unaffected: the web forms already enforce these, so only input the service always meant to reject is now rejected, with a clean 400.
  2. Escape the api-key lookup regex. A key prefix was interpolated straight into a RegExp; a prefix with regex metacharacters could fail the request or mis-match. It is now escaped. The escapeRegExp helper (with its existing spec) moved to src/common so auth and gateway share one copy.
  3. Narrow the device list response. The device-list query returned the full document, including the push token and hardware serial, to the browser. Both are now projected out. Only the user-facing list is affected; the internal by-id lookup is untouched.

Deferred, with reasons

  • Global ValidationPipe. class-transformer (a required peer) is not installed, so enabling the pipe would add a dependency and risk breaking once any DTO gains a decorator. The validation win is delivered instead by fix V2 #1, which needs no new dependency. Recommended as a follow-up: add the dependency, enable the pipe, and decorate the auth DTOs, guarded by controller tests.
  • Stricter validation on the gateway send/receive DTOs (client payloads need a survey first), a by-id projection (feeds the guards), moving the webhook secret out of plaintext (needs a migration), and splitting the large gateway service (large restructure, better done now that these tests protect it).

Verification

Full suite green (121), pnpm build clean.

🤖 Generated with Claude Code

vernu and others added 7 commits July 19, 2026 18:21
The guards that prevent cross-tenant access had zero tests, which is the
highest-risk gap in the backend: a silent regression here would let one
account reach another account's devices or api keys.

These specs pin the current authz contract without changing any code:

- AuthGuard: valid bearer resolves the user; invalid/expired bearer is
  401; a valid x-api-key resolves via findActiveApiKeyByClientKey plus a
  matching bcrypt hash and attaches request.apiKey; a non-matching hash,
  an unknown key, and no credentials are all rejected; a token that
  resolves an id for a user that no longer exists is 401.
- CanModifyDevice / CanModifyApiKey: owner passes; non-owner is rejected
  (the cross-tenant case); admin passes regardless; an invalid ObjectId
  is 400 before any lookup; a missing record is rejected.

Guards are plain constructor-injected classes, so they are instantiated
directly with mocked services and a minimal ExecutionContext, which is
lighter than a testing module and equally faithful.

17 tests, all passing against the current unchanged guards.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
auth.service was 521 lines with only the withoutPassword helper covered.
These specs pin the flows a regression would hurt most, all against the
current unchanged code:

- validateEmail / validatePassword: boundary and malformed-input cases,
  including the 6 and 128 character password edges.
- findActiveApiKeyByClientKey: the exact-masked hit path and the legacy
  prefix-regex fallback, asserting the revoked-key exclusion is applied on
  both lookups. This is the behaviour lock for the upcoming regex-escape
  fix: it captures how a legitimate key resolves today.
- generateApiKey: the raw key is returned once, and only a masked value
  plus a bcrypt hash of the key are persisted (never the raw key).
- changePassword: a wrong old password is rejected without saving; the
  success path replaces the stored hash.
- resetPassword: a missing or non-matching OTP is rejected without saving;
  the success path updates the password and closes the reset window.

Note (to be fixed under the refactor part, not here): changePassword
calls validatePassword without await, so the length rule does not
actually block a weak password on change. Left as-is in this behaviour
-lock commit and tracked for a guarded fix.

16 tests, all passing against current code.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
webhook.service was 1039 lines with zero tests. These specs pin the
parts a regression would hurt most, without changing any code:

- validateDeliveryUrl: accepts normal https; rejects non-http(s) and
  malformed URLs; rejects loopback and private hosts including the cloud
  metadata IP. This locks the SSRF guard so it cannot silently regress.
- Signing secret validation: a secret under 20 characters is rejected on
  both create and update.
- attemptWebhookDelivery signing: the X-Signature header equals an
  independently computed HMAC-SHA256 of the JSON payload under the
  subscription secret, and the signature changes with the secret.
- Delivery abort: when the subscription is inactive or soft-deleted, the
  attempt is marked aborted and saved and no HTTP request is made.

axios is mocked so no request leaves the process; private methods are
exercised through the instance.

16 tests, all passing against current code.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
support.service had no tests. These lock both flows against current code:

- createSupportMessage: the 24h rate limit rejects a fourth request
  without persisting or emailing; the success path saves the message,
  strips the turnstile token before persistence, emails the requester,
  and returns success.
- requestAccountDeletion: a missing or invalid user id and an unknown
  user are both NotFound; a second request is a Conflict; the success
  path records accountDeletionRequestedAt with the reason and emails the
  user.

6 tests, all passing against current code.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
validateEmail and validatePassword are async and signal failure by
throwing (a rejected promise). Three callers invoked them without await:
register (both) and changePassword. An unawaited rejected promise is
dropped, so execution continued past the check: registration accepted a
malformed email or an out-of-range password, and a password change
accepted a too-short new password. The validators were effectively dead.

Adding await makes the existing rules take effect at their intended
point. Legitimate clients are unaffected: the web signup and
change-password forms already enforce a valid email and the length
bounds, so this only rejects input the service was always meant to
reject, with a clean 400 instead of persisting bad data.

Guards were written first and seen to fail against the pre-fix code
(register with a bad email or short password still created the user;
changePassword with a short password still saved), then pass after the
await is added. The valid-input paths are asserted to still succeed.

This is the dependency-free half of what a global ValidationPipe would
have covered. The pipe itself is deferred: class-transformer is not
installed, so enabling it now would add a dependency and risk crashing
once any DTO gains a decorator. Recommended as a separate follow-up.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
findActiveApiKeyByClientKey builds a fallback lookup as
new RegExp(`^${prefix}`) from the client-supplied key prefix and runs it
as a Mongo $regex. A prefix containing regex metacharacters compiles to
the wrong pattern or, for something like "((((", throws SyntaxError and
fails the request. On the authentication hot path that is a denial and a
correctness hole.

The prefix is now escaped with escapeRegExp before it reaches the RegExp.
escapeRegExp already existed under gateway with its own spec; it is moved
to src/common so auth and gateway share one copy, and the gateway import
is updated. No behaviour change for the gateway caller.

Guarded by the existing lock (a legitimate key still resolves via the
masked hit and the prefix fallback) plus a new case, written first and
seen to fail against the pre-fix code (it threw "Unterminated group"):
a key with metacharacters now compiles to a literal pattern and resolves
to no match instead of throwing.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
getDevicesForUser did find({ user }) with no projection, so the device
list endpoint returned the full document, including the fcmToken push
credential and the hardware serial, straight to the browser. Neither is
used by the dashboard or the Android client.

The query now projects both fields out with '-fcmToken -serial'. Only the
user-facing list is narrowed; getDeviceById, which feeds the guards and
internal logic, is untouched.

Guarded by the existing getDevicesForUser test, rewritten to assert the
projection and seen to fail against the pre-change single-argument call.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@vercel

vercel Bot commented Jul 19, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
textbee Ready Ready Preview, Comment Jul 19, 2026 5:25pm

Request Review

@vernu
vernu merged commit 381e898 into dev Jul 19, 2026
5 checks passed
@vernu
vernu deleted the feat/api-hardening branch July 19, 2026 17:35
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.

1 participant