Skip to content

Validate send options and subscription keys upfront, cap response bodies - #1

Closed
MMMikeM wants to merge 9 commits into
mainfrom
claude/admiring-davinci-ord4ga
Closed

MMMikeM wants to merge 9 commits into
mainfrom
claude/admiring-davinci-ord4ga

Conversation

@MMMikeM

@MMMikeM MMMikeM commented Oct 19, 2026

Copy link
Copy Markdown
Owner

Summary

Hardens input validation and error handling across the send path: rejects invalid timeoutMs, ttl, and vapidExpiration before any network request, validates VAPID and subscription keys early to avoid wasted ECDSA signatures, caps push service response bodies to 4 KB to prevent unbounded buffering, and re-signs VAPID tokens at half their lifetime in batch sends to avoid expiry mid-batch.

Key changes

  • Input validation moved upfront. timeoutMs must be a whole number from 1 to 2^31 − 1 (the ceiling AbortSignal.timeout honors); ttl must be a whole number of seconds ≥ 0; vapidExpiration rejects NaN. All three are checked once per send before any network I/O, rather than per-subscription or on the wire.

  • Subscription key decoding separated from encryption. New decodeSubscriptionKeys() validates p256dh and auth once per send before the VAPID JWT signature, avoiding wasted ECDSA work on bad input. encryptPayload() now takes pre-decoded keys and a SubscriptionKeyBytes struct.

  • VAPID public key validation extracted. New decodeVapidPublicKey() in encoding.ts checks that a key is a 65-byte uncompressed P-256 point, with consistent error messages across subscribe(), createVapidJwt(), and the send path. Catches malformed keys (unset env vars, quoted strings, private keys by mistake) before the permission prompt or any subscription is touched.

  • Response body capped at 4 KB. New readBodyPrefix() reads push service responses in chunks and cancels the stream once 4 KB is reached, preventing hostile endpoints from streaming unbounded bodies into memory. WebPushError.body now documents this limit.

  • VAPID tokens re-signed at half their lifetime in batch sends. The JWT cache now tracks expiry and re-signs when half the lifetime has elapsed, so a batch that outlasts vapidExpiration never sends an expired token. Fixes 401/403 fills in failed on long batches.

  • subscribe() returns replacedEndpoint on key rotation. When a stale-key subscription is unsubscribed and replaced, its endpoint is returned so the server can delete that record immediately, rather than waiting for a send to report it gone.

  • subscribe() validates the VAPID key before the permission prompt. A malformed key no longer unsubscribes a working subscription as a "rotation"; the key is checked first and throws if invalid.

  • Null rows in sendPushBatch land in failed instead of rejecting the batch. The error handler now safely handles null rows with an empty endpoint.

  • Per-message ECDH key pair is non-extractable. Only the public half is exported; private keys stay locked in the HSM.

  • New encoding.ts module. Centralizes base64url codec and P-256 key decoders, re-exported from vapid.ts for the public API.

Implementation notes

  • assertValidSendOptions() consolidates validation of topic, ttl, and timeoutMs into one call, reducing duplication between sendPushNotification() and sendPushBatch().
  • MAX_TIMEOUT_MS = 2^31 − 1 is documented as the ceiling both Node and browsers enforce for AbortSignal.timeout().
  • The JWT cache in sendPushBatch() now stores { jwt: Promise<string>; refreshAt: number } tuples to track expiry per origin.
  • readBodyPrefix() uses a TextDecoder stream to handle multi-byte UTF-8 sequences correctly at the 4 KB boundary.

https://claude.ai/code/session_01DWSBBC6tKja4b8eyAVN38V

…ription

subscribe() compared the new key against the existing subscription's key
before anything checked the key was well-formed. An empty or wrong-length
key (an unset env var, the private key by mistake) never matched, so the
working subscription was unsubscribed as a key rotation, and only then did
pushManager.subscribe reject the key. The key is now decoded and checked
as a 65-byte uncompressed P-256 point first, before the permission prompt.

getCurrentSubscription() awaited navigator.serviceWorker.ready, which never
settles without a registered worker, so it and unsubscribe() /
unsubscribeFromPush() hung. It now uses getRegistration() and resolves null
when nothing is registered.

Also adds the missing @throws on subscribe and unsubscribeFromPush,
collapses unsubscribe's empty try/catch to .catch(), and documents the
unsubscribe() -> removeSubscriptionFromServer(endpoint) flow in the README.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DWSBBC6tKja4b8eyAVN38V
When subscribe() found a subscription bound to a different VAPID key, it
unsubscribed it and threw the endpoint away, so the server could not
delete the stale record. It lingered until a send reported it gone, which
never happens while the push service answers the new key with 401/403.

