Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,9 @@ jobs:
strategy:
fail-fast: false
matrix:
node-version: ["22.19.0", "24"]
# Node 24.19.0 regressed native ObjectWrap teardown. Restore the
# floating "24" lane after https://github.com/nodejs/node/pull/65042 ships.
node-version: ["22.19.0", "24.18.0"]

steps:
- name: Checkout
Expand Down
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@

## Unreleased

- Fixed Web Push delivery on supported Node releases by honoring the
all-address DNS lookup callback contract while still pinning each request to
one previously validated public address.

## 0.19.0 — Web Push, console discovery, and host themes (2026-08-13)

### Reliable Web Push
Expand Down
26 changes: 26 additions & 0 deletions packages/web/src/__tests__/push.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import { createECDH } from "node:crypto";
import { once } from "node:events";
import { rm } from "node:fs/promises";
import { connect, createServer } from "node:net";
import { join } from "node:path";
import { DatabaseSync } from "node:sqlite";

Expand All @@ -9,6 +11,7 @@ import webPush from "web-push";
import type { WebAgentSummary } from "../contracts.js";
import { webPushPreview } from "../push-preview.js";
import {
createWebPushPinnedLookup,
generateWebPushIdentity,
validateWebPushEndpoint,
validateWebPushKeys,
Expand Down Expand Up @@ -92,6 +95,29 @@ async function waitFor(predicate: () => boolean, timeoutMs = 2_000): Promise<voi
}

describe("Web Push safety and persistence", () => {
it("returns an address array when Node auto-family lookup requests all pinned addresses", async () => {
const server = createServer((socket) => socket.end());
server.listen(0, "127.0.0.1");
await once(server, "listening");
const address = server.address();
if (address === null || typeof address === "string") throw new Error("Expected a TCP test address.");

const socket = connect({
host: "push.example.test",
port: address.port,
autoSelectFamily: true,
lookup: createWebPushPinnedLookup({ address: "127.0.0.1", family: 4 }),
});
try {
await once(socket, "connect");
} finally {
socket.destroy();
await new Promise<void>((resolvePromise, reject) => {
server.close((error) => error === undefined ? resolvePromise() : reject(error));
});
}
});

it("persists one VAPID identity and fails closed on partial state", async () => {
const now = () => new Date("2026-08-13T08:00:00.000Z");
const store = await storeAt(now);
Expand Down
17 changes: 13 additions & 4 deletions packages/web/src/push.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { createHash } from "node:crypto";
import type { LookupAddress } from "node:dns";
import { lookup as dnsLookup } from "node:dns/promises";
import { request as httpsRequest } from "node:https";
import { BlockList, isIP } from "node:net";
import { BlockList, isIP, type LookupFunction } from "node:net";

import webPush, {
type Headers as WebPushHeaders,
Expand Down Expand Up @@ -550,9 +550,7 @@ function performPinnedRequest(
signal,
// Keep TLS hostname verification on the original host while pinning the
// validated address. Node's HTTPS client does not follow redirects.
lookup: ((_hostname: string, _options: unknown, callback: (error: Error | null, address: string, family: number) => void) => {
callback(null, pinned.address, pinned.family);
}) as never,
lookup: createWebPushPinnedLookup(pinned),
}, (response) => {
const chunks: Buffer[] = [];
let captured = 0;
Expand All @@ -576,6 +574,17 @@ function performPinnedRequest(
});
}

/** Preserve one validated address while honoring both Node lookup callback modes. */
export function createWebPushPinnedLookup(pinned: LookupAddress): LookupFunction {
return (_hostname, options, callback) => {
if (options.all === true) {
callback(null, [pinned]);
return;
}
callback(null, pinned.address, pinned.family);
};
}

function retryAfterMs(
headers: Readonly<Record<string, string | readonly string[] | undefined>>,
now: Date,
Expand Down
6 changes: 3 additions & 3 deletions scripts/__tests__/verify-all.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import {
runVerifyAll,
} from "../verify-all.mjs";

const EXPECTED_CI_NODE_MATRIX = Object.freeze([MINIMUM_NODE_VERSION, "24"]);
const EXPECTED_CI_NODE_MATRIX = Object.freeze([MINIMUM_NODE_VERSION, "24.18.0"]);
const CI_CHECKOUT_STEP = [
" - name: Checkout",
" uses: actions/checkout@v4",
Expand Down Expand Up @@ -293,7 +293,7 @@ describe("verify-all", () => {
].join("\n");
const mutated = [
" - name: Install packed consumer at the minimum Node version",
" if: ${{ matrix.node-version == '24' }}",
" if: ${{ matrix.node-version == '24.18.0' }}",
].join("\n");
const mutatedSource = replaceExactly(source, original, mutated);

Expand Down Expand Up @@ -754,7 +754,7 @@ describe("verify-all", () => {

it("rejects every noncanonical CI Node matrix value", () => {
const source = readCiWorkflow();
const matrix = " node-version: [\"22.19.0\", \"24\"]";
const matrix = " node-version: [\"22.19.0\", \"24.18.0\"]";

for (const replacement of ["20", "99", "future"]) {
const mutation = replaceExactly(
Expand Down
Loading