@goodandready/dsh-plugin-notify 0.4.5 → 0.4.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -4,6 +4,14 @@ All notable changes to this project will be documented in this file.
4
4
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
5
5
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
6
 
7
+ ## [0.4.6] - 2026-10-22
8
+
9
+ ### Security
10
+ - **Web Push Endpoint Allowlist & SSRF Protection** (#64): Enforce strict validation of subscription endpoints against canonical Web Push gateways (Google FCM, Mozilla Autopush, Apple Web Push, Microsoft WNS) with optional `webPush.allowedHosts`; reject loopback, private IPv4/IPv6, link-local, userinfo, and non-standard ports.
11
+ - **Push Endpoint Masking in Logs** (#64): Mask subscription endpoint tokens in server logs (`maskEndpoint`) and delivery error messages to prevent token exfiltration.
12
+ - **Subscription Capacity Bound** (#64): Constrain total stored subscriptions (max 100) to prevent state file exhaustion DoS.
13
+ - **Dead & Disallowed Endpoint Pruning** (#64): Prune any invalid or unapproved endpoints during `deliverWebPush` without dispatching requests.
14
+
7
15
  ## [0.4.5] - 2026-10-19
8
16
 
9
17
  ### Fixed
package/README.md CHANGED
@@ -151,6 +151,7 @@ Put each webhook URL into **Settings → Credentials**. In the plugin card, type
151
151
  | `webPush.onlyWhenAway` | boolean | `true` | Skip web push while a browser client is connected to the SSE stream. |
152
152
  | `webPush.subject` | string | `mailto:notify@localhost` | VAPID subject (mailto:/https: contact) advertised to push services. |
153
153
  | `webPush.stateFile` | string | empty | State file for VAPID keys and subscriptions (default `$DSH_HOME/plugin-notify-state.json`). |
154
+ | `webPush.allowedHosts` | string[] | `[]` | Optional list of extra allowed push endpoint hostnames or wildcards (default allowlist covers Google FCM, Mozilla, Apple, Microsoft). |
154
155
  | `webhooks.*` | string | empty | Credential **name** whose value is the webhook URL. Empty disables the channel. |
155
156
  | `events` | string[] | `task_done`, `error`, `approval_requested` | Event whitelist. Empty restores the default three. |
156
157
  | `local` | boolean | `true` | macOS `osascript` popup; ignored on other platforms. |
package/README.ru.md CHANGED
@@ -159,6 +159,7 @@ dsh plugin --profile web add @goodandready/dsh-plugin-notify
159
159
  | `webPush.onlyWhenAway` | boolean | `true` | Пропускать web push, пока подключён браузерный клиент. |
160
160
  | `webPush.subject` | string | `mailto:notify@localhost` | VAPID-контакт (mailto:/https:), передаваемый push-сервисам. |
161
161
  | `webPush.stateFile` | string | пусто | Файл состояния для VAPID-ключей и подписок (по умолчанию `$DSH_HOME/plugin-notify-state.json`). |
162
+ | `webPush.allowedHosts` | string[] | `[]` | Дополнительные доверенные хосты/маски push-эндпоинтов (по умолчанию строго разрешены Google FCM, Mozilla, Apple, Microsoft). |
162
163
 
163
164
  Сырой `http(s)://` в `webhooks.*` всё ещё отправляется с предупреждением. Перенесите URL в Credentials.
164
165
 
package/README.zh.md CHANGED
@@ -158,6 +158,7 @@ dsh plugin --profile web add @goodandready/dsh-plugin-notify
158
158
  | `webPush.onlyWhenAway` | boolean | `true` | 有浏览器客户端连接时跳过网页推送。 |
159
159
  | `webPush.subject` | string | `mailto:notify@localhost` | 向推送服务公示的 VAPID 联系方式(mailto:/https:)。 |
160
160
  | `webPush.stateFile` | string | 空 | VAPID 密钥与订阅的状态文件(默认 `$DSH_HOME/plugin-notify-state.json`)。 |
161
+ | `webPush.allowedHosts` | string[] | `[]` | 额外允许的推送端点域名或通配符列表(默认严格仅允许 Google FCM、Mozilla、Apple、Microsoft)。 |
161
162
 
162
163
  `webhooks.*` 中残留的原始 `http(s)://` 仍会发送,但会给出弃用警告。请迁移到凭据。
163
164
 
package/lib/index.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { spawn } from 'node:child_process';
2
2
  import crypto from 'node:crypto';
3
+ import net from 'node:net';
3
4
  import fs from 'node:fs';
4
5
  import fsp from 'node:fs/promises';
5
6
  import os from 'node:os';
@@ -62,6 +63,8 @@ export const Config = Schema.object({
62
63
  .description('VAPID subject (mailto: or https: contact URL) advertised to push services'),
63
64
  stateFile: Schema.string().default('').volatile()
64
65
  .description('State file for VAPID keys and push subscriptions (default: $DSH_HOME/plugin-notify-state.json)'),
66
+ allowedHosts: Schema.array(Schema.string()).default([]).volatile()
67
+ .description('Optional list of additional custom push endpoint hostnames or wildcards (e.g. push.mycorp.internal)'),
65
68
  }).description('Web Push (PWA) channel configuration'),
66
69
  });
67
70
 
@@ -398,25 +401,118 @@ export async function getVapidKeys(ctx, webPushConfig = {}) {
398
401
  return keys;
399
402
  }
400
403
 
401
- function isValidSubscription(sub) {
404
+ export const DEFAULT_ALLOWED_PUSH_HOST_PATTERNS = [
405
+ // Google FCM / Android
406
+ /^fcm\.googleapis\.com$/i,
407
+ /^[a-z0-9-]+\.fcm\.googleapis\.com$/i,
408
+ /^android\.googleapis\.com$/i,
409
+ // Mozilla Firefox
410
+ /^updates\.push\.services\.mozilla\.com$/i,
411
+ /^(?:[a-z0-9-]+\.)*push\.services\.mozilla\.com$/i,
412
+ /^(?:[a-z0-9-]+\.)*autopush\.services\.mozilla\.com$/i,
413
+ // Apple Safari (macOS / iOS)
414
+ /^web\.push\.apple\.com$/i,
415
+ /^(?:[a-z0-9-]+\.)*push\.apple\.com$/i,
416
+ // Microsoft Windows / Edge WNS
417
+ /^(?:[a-z0-9-]+\.)*notify\.windows\.com$/i,
418
+ /^(?:[a-z0-9-]+\.)*wns\.windows\.com$/i,
419
+ ];
420
+
421
+ let testAllowedPushHosts = null;
422
+ /** Test seam: temporarily allow extra test push hosts or wildcards (e.g. push.example). */
423
+ export function setAllowedPushHosts(hosts) {
424
+ testAllowedPushHosts = Array.isArray(hosts) ? hosts : (hosts ? [hosts] : null);
425
+ }
426
+ export function resetAllowedPushHosts() {
427
+ testAllowedPushHosts = null;
428
+ }
429
+
430
+ export function maskEndpoint(endpoint) {
431
+ if (typeof endpoint !== 'string') return '';
432
+ try {
433
+ const u = new URL(endpoint);
434
+ const parts = u.pathname.split('/').filter(Boolean);
435
+ const prefix = parts.slice(0, 2).join('/');
436
+ return `${u.protocol}//${u.host}/${prefix}${parts.length > 2 ? '/[masked]' : ''}`;
437
+ } catch {
438
+ return '[invalid-endpoint]';
439
+ }
440
+ }
441
+
442
+ export function isAllowedPushEndpoint(endpoint, customAllowedHosts = []) {
443
+ if (typeof endpoint !== 'string' || endpoint.length === 0 || endpoint.length > 2048) return false;
444
+ let url;
445
+ try {
446
+ url = new URL(endpoint);
447
+ } catch {
448
+ return false;
449
+ }
450
+ if (url.protocol !== 'https:') return false;
451
+ if (url.username || url.password) return false;
452
+ if (url.port && url.port !== '443') return false;
453
+
454
+ const host = url.hostname.toLowerCase().trim();
455
+ if (!host || host.length > 253) return false;
456
+ if (/[\s\x00-\x1f]/.test(host)) return false;
457
+
458
+ // Reject IP addresses (IPv4, IPv6, numeric, hex, octal, bracketed)
459
+ if (net.isIP(host) !== 0) return false;
460
+ if (/^(\d+|0x[0-9a-f]+)(\.(\d+|0x[0-9a-f]+)){0,3}$/i.test(host)) return false;
461
+ if (/^\[?[0-9a-f:]+\]?$/i.test(host)) return false;
462
+
463
+ const combinedCustom = [
464
+ ...(Array.isArray(customAllowedHosts) ? customAllowedHosts : []),
465
+ ...(Array.isArray(testAllowedPushHosts) ? testAllowedPushHosts : []),
466
+ ];
467
+
468
+ // Reject local / private TLDs unless explicitly permitted in custom allowed hosts
469
+ const blockedSuffixes = ['.localhost', '.local', '.internal', '.lan', '.home.arpa', '.invalid', '.test', '.example'];
470
+ if (host === 'localhost' || blockedSuffixes.some((s) => host.endsWith(s))) {
471
+ const explicitlyAllowed = combinedCustom.some((c) => typeof c === 'string' && c.toLowerCase().trim() === host);
472
+ if (!explicitlyAllowed) return false;
473
+ }
474
+
475
+ if (DEFAULT_ALLOWED_PUSH_HOST_PATTERNS.some((re) => re.test(host))) return true;
476
+
477
+ for (const pattern of combinedCustom) {
478
+ if (!pattern || typeof pattern !== 'string') continue;
479
+ const p = pattern.toLowerCase().trim();
480
+ if (p.startsWith('*.')) {
481
+ const root = p.slice(2);
482
+ if (host === root || host.endsWith('.' + root)) return true;
483
+ } else if (host === p) {
484
+ return true;
485
+ }
486
+ }
487
+
488
+ return false;
489
+ }
490
+
491
+ export function isValidSubscription(sub, customAllowedHosts = []) {
402
492
  return Boolean(
403
493
  sub
404
494
  && typeof sub.endpoint === 'string'
405
- && /^https:\/\//i.test(sub.endpoint)
495
+ && isAllowedPushEndpoint(sub.endpoint, customAllowedHosts)
406
496
  && sub.keys
407
497
  && typeof sub.keys.p256dh === 'string' && sub.keys.p256dh.length > 0
408
498
  && typeof sub.keys.auth === 'string' && sub.keys.auth.length > 0,
409
499
  );
410
500
  }
411
501
 
412
- export async function addSubscription(statePath, sub) {
413
- if (!isValidSubscription(sub))
414
- throw new Error('invalid push subscription (need https endpoint and keys.p256dh/auth)');
502
+ export async function addSubscription(statePath, sub, allowedHosts = [], maxSubscriptions = 100) {
503
+ if (!isValidSubscription(sub, allowedHosts))
504
+ throw new Error('invalid push subscription (need https endpoint from allowed push service and keys.p256dh/auth)');
415
505
  const state = await loadState(statePath);
416
506
  const clean = { endpoint: sub.endpoint, keys: { p256dh: sub.keys.p256dh, auth: sub.keys.auth }, addedAt: Date.now() };
417
507
  const existing = state.subscriptions.findIndex((s) => s.endpoint === clean.endpoint);
418
- if (existing >= 0) state.subscriptions[existing] = clean;
419
- else state.subscriptions.push(clean);
508
+ if (existing >= 0) {
509
+ state.subscriptions[existing] = clean;
510
+ } else {
511
+ if (typeof maxSubscriptions === 'number' && maxSubscriptions > 0 && state.subscriptions.length >= maxSubscriptions) {
512
+ throw new Error(`push subscriptions limit reached (max ${maxSubscriptions})`);
513
+ }
514
+ state.subscriptions.push(clean);
515
+ }
420
516
  await saveState(statePath, state);
421
517
  return state.subscriptions.length;
422
518
  }
@@ -490,6 +586,11 @@ export function deliverWebPush(ctx, n, config = {}) {
490
586
  const payload = JSON.stringify(webPushPayload(n, config));
491
587
  let sent = 0;
492
588
  for (const sub of subscriptions) {
589
+ if (!isValidSubscription(sub, wp.allowedHosts)) {
590
+ await removeSubscription(statePath, sub.endpoint);
591
+ ctx?.logger?.warn?.(`[plugin-notify] pruned disallowed push subscription (${maskEndpoint(sub.endpoint)})`);
592
+ continue;
593
+ }
493
594
  try {
494
595
  await webpush.sendNotification(sub, payload, {
495
596
  vapidDetails: {
@@ -505,7 +606,8 @@ export function deliverWebPush(ctx, n, config = {}) {
505
606
  await removeSubscription(statePath, sub.endpoint);
506
607
  ctx?.logger?.info?.(`[plugin-notify] pruned dead push subscription (${error.statusCode})`);
507
608
  } else {
508
- ctx?.logger?.warn?.(`[plugin-notify] web push send failed: ${String(error)}`);
609
+ const maskedErr = String(error?.message || error).replace(/https:\/\/[^\s"'`]+/g, (m) => maskEndpoint(m));
610
+ ctx?.logger?.warn?.(`[plugin-notify] web push send failed: ${maskedErr}`);
509
611
  }
510
612
  }
511
613
  }
@@ -612,7 +714,7 @@ export async function handlePushSubscribe(req, res, targetCtx, getConfig) {
612
714
  }
613
715
  const statePath = resolveStatePath(cfg.webPush || {});
614
716
  try {
615
- const count = await addSubscription(statePath, sub);
717
+ const count = await addSubscription(statePath, sub, cfg.webPush?.allowedHosts);
616
718
  targetCtx?.logger?.info?.(`[plugin-notify] push subscribed (${count} total)`);
617
719
  writeJson(res, 200, { ok: true, count });
618
720
  } catch (error) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodandready/dsh-plugin-notify",
3
- "version": "0.4.5",
3
+ "version": "0.4.6",
4
4
  "description": "DSH plugin: audio chimes, cross-session toasts, desktop push, IM webhooks, and web push (PWA) for turn completion, errors, and approvals.",
5
5
  "license": "MIT",
6
6
  "type": "module",