Repository navigation
Conversation
…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.
Owner
Author
|
Superseded: the branch predates 1.4.0 and conflicts, so its changes were reworked onto the 1.4 line instead of merged.
|
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.
Summary
Hardens input validation and error handling across the send path: rejects invalid
timeoutMs,ttl, andvapidExpirationbefore 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.
timeoutMsmust be a whole number from 1 to 2^31 − 1 (the ceilingAbortSignal.timeouthonors);ttlmust be a whole number of seconds ≥ 0;vapidExpirationrejectsNaN. 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()validatesp256dhandauthonce per send before the VAPID JWT signature, avoiding wasted ECDSA work on bad input.encryptPayload()now takes pre-decoded keys and aSubscriptionKeyBytesstruct.VAPID public key validation extracted. New
decodeVapidPublicKey()inencoding.tschecks that a key is a 65-byte uncompressed P-256 point, with consistent error messages acrosssubscribe(),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.bodynow 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
vapidExpirationnever sends an expired token. Fixes 401/403 fills infailedon long batches.subscribe()returnsreplacedEndpointon 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
sendPushBatchland infailedinstead of rejecting the batch. The error handler now safely handlesnullrows with an emptyendpoint.Per-message ECDH key pair is non-extractable. Only the public half is exported; private keys stay locked in the HSM.
New
encoding.tsmodule. Centralizes base64url codec and P-256 key decoders, re-exported fromvapid.tsfor the public API.Implementation notes
assertValidSendOptions()consolidates validation oftopic,ttl, andtimeoutMsinto one call, reducing duplication betweensendPushNotification()andsendPushBatch().MAX_TIMEOUT_MS = 2^31 − 1is documented as the ceiling both Node and browsers enforce forAbortSignal.timeout().sendPushBatch()now stores{ jwt: Promise<string>; refreshAt: number }tuples to track expiry per origin.readBodyPrefix()uses aTextDecoderstream to handle multi-byte UTF-8 sequences correctly at the 4 KB boundary.https://claude.ai/code/session_01DWSBBC6tKja4b8eyAVN38V