SubscribeResult gains an optional replacedEndpoint, set only after a
rotation. Optional rather than required so code that builds a
SubscribeResult by hand keeps compiling.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DWSBBC6tKja4b8eyAVN38V
If a push service reissued the same endpoint after a key rotation, the
README flow (save the new subscription, then delete replacedEndpoint)
deleted the record it had just saved. The property is now omitted in
that case, and also omitted, rather than present as undefined, when
nothing was replaced, matching "set only after a rotation".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DWSBBC6tKja4b8eyAVN38V
The 65-byte/0x04 public key check lived in three places (client.ts,
vapid.ts, encrypt.ts). It now lives once in decodeP256PublicKey, in a new
internal src/encoding.ts that also takes the base64url codec; vapid.ts
re-exports the codec, so the public surface is unchanged.

The decoder also maps atob's InvalidCharacterError onto the documented
message, so a still-quoted env var or other non-base64 key reads as a
bad VAPID or p256dh key rather than a decoding failure.

The client bundle grows by 46 B, which moves the README size figures.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DWSBBC6tKja4b8eyAVN38V
- timeoutMs accepted any finite positive number, but AbortSignal.timeout
  in Node clamps anything above 2^31 - 1 to 1ms and throws on fractions,
  so 3e9 aborted every request and 1500.5 threw on every send.
- ttl was never checked, so NaN or -1 went into the TTL header and every
  request drew a 400.
- The vapidExpiration range check let NaN through (both comparisons are
  false), signing a JWT with "exp": null.

Each now throws once before anything is sent. The three send-option
checks share one assertValidSendOptions in both send functions.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DWSBBC6tKja4b8eyAVN38V
sendPushBatch cached one JWT per push-service origin for the whole batch,
so a batch outlasting vapidExpiration (a short expiry, or a long fan-out)
sent expired tokens. Each cached token is now re-signed once it reaches
half its lifetime.

A null row (data cast from a database or JSON) threw a TypeError inside
the try, and then again in the catch when it read subscription.endpoint.
That rejected the batch, lost every other result, and left the other
workers sending unobserved. The row now lands in failed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DWSBBC6tKja4b8eyAVN38V
postToPushService read the whole response with response.text(). The
endpoint is subscriber-supplied, so a hostile one could stream an endless
body into memory on every send, up to 100 at once in a batch. It now reads
at most 4 KB and cancels the rest of the stream. Against the old code the
new endless-body test crashes the test worker rather than failing.

The per-message ephemeral key pair was generated extractable. Only its
public half is exported, and Web Crypto public keys are exportable either
way, so it is now generated with extractable: false.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DWSBBC6tKja4b8eyAVN38V
validatePushInputs ran twice on every send: as the pre-check before
signing the VAPID JWT, then again inside the encryption core, which threw
the decoded bytes away and base64-decoded both keys a second time. In a
batch it also re-checked the payload size per subscription after the
up-front check.

decodeSubscriptionKeys now decodes the keys once, the encryption core
takes the decoded bytes, and the payload limit is checked once per send
or once per batch. postToPushService becomes plain HTTP over an
encrypted body.

README size figures move with this series: the server entry is 212 B
larger than before it, mostly the response-body reader.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DWSBBC6tKja4b8eyAVN38V
getCurrentSubscription and unsubscribe can reject from the registration
or subscription lookup; sendSubscriptionToServer throws on a subscription
without keys, and it and removeSubscriptionFromServer reject when fetch
cannot reach the server; generateVapidKeys throws if the runtime exports
a private key without its scalar. unsubscribeFromPush's @throws implied
unsubscribe never rejects; it now says which failure each one surfaces.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DWSBBC6tKja4b8eyAVN38V
MMMikeM added a commit that referenced this pull request Oct 22, 2026
Also rounds fractional ttl and timeoutMs, puts null batch rows in failed, and makes the ECDH key non-extractable. From #1.
@MMMikeM

MMMikeM commented Oct 22, 2026

Copy link
Copy Markdown
Owner Author

Superseded: the branch predates 1.4.0 and conflicts, so its changes were reworked onto the 1.4 line instead of merged.

  • Done separately in 1.4.0: the VAPID key check before subscribe touches an existing subscription, a plain Error for a malformed subscription, and rejecting a NaN vapidExpiration.
  • Ported in 1.4.1 (167f25f): batch VAPID tokens re-signed at half their lifetime, response bodies capped at 4 KB, the non-extractable ECDH key, null batch rows landing in failed, and timeoutMs/ttl checks. Fractional values are rounded rather than rejected, to keep it a patch release.
  • Not taken: replacedEndpoint, which is new API and may come in a minor release, and the encoding.ts refactor.

@MMMikeM MMMikeM closed this Oct 22, 2026
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