domma-cms 1.10.30 → 1.10.31
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 +4 -0
- package/CLAUDE.md +1 -1
- package/docs/plugin-development.md +1 -0
- package/package.json +1 -1
- package/server/services/outbound.js +59 -0
- package/server/services/plugins.js +4 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [1.10.31] - 2026-10-26
|
|
4
|
+
|
|
5
|
+
- **Plugins can fetch user-chosen addresses safely.** `hooks.safeFetch(url, opts)` hands plugins the CMS's own outbound guard: public addresses only, the connection pinned to the checked address, redirects re-checked, ports 80 and 443, and the site's `outboundAllowPrivate` rule (ignored on hosted sites). Through the hook a plugin cannot pass in its own policy or resolver, and may raise the size, time and redirect limits only to 25 MB, 60 s and 5. It guards against plugin mistakes; on hosted sites plugin signing is what keeps out hostile code.
|
|
6
|
+
|
|
3
7
|
## [1.10.30] - 2026-10-26
|
|
4
8
|
|
|
5
9
|
- Admin navigation keeps My Profile in the top account menu, removing its redundant System entry. Search defaults to Content, and existing System entries move there once while retaining saved settings and renamed folders.
|
package/CLAUDE.md
CHANGED
|
@@ -132,7 +132,7 @@ Collection entry I/O is delegated to a storage adapter. Schema management is alw
|
|
|
132
132
|
`$graphLookup`, server-state stages, a `$lookup` not into a site `cms_<slug>` collection) and `__proto__`/`constructor`/
|
|
133
133
|
`prototype`. A new path that hands user input to `find`/`aggregate` must go through one of the two
|
|
134
134
|
- **Outbound requests to user-chosen URLs go through `services/outbound.js` `safeFetch()`** (Actions' webhook step,
|
|
135
|
-
form webhooks; plugins
|
|
135
|
+
form webhooks; plugins use `hooks.safeFetch` - `pluginSafeFetch()`, which drops any policy/resolver they pass) - never bare `fetch`. Public addresses only after DNS, the
|
|
136
136
|
connection pinned to the checked address, never this server's own addresses, redirects re-checked, ports 80/443.
|
|
137
137
|
`server.json` `outboundAllowPrivate` (`true` or a list of hosts/addresses/CIDRs) lifts it for self-hosters and is
|
|
138
138
|
ignored when `codePolicy.signedOnly()`. `wpImport/fetch.js` shares its address rules and pinned request
|
|
@@ -356,6 +356,7 @@ roles and notifications are stamped with it). The sections below cover the main
|
|
|
356
356
|
| `registerNotificationSource({id, label, severity, defaultOn, every, atBoot, check, audience})` | A switchable notification source, optionally scheduled (`boot`, `15m`, `hourly`, `daily`) (0.80.0) |
|
|
357
357
|
| `registerLoginCheck({id, phase, check, verify?})` | A sign-in check: `phase: 'before'` (e.g. lockout, before the password is checked) or `'after'` (e.g. two-factor, can ask one more question) (0.83.0) |
|
|
358
358
|
| `registerPasswordCheck({id, check})` | A password rule applied wherever a password is set (0.83.0) |
|
|
359
|
+
| `safeFetch(url, {method, headers, body, timeoutMs, maxBytes, maxRedirects, truncate, signal})` | Fetch an address a site user chose, under the site's outbound rules (`services/outbound.js`): public addresses only, the connection pinned to the checked address, redirects re-checked, ports 80/443; `outboundAllowPrivate` applies on a self-hosted site and never on a signed-only one. Limits default to 1 MB / 15 s / 3 redirects and may be raised to 25 MB / 60 s / 5. Resolves `{ok, status, headers, buffer, text(), json(), url, truncated}`; a refusal rejects with `err.code === 'EREFUSED_SSRF'`. `body` is a string, Buffer or Uint8Array and `headers` a plain object or `Headers`; anything else rejects with a TypeError rather than going out empty. Use it instead of `fetch` for any user-chosen URL. Call it with `?.` - older cores lack it (1.10.31) |
|
|
359
360
|
| `registerComponent({name, source})` | Add a Domma component from a `.dmc` source string, used on pages as `<dm-name>` or `[component]`; it is listed read-only in Components (0.92.2) |
|
|
360
361
|
|
|
361
362
|
`onEnable` / `onDisable` exports receive `{fastify, services, hooks}` where `hooks` holds only the role and notification
|
package/package.json
CHANGED
|
@@ -392,3 +392,62 @@ export async function safeFetch(url, opts = {}) {
|
|
|
392
392
|
}
|
|
393
393
|
throw new FetchRefused('Too many redirects.');
|
|
394
394
|
}
|
|
395
|
+
|
|
396
|
+
/** The most a plugin may raise safeFetch's limits to. */
|
|
397
|
+
export const PLUGIN_FETCH_CAPS = Object.freeze({maxBytes: 25 * 1024 * 1024, timeoutMs: 60_000, maxRedirects: 5});
|
|
398
|
+
|
|
399
|
+
/** A Headers instance or a plain object, as a plain object; anything else is refused. */
|
|
400
|
+
function pluginHeaders(h) {
|
|
401
|
+
if (h == null) return undefined;
|
|
402
|
+
if (typeof Headers !== 'undefined' && h instanceof Headers) return Object.fromEntries(h.entries());
|
|
403
|
+
if (typeof h === 'object' && !Array.isArray(h)) return h;
|
|
404
|
+
throw new TypeError('safeFetch: headers must be an object or a Headers instance.');
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
/** A string, Buffer or byte array; anything else is refused rather than sent empty. */
|
|
408
|
+
function pluginBody(b) {
|
|
409
|
+
if (b == null) return undefined;
|
|
410
|
+
if (typeof b === 'string' || Buffer.isBuffer(b)) return b;
|
|
411
|
+
if (b instanceof Uint8Array) return Buffer.from(b.buffer, b.byteOffset, b.byteLength);
|
|
412
|
+
throw new TypeError('safeFetch: body must be a string, Buffer or Uint8Array.');
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
/**
|
|
416
|
+
* The options a plugin's safeFetch call keeps: the request's own. `policy`,
|
|
417
|
+
* `lookup` and `own` are dropped - the site's policy is always read fresh (so
|
|
418
|
+
* `outboundAllowPrivate` still applies on a self-hosted site and is still
|
|
419
|
+
* ignored on a signed-only one) and a plugin cannot hand in its own resolver.
|
|
420
|
+
* The limits may be raised up to PLUGIN_FETCH_CAPS, never past them.
|
|
421
|
+
*
|
|
422
|
+
* @param {object} [opts] - method, headers, body, timeoutMs, maxBytes, maxRedirects, truncate, signal
|
|
423
|
+
* @returns {object}
|
|
424
|
+
*/
|
|
425
|
+
export function pluginFetchOptions(opts = {}) {
|
|
426
|
+
const o = opts && typeof opts === 'object' ? opts : {};
|
|
427
|
+
const capped = (key) => {
|
|
428
|
+
const n = Number(o[key]);
|
|
429
|
+
return o[key] != null && Number.isFinite(n) && n >= 0 ? Math.min(n, PLUGIN_FETCH_CAPS[key]) : undefined;
|
|
430
|
+
};
|
|
431
|
+
return {
|
|
432
|
+
method: typeof o.method === 'string' ? o.method : undefined,
|
|
433
|
+
headers: pluginHeaders(o.headers),
|
|
434
|
+
body: pluginBody(o.body),
|
|
435
|
+
timeoutMs: capped('timeoutMs'),
|
|
436
|
+
maxBytes: capped('maxBytes'),
|
|
437
|
+
maxRedirects: capped('maxRedirects'),
|
|
438
|
+
truncate: o.truncate === true,
|
|
439
|
+
signal: o.signal instanceof AbortSignal ? o.signal : undefined
|
|
440
|
+
};
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
/**
|
|
444
|
+
* `hooks.safeFetch(url, opts)` as plugins get it - see pluginFetchOptions().
|
|
445
|
+
* A refusal rejects with FetchRefused (`err.code === 'EREFUSED_SSRF'`); a body or
|
|
446
|
+
* headers of the wrong type reject with a TypeError.
|
|
447
|
+
*
|
|
448
|
+
* @param {string} url
|
|
449
|
+
* @param {object} [opts]
|
|
450
|
+
*/
|
|
451
|
+
export async function pluginSafeFetch(url, opts = {}) {
|
|
452
|
+
return safeFetch(String(url), pluginFetchOptions(opts));
|
|
453
|
+
}
|
|
@@ -39,6 +39,7 @@ import {registerThemeProvider} from './themeSettings.js';
|
|
|
39
39
|
import {classifyEntitlement, mayLoad} from './pluginEntitlement.js';
|
|
40
40
|
import {notifierFor} from './notify.js';
|
|
41
41
|
import {disableInstantCalls, enableInstantCalls, grantInstantCallsOnce} from './instantCalls.js';
|
|
42
|
+
import {pluginSafeFetch} from './outbound.js';
|
|
42
43
|
import {disableDashboardPro, enableDashboardPro, registerWidget, unregisterWidgetsFrom} from './widgets.js';
|
|
43
44
|
import {createCollection, getCollection} from './collections.js';
|
|
44
45
|
import * as defaultRolesService from './roles.js';
|
|
@@ -570,7 +571,9 @@ export async function registerPlugins(fastify) {
|
|
|
570
571
|
// Who may read a contact that is not their own (Contacts Pro's sharing) - hooks.js 7b.
|
|
571
572
|
registerContactAccess: (fn) => registerContactAccess(manifest.name, fn),
|
|
572
573
|
// Direct messages between team members (Messages) - hooks.js 7c; a call's "Add people" uses it.
|
|
573
|
-
registerDirectMessenger: (fn) => registerDirectMessenger(manifest.name, fn)
|
|
574
|
+
registerDirectMessenger: (fn) => registerDirectMessenger(manifest.name, fn),
|
|
575
|
+
// Fetching an address a site user chose (services/outbound.js) - the site's policy, never the plugin's.
|
|
576
|
+
safeFetch: pluginSafeFetch},
|
|
574
577
|
settings,
|
|
575
578
|
config: {}
|
|
576
579
|
});
|