@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 +8 -0
- package/README.md +1 -0
- package/README.ru.md +1 -0
- package/README.zh.md +1 -0
- package/lib/index.js +111 -9
- package/package.json +1 -1
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
|
-
|
|
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
|
-
&&
|
|
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)
|
|
419
|
-
|
|
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
|
-
|
|
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.
|
|
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",
|