apostrophe 4.31.0-alpha.1 → 4.31.1-beta.1
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/.claude/settings.local.json +15 -0
- package/CHANGELOG.md +26 -60
- package/modules/@apostrophecms/file/index.js +9 -8
- package/modules/@apostrophecms/image/ui/apos/components/AposMediaUploader.vue +3 -0
- package/modules/@apostrophecms/oembed/index.js +45 -12
- package/modules/@apostrophecms/ui/ui/apos/scss/global/_inputs.scss +2 -0
- package/modules/@apostrophecms/util/index.js +17 -0
- package/package.json +7 -7
- package/test/add-missing-schema-fields-project/node_modules/.package-lock.json +131 -0
- package/test/files.js +28 -0
- package/test/oembed.js +98 -0
- package/test/utils.js +103 -0
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
{
|
|
2
|
+
"permissions": {
|
|
3
|
+
"allow": [
|
|
4
|
+
"Bash(timeout 180 npx mocha:*)",
|
|
5
|
+
"Bash(timeout 600 npx mocha:*)",
|
|
6
|
+
"Bash(npm ls:*)",
|
|
7
|
+
"Bash(timeout 540 npx mocha:*)",
|
|
8
|
+
"Bash(echo:*)",
|
|
9
|
+
"Bash(timeout 10 node:*)",
|
|
10
|
+
"Bash(timeout 300 npx mocha:*)",
|
|
11
|
+
"Bash(timeout 60 npx mocha:*)",
|
|
12
|
+
"Bash(timeout 120 npx mocha:*)"
|
|
13
|
+
]
|
|
14
|
+
}
|
|
15
|
+
}
|
package/CHANGELOG.md
CHANGED
|
@@ -1,70 +1,36 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
-
## 4.31.
|
|
3
|
+
## 4.31.1-beta.1
|
|
4
4
|
|
|
5
|
-
###
|
|
5
|
+
### Fixes
|
|
6
|
+
|
|
7
|
+
The `@apostrophecms/oembed` module now caches failed lookups in addition to successful ones. Previously only successes were cached, so a bad or removed URL (such as a deleted YouTube video) could be requested over and over, triggering provider rate-limiting (e.g. YouTube 429 errors) and temporary lockouts. Cached entries written by older versions of Apostrophe are still tolerated, so no cache clearing is required when upgrading. In addition, the underlying error is now logged (via structured logging) before the higher-level "Video URL invalid" error is thrown, making the original cause easier to diagnose.
|
|
8
|
+
|
|
9
|
+
## 4.31.0 (2026-06-10)
|
|
10
|
+
|
|
11
|
+
### Adds
|
|
6
12
|
|
|
7
|
-
-
|
|
8
|
-
-
|
|
9
|
-
-
|
|
10
|
-
-
|
|
13
|
+
- Added support for `draggable: false` on non-inline `array` schema fields. Previously this option was only respected when `inline: true`. When set on a standard (modal-based) array field, drag-and-drop reordering and keyboard reordering are now disabled in the array editor's slat list.
|
|
14
|
+
- Introduced support for postgres://, sqlite://, and multipostgres:// database URIs in addition to mongodb://. The new db-connect API supports all of the database operations currently used in our own core, pro and multisite modules. For more information see the documentation.
|
|
15
|
+
- JSX support for templates within ApostropheCMS. JSX is now co-equal with Nunjucks, with a gradual migration strategy. Anyone who is familiar with React will be very comfortable writing JSX templates, which also offer a superior debugging experience, and templates can be migrated gradually. JSX is a great option for those who don't wish to create parallel Astro and ApostropheCMS projects, but still prefer a modern syntax. For more information, see the new [JSX templates guide](https://apostrophecms.com/docs/guide/jsx-templates.html).
|
|
16
|
+
- The session secret and the uploadfs `disabledFileKey` can now be supplied via the `APOS_SESSION_SECRET` and `APOS_UPLOADFS_DISABLED_FILE_KEY` environment variables. As with other Apostrophe environment variables, these take precedence over the corresponding `app.js` configuration.
|
|
11
17
|
|
|
18
|
+
### Fixes
|
|
19
|
+
|
|
20
|
+
- Fixed an issue where using the Tab key to navigate within modals could incorrectly jump focus to a wrong element instead of the next input field.
|
|
12
21
|
Fixed Tab navigation escaping out of modals when the form contained hidden sections or elements that became disabled after editing.
|
|
22
|
+
- Fixed adding or removing an area field from a schema breaking existing documents on an external front such as Astro.
|
|
23
|
+
- For Astro: `AposArea` now renders only schema-backed areas. A missing area no longer throws, and an area orphaned by removing its field from the schema (while its content remains in the document) renders nothing instead of breaking sibling areas in edit mode. Logged-in editors get a diagnostic message in place of an orphaned area; anonymous visitors see nothing.
|
|
24
|
+
- Editable documents sent to an external front (Asgtro) now materialize empty area objects for schema area fields added after the document was created, so they can be edited in context.
|
|
25
|
+
- `apos.util.getManagerOf` accepts a `{ log }` option to suppress its error log when probing objects that may not have a manager.
|
|
26
|
+
- Fix more admin UI a11y issues.
|
|
27
|
+
- Selecting an item in a relationship "browse" dialog no longer scrolls the title and Cancel/Select buttons out of view when the item is far down the list.
|
|
28
|
+
- Sites with a custom filterByIndexPage method no longer experience failures in the sitemap module and potential creeping CPU performance penalties. A regression introduced with our static site support, but not specific to static sites.
|
|
29
|
+
|
|
30
|
+
### Security
|
|
13
31
|
|
|
14
|
-
-
|
|
15
|
-
-
|
|
16
|
-
|
|
17
|
-
- `AposArea` now renders only schema-backed areas. A missing area no longer throws, and an area orphaned by removing its field from the schema (while its content remains in the document) renders nothing instead of breaking sibling areas in edit mode. Logged-in editors get a diagnostic message in place of an orphaned area; anonymous visitors see nothing.
|
|
18
|
-
- Editable documents sent to an external front now materialize empty area objects for schema area fields added after the document was created, so they can be edited in context.
|
|
19
|
-
- `apos.util.getManagerOf` accepts a `{ log }` option to suppress its error log when probing objects that may not have a manager.
|
|
20
|
-
|
|
21
|
-
- bc8f7be: Editors can now control the layout-widget gap through the styles system, both site-wide via a global `layoutGap` preset and per widget via a `gap` styles field. New Layout widget option `className` allows for additional CSS class names to be added to the widget Grid container.
|
|
22
|
-
|
|
23
|
-
### Patch Changes
|
|
24
|
-
|
|
25
|
-
- 2e2f3b4: Accessibility: corrected ARIA semantics on the top admin navigation bar.
|
|
26
|
-
- 2e2f3b4: Accessibility: improvements to the document context title (admin bar middle group) and the underlying `AposContextMenu` machinery.
|
|
27
|
-
- 2e2f3b4: Accessibility: improve the locale switcher (`AposLocalePicker`).
|
|
28
|
-
- 2e2f3b4: Accessibility: the Recently Edited Documents tray icon (admin bar) now exposes its action through `aria-label`.
|
|
29
|
-
- 2e2f3b4: Accessibility: fix `.apos-sr-only` so screen-reader-only content is exposed to the accessibility tree.
|
|
30
|
-
- 2e2f3b4: Accessibility: icon-only context-utility buttons in the admin bar tray (e.g. the global settings cog) now expose their action through `aria-label`.
|
|
31
|
-
- 41670a3: Security fix: the password reset request feature will refuse to operate unless the baseUrl option or the APOS_BASE_URL environment variable has been set (note that this is automatic in multisite projects). This fix is necessary to prevent a vulnerability that can be used to convince ApostropheCMS to send emails containing links to other sites. It is only a vulnerability if you have enabled the passwordReset: true option for the login module. Thanks to [SPIDY](https://github.com/Mujahidkhan525) for reporting the issue.
|
|
32
|
-
- 9f458b5: Fix illegal HTML id attribute values generated by the admin UI
|
|
33
|
-
- 08845c5: - Removed duplicate <meta charset> tag from `outerLayoutBase.html`
|
|
34
|
-
- Standardized charset to utf-8 (the legacy configuration option is now ignored). Per the spec this is the only legal setting, so we classify this as a bug fix
|
|
35
|
-
- Altered unused/legacy i18n template helper to return `utf-8`, ensuring backwards compatibility
|
|
36
|
-
- b9b32bd: Keyboard shortcuts for widget operations (copy, cut, paste, duplicate, remove) no longer block the browser's native clipboard behavior when no widget is focused. Previously, selecting and copying text on a page while logged in was prevented by the admin UI intercepting those shortcuts unconditionally.
|
|
37
|
-
- 2191c8a: Fix more admin UI a11y issues
|
|
38
|
-
- 08dfcac: Security: a malicious full name containing HTML was executed as HTML in the tooltip displayed with an "i" icon next to the title of the current page, creating an
|
|
39
|
-
XSS attack risk versus other users. Since most projects permit users to change their full name (the "title" property), All projects with multiple users should be
|
|
40
|
-
updated promptly to close this vulnerability. Thanks to [Muhammad Uwais](https://github.com/MuhammadUwais) for reporting the issue.
|
|
41
|
-
- 5d1a028: Security: launder now uses and exports the best available naughtyHref function for detecting malicious URLs. sanitize-html now depends on it, and apostrophe now uses type: 'url' for the link URL field of image widgets, which leverages it. Prior to this fix, it was possible for any user with editing privileges, including a contributor, to trigger arbitrary JavaScript via a javascript: URL in the link URL field of an image widget. A migration has been included to strip any such malicious URLs already present in the database. All users of apostrophe are encouraged to upgrade to get this security fix. Thanks to [Muhammad Uwais](https://github.com/MuhammadUwais) for reporting the issue.
|
|
42
|
-
- 8098017: Security: the HTML import feature of the rich text widget no longer permits images to be fetched
|
|
43
|
-
from arbitrary hosts. This could be used to probe internal networks, and to exfiltrate images from
|
|
44
|
-
internal hosts if their URLs were known. Instead, the `imageImportAllowedHostnames` option of the
|
|
45
|
-
`@apostrophecms/rich-text-widget` must be configured to opt into that feature.
|
|
46
|
-
|
|
47
|
-
Thanks to [Yiğit Şengezer](https://github.com/yigitsengezer) and [Sainithin0309](https://github.com/Sainithin0309) for reporting this issue.
|
|
48
|
-
|
|
49
|
-
- b7f9ad5: Sites with a custom filterByIndexPage method no longer experience failures in the sitemap module and potential creeping CPU performance penalties. A regression introduced with our static site support, but not specific to static sites.
|
|
50
|
-
- b360b05: fixes issue where orderable table array items drag the entire floating window
|
|
51
|
-
- e9b3bac: apostrophe and oembetter have been updated to eliminate a number of services that formerly supported
|
|
52
|
-
oembed for the general public, but no longer do so. While there is no security risk today, removing
|
|
53
|
-
these ensures that if these domains are ever allowed to lapse, they do not become an XSS
|
|
54
|
-
attack vector in the future.
|
|
55
|
-
|
|
56
|
-
Because oembed responses are not always iframes, it is important that this list be maintained
|
|
57
|
-
over time. In addition, developers always have the option to prune it on their own by setting
|
|
58
|
-
the new minimumAllowlist and minimumEndpoints options of the @apostrophecms/oembed module.
|
|
59
|
-
|
|
60
|
-
Thanks to [Sainithin0309](https://github.com/Sainithin0309) for pointing out the potential
|
|
61
|
-
long-term security concern.
|
|
62
|
-
|
|
63
|
-
- Updated dependencies [862d760]
|
|
64
|
-
- Updated dependencies [5d1a028]
|
|
65
|
-
- Updated dependencies [8d4c882]
|
|
66
|
-
- sanitize-html@2.17.5
|
|
67
|
-
- launder@1.7.2
|
|
32
|
+
- Server-side prototype pollution (CWE-1321) via dot-notation paths. `apos.util.set()` and `apos.util.get()` now refuse to traverse `__proto__`, `constructor` and `prototype` path segments. Previously an authenticated editor could send a PATCH REST API request whose patch operators (for example `$pullAll` with a key of `__proto__.publicApiProjection`) wrote to `Object.prototype`. A polluted `publicApiProjection` defeated the `publicApiCheck()` authorization gate on piece-type REST endpoints for subsequent unauthenticated requests, for the lifetime of the Node.js process. All users should update. Thanks to [tonghuaroot](https://github.com/tonghuaroot), [H3xV0rT3x](https://github.com/H3xV0rT3x), and [5h1kh4r](https://github.com/5h1kh4r) for reporting the vulnerability.
|
|
33
|
+
- When `@apostrophecms/file` pretty URLs are enabled (`prettyUrls: true`), the upstream request used to serve the file is no longer built from the incoming `Host` header. The self-request is now resolved against the site's configured `baseUrl` (via `req.baseUrl`), falling back to the request host only when no `baseUrl` is configured. This closes a server-side request forgery (SSRF) vector in which the `Host` header could steer the proxied fetch at another host. The real-world risk was low: the path is constrained to an existing attachment's `/uploads/attachments/<cuid>-<slug>.<ext>`, and cuids are unique and immutable, so any reachable content was already public via the front door. Thanks to [EchoSkorJjj](https://github.com/EchoSkorJjj) for reporting the issue.
|
|
68
34
|
|
|
69
35
|
## 4.30.0
|
|
70
36
|
|
|
@@ -248,14 +248,15 @@ module.exports = {
|
|
|
248
248
|
const uglyUrl = self.apos.attachment.url(file.attachment, {
|
|
249
249
|
prettyUrl: false
|
|
250
250
|
});
|
|
251
|
-
//
|
|
252
|
-
//
|
|
253
|
-
//
|
|
254
|
-
// `
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
251
|
+
// `uglyUrl` may be relative (local uploadfs) or absolute
|
|
252
|
+
// (S3/CDN). For the relative case `streamProxy` resolves it
|
|
253
|
+
// against `req.baseUrl`, which reflects the configured
|
|
254
|
+
// `baseUrl` (or locale hostname) and only falls back to the
|
|
255
|
+
// request host when the site has none. We deliberately do
|
|
256
|
+
// not build the upstream URL from the raw `Host` header
|
|
257
|
+
// here, as that is attacker-controlled and would allow the
|
|
258
|
+
// self-request to be redirected to an arbitrary host (SSRF).
|
|
259
|
+
return await streamProxy(req, uglyUrl, { error: self.apos.util.error });
|
|
259
260
|
} catch (e) {
|
|
260
261
|
self.apos.util.error('Error in pretty URL route:', e);
|
|
261
262
|
return res.status(500).send('error');
|
|
@@ -252,6 +252,9 @@ export default {
|
|
|
252
252
|
@include apos-transition();
|
|
253
253
|
|
|
254
254
|
& {
|
|
255
|
+
// Contain the visually hidden (`.apos-sr-only`, position: absolute)
|
|
256
|
+
// file input so focusing it does not scroll a distant ancestor.
|
|
257
|
+
position: relative;
|
|
255
258
|
display: flex;
|
|
256
259
|
box-sizing: border-box;
|
|
257
260
|
align-items: center;
|
|
@@ -86,7 +86,9 @@ module.exports = {
|
|
|
86
86
|
// The `options` object is passed on to `oembetter.fetch`.
|
|
87
87
|
//
|
|
88
88
|
// Responses are automatically cached, by default for one hour. See the
|
|
89
|
-
// cacheLifetime option to the module.
|
|
89
|
+
// cacheLifetime option to the module. Failures are cached as well, so
|
|
90
|
+
// that a bad or removed URL does not lead to repeated requests that can
|
|
91
|
+
// trigger rate-limiting (e.g. YouTube 429 errors and lockouts).
|
|
90
92
|
async query(req, url, options) {
|
|
91
93
|
if (!options) {
|
|
92
94
|
options = {};
|
|
@@ -112,18 +114,44 @@ module.exports = {
|
|
|
112
114
|
throw self.apos.error('invalid', req.t('apostrophe:oembedVideoUrlInvalid'));
|
|
113
115
|
}
|
|
114
116
|
const key = url + ':' + JSON.stringify(options);
|
|
115
|
-
|
|
116
|
-
if (
|
|
117
|
-
|
|
117
|
+
const cached = await self.apos.cache.get('@apostrophecms/oembed', key);
|
|
118
|
+
if (cached !== undefined) {
|
|
119
|
+
// Cache entries are wrapped so that both successes and failures can
|
|
120
|
+
// be remembered. Caching failures prevents a bad or removed URL
|
|
121
|
+
// (such as a deleted YouTube video) from being requested over and
|
|
122
|
+
// over, which can trigger 429 rate-limiting and temporary lockouts.
|
|
123
|
+
//
|
|
124
|
+
// Legacy entries written before this wrapping existed are the raw
|
|
125
|
+
// oembed response object, so tolerate them by returning them as-is.
|
|
126
|
+
if (cached && cached.aposOembedCache) {
|
|
127
|
+
if (cached.error) {
|
|
128
|
+
throw self.apos.error('invalid', req.t('apostrophe:oembedVideoUrlInvalid'));
|
|
129
|
+
}
|
|
130
|
+
return cached.response;
|
|
131
|
+
}
|
|
132
|
+
return cached;
|
|
118
133
|
}
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
134
|
+
let response;
|
|
135
|
+
try {
|
|
136
|
+
if (options.alwaysIframe) {
|
|
137
|
+
response = await self.iframe(req, url, options);
|
|
138
|
+
} else {
|
|
123
139
|
response = await require('util').promisify(self.oembetter.fetch)(url, options);
|
|
124
|
-
} catch (err) {
|
|
125
|
-
throw self.apos.error('invalid', req.t('apostrophe:oembedVideoUrlInvalid'));
|
|
126
140
|
}
|
|
141
|
+
} catch (err) {
|
|
142
|
+
// Log the underlying cause first, since the higher-level error
|
|
143
|
+
// thrown below would otherwise obscure it.
|
|
144
|
+
self.logError(req, 'query-failed', err.message, {
|
|
145
|
+
url,
|
|
146
|
+
stack: err.stack
|
|
147
|
+
});
|
|
148
|
+
// Cache the failure too, so we don't keep hammering the provider
|
|
149
|
+
// for a URL that is currently failing.
|
|
150
|
+
await self.apos.cache.set('@apostrophecms/oembed', key, {
|
|
151
|
+
aposOembedCache: true,
|
|
152
|
+
error: true
|
|
153
|
+
}, self.options.cacheLifetime);
|
|
154
|
+
throw self.apos.error('invalid', req.t('apostrophe:oembedVideoUrlInvalid'));
|
|
127
155
|
}
|
|
128
156
|
// Make non-secure URLs protocol relative and
|
|
129
157
|
// let the browser upgrade them to https if needed
|
|
@@ -137,8 +165,13 @@ module.exports = {
|
|
|
137
165
|
if (response.html) {
|
|
138
166
|
response.html = makeProtocolRelative(response.html);
|
|
139
167
|
}
|
|
140
|
-
//
|
|
141
|
-
|
|
168
|
+
// Cache the successful response (by default for one hour; see the
|
|
169
|
+
// cacheLifetime option).
|
|
170
|
+
await self.apos.cache.set('@apostrophecms/oembed', key, {
|
|
171
|
+
aposOembedCache: true,
|
|
172
|
+
error: false,
|
|
173
|
+
response
|
|
174
|
+
}, self.options.cacheLifetime);
|
|
142
175
|
return response;
|
|
143
176
|
},
|
|
144
177
|
// Not currently used. Present for backwards compatibility
|
|
@@ -35,6 +35,13 @@ const util = require('util');
|
|
|
35
35
|
const { stripIndent } = require('common-tags');
|
|
36
36
|
const glob = require('../../../lib/glob.js');
|
|
37
37
|
|
|
38
|
+
// Dot-path segments that must never be traversed when walking a
|
|
39
|
+
// user-supplied path in `apos.util.get` and `apos.util.set`. Following any
|
|
40
|
+
// of these reaches the prototype chain and enables server-side prototype
|
|
41
|
+
// pollution (CWE-1321), e.g. a PATCH `$pullAll` key of
|
|
42
|
+
// `__proto__.publicApiProjection`.
|
|
43
|
+
const unsafePathSegments = new Set([ '__proto__', 'constructor', 'prototype' ]);
|
|
44
|
+
|
|
38
45
|
module.exports = {
|
|
39
46
|
options: {
|
|
40
47
|
alias: 'util',
|
|
@@ -755,6 +762,10 @@ module.exports = {
|
|
|
755
762
|
if (o == null) {
|
|
756
763
|
return undefined;
|
|
757
764
|
}
|
|
765
|
+
if (unsafePathSegments.has(p)) {
|
|
766
|
+
// Never read through the prototype chain (CWE-1321)
|
|
767
|
+
return undefined;
|
|
768
|
+
}
|
|
758
769
|
o = o[p];
|
|
759
770
|
}
|
|
760
771
|
}
|
|
@@ -819,6 +830,12 @@ module.exports = {
|
|
|
819
830
|
}
|
|
820
831
|
}
|
|
821
832
|
path = path.split('.');
|
|
833
|
+
for (p of path) {
|
|
834
|
+
if (unsafePathSegments.has(p)) {
|
|
835
|
+
// Refuse to write through the prototype chain (CWE-1321)
|
|
836
|
+
throw self.apos.error('invalid', `Unsafe property name "${p}" in dot path`);
|
|
837
|
+
}
|
|
838
|
+
}
|
|
822
839
|
for (i = 0; (i < (path.length - 1)); i++) {
|
|
823
840
|
p = path[i];
|
|
824
841
|
o = o[p];
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "apostrophe",
|
|
3
|
-
"version": "4.31.
|
|
3
|
+
"version": "4.31.1-beta.1",
|
|
4
4
|
"description": "The Apostrophe Content Management System.",
|
|
5
5
|
"main": "index.js",
|
|
6
6
|
"repository": {
|
|
@@ -120,15 +120,15 @@
|
|
|
120
120
|
"webpack": "^5.106.1",
|
|
121
121
|
"webpack-merge": "^5.7.3",
|
|
122
122
|
"xregexp": "^2.0.0",
|
|
123
|
-
"
|
|
123
|
+
"@apostrophecms/db-connect": "^1.0.1",
|
|
124
124
|
"broadband": "^1.1.0",
|
|
125
|
+
"boring": "^1.1.1",
|
|
125
126
|
"express-cache-on-demand": "^1.0.4",
|
|
126
|
-
"launder": "^1.7.
|
|
127
|
-
"oembetter": "^1.2.0",
|
|
128
|
-
"sanitize-html": "^2.17.5-alpha.1",
|
|
127
|
+
"launder": "^1.7.1",
|
|
129
128
|
"postcss-viewport-to-container-toggle": "^2.3.0",
|
|
130
|
-
"
|
|
131
|
-
"
|
|
129
|
+
"oembetter": "^1.2.0",
|
|
130
|
+
"sanitize-html": "^2.17.5",
|
|
131
|
+
"uploadfs": "^1.26.1"
|
|
132
132
|
},
|
|
133
133
|
"devDependencies": {
|
|
134
134
|
"chai": "^4.3.10",
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "add-missing-schema-fields-project",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"lockfileVersion": 3,
|
|
5
|
+
"requires": true,
|
|
6
|
+
"packages": {
|
|
7
|
+
"../..": {
|
|
8
|
+
"version": "4.28.0",
|
|
9
|
+
"license": "MIT",
|
|
10
|
+
"dependencies": {
|
|
11
|
+
"@apostrophecms/emulate-mongo-3-driver": "workspace:^",
|
|
12
|
+
"@apostrophecms/vue-material-design-icons": "^1.0.0",
|
|
13
|
+
"@ctrl/tinycolor": "^4.1.0",
|
|
14
|
+
"@floating-ui/dom": "^1.5.3",
|
|
15
|
+
"@opentelemetry/api": "^1.9.0",
|
|
16
|
+
"@opentelemetry/semantic-conventions": "^1.0.1",
|
|
17
|
+
"@paralleldrive/cuid2": "^2.2.2",
|
|
18
|
+
"@tiptap/extension-color": "^2.4.0",
|
|
19
|
+
"@tiptap/extension-floating-menu": "^2.0.3",
|
|
20
|
+
"@tiptap/extension-highlight": "^2.0.3",
|
|
21
|
+
"@tiptap/extension-link": "^2.0.3",
|
|
22
|
+
"@tiptap/extension-placeholder": "^2.0.3",
|
|
23
|
+
"@tiptap/extension-subscript": "^2.0.3",
|
|
24
|
+
"@tiptap/extension-superscript": "^2.0.3",
|
|
25
|
+
"@tiptap/extension-table": "^2.0.3",
|
|
26
|
+
"@tiptap/extension-table-cell": "^2.0.3",
|
|
27
|
+
"@tiptap/extension-table-header": "^2.0.3",
|
|
28
|
+
"@tiptap/extension-table-row": "^2.0.3",
|
|
29
|
+
"@tiptap/extension-text-align": "^2.0.3",
|
|
30
|
+
"@tiptap/extension-text-style": "^2.0.3",
|
|
31
|
+
"@tiptap/extension-underline": "^2.0.3",
|
|
32
|
+
"@tiptap/starter-kit": "^2.0.3",
|
|
33
|
+
"@tiptap/vue-3": "^2.0.3",
|
|
34
|
+
"@vue/compiler-sfc": "^3.3.8",
|
|
35
|
+
"autoprefixer": "^10.4.1",
|
|
36
|
+
"bluebird": "^3.7.2",
|
|
37
|
+
"body-parser": "^1.18.2",
|
|
38
|
+
"boring": "workspace:^",
|
|
39
|
+
"broadband": "workspace:^",
|
|
40
|
+
"cheerio": "^1.0.0-rc.10",
|
|
41
|
+
"chokidar": "^3.5.2",
|
|
42
|
+
"common-tags": "^1.8.0",
|
|
43
|
+
"concat-with-sourcemaps": "^1.1.0",
|
|
44
|
+
"connect-mongo": "^5.1.0",
|
|
45
|
+
"cookie-parser": "^1.4.5",
|
|
46
|
+
"cors": "^2.8.5",
|
|
47
|
+
"css-loader": "^5.2.4",
|
|
48
|
+
"cssnano": "^7.1.1",
|
|
49
|
+
"csv-parse": "^5.6.0",
|
|
50
|
+
"dayjs": "^1.9.8",
|
|
51
|
+
"dompurify": "^3.2.5",
|
|
52
|
+
"encodeurl": "^2.0.0",
|
|
53
|
+
"express": "^4.16.4",
|
|
54
|
+
"express-bearer-token": "^3.0.0",
|
|
55
|
+
"express-cache-on-demand": "workspace:^",
|
|
56
|
+
"express-session": "^1.18.2",
|
|
57
|
+
"fs-extra": "^7.0.1",
|
|
58
|
+
"glob": "^10.4.5",
|
|
59
|
+
"he": "^1.2.0",
|
|
60
|
+
"html-to-text": "^9.0.5",
|
|
61
|
+
"i18next": "^20.3.2",
|
|
62
|
+
"i18next-http-middleware": "^3.1.5",
|
|
63
|
+
"import-fresh": "^3.3.0",
|
|
64
|
+
"is-wsl": "^2.2.0",
|
|
65
|
+
"jsdom": "^24.1.0",
|
|
66
|
+
"klona": "^2.0.4",
|
|
67
|
+
"launder": "^1.4.0",
|
|
68
|
+
"lodash": "^4.17.21",
|
|
69
|
+
"mini-css-extract-plugin": "^1.6.0",
|
|
70
|
+
"minimatch": "^3.0.4",
|
|
71
|
+
"mkdirp": "^0.5.5",
|
|
72
|
+
"multer": "^2.0.2",
|
|
73
|
+
"node-fetch": "^2.6.1",
|
|
74
|
+
"nodemailer": "^7.0.10",
|
|
75
|
+
"nunjucks": "^3.2.1",
|
|
76
|
+
"oembetter": "^1.1.3",
|
|
77
|
+
"parseurl": "^1.3.3",
|
|
78
|
+
"passport": "^0.6.0",
|
|
79
|
+
"passport-local": "^1.0.0",
|
|
80
|
+
"path-to-regexp": "^1.8.0",
|
|
81
|
+
"performance-now": "^2.1.0",
|
|
82
|
+
"pinia": "^2.1.7",
|
|
83
|
+
"postcss": "^8.4.47",
|
|
84
|
+
"postcss-html": "^1.3.0",
|
|
85
|
+
"postcss-loader": "^8.1.1",
|
|
86
|
+
"postcss-scss": "^4.0.3",
|
|
87
|
+
"postcss-viewport-to-container-toggle": "workspace:^",
|
|
88
|
+
"prompts": "^2.4.1",
|
|
89
|
+
"qs": "^6.10.1",
|
|
90
|
+
"regexp-quote": "0.0.0",
|
|
91
|
+
"resolve": "^1.19.0",
|
|
92
|
+
"resolve-from": "^5.0.0",
|
|
93
|
+
"sanitize-html": "workspace:^",
|
|
94
|
+
"sass": "^1.80.3",
|
|
95
|
+
"sass-loader": "^16.0.0",
|
|
96
|
+
"server-destroy": "^1.0.1",
|
|
97
|
+
"sluggo": "^1.0.0",
|
|
98
|
+
"sortablejs": "^1.15.0",
|
|
99
|
+
"sortablejs-vue3": "^1.2.11",
|
|
100
|
+
"tiny-emitter": "^2.1.0",
|
|
101
|
+
"tough-cookie": "^4.0.0",
|
|
102
|
+
"underscore.string": "^3.3.4",
|
|
103
|
+
"uploadfs": "workspace:^",
|
|
104
|
+
"void-elements": "^3.1.0",
|
|
105
|
+
"vue": "^3.5.20",
|
|
106
|
+
"vue-advanced-cropper": "^2.8.8",
|
|
107
|
+
"vue-loader": "^17.1.0",
|
|
108
|
+
"vue-style-loader": "^4.1.3",
|
|
109
|
+
"webpack": "^5.72.0",
|
|
110
|
+
"webpack-merge": "^5.7.3",
|
|
111
|
+
"xregexp": "^2.0.0"
|
|
112
|
+
},
|
|
113
|
+
"devDependencies": {
|
|
114
|
+
"eslint": "^9.39.1",
|
|
115
|
+
"eslint-config-apostrophe": "workspace:^",
|
|
116
|
+
"form-data": "^4.0.4",
|
|
117
|
+
"mocha": "^11.7.1",
|
|
118
|
+
"nyc": "^17.1.0",
|
|
119
|
+
"stylelint": "^16.5.0",
|
|
120
|
+
"stylelint-config-apostrophe": "workspace:^"
|
|
121
|
+
},
|
|
122
|
+
"engines": {
|
|
123
|
+
"node": ">=16.0.0"
|
|
124
|
+
}
|
|
125
|
+
},
|
|
126
|
+
"node_modules/apostrophe": {
|
|
127
|
+
"resolved": "../..",
|
|
128
|
+
"link": true
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
}
|
package/test/files.js
CHANGED
|
@@ -132,6 +132,34 @@ describe('Files', function() {
|
|
|
132
132
|
}
|
|
133
133
|
});
|
|
134
134
|
|
|
135
|
+
it('should ignore a spoofed Host header when proxying the pretty URL (SSRF regression)', async function() {
|
|
136
|
+
const req = apos.task.getAnonReq();
|
|
137
|
+
try {
|
|
138
|
+
apos.file.options.prettyUrls = true;
|
|
139
|
+
const files = await apos.file.find(req).toArray();
|
|
140
|
+
assert.strictEqual(files.length, 1);
|
|
141
|
+
const file = files[0];
|
|
142
|
+
const attachment = apos.attachment.first(file);
|
|
143
|
+
const url = apos.attachment.url(attachment);
|
|
144
|
+
assert(url);
|
|
145
|
+
// Send an attacker-controlled Host header (e.g. the cloud metadata
|
|
146
|
+
// address from the advisory). The upstream fetch must be resolved
|
|
147
|
+
// against the server-trusted baseUrl, not this header, so the
|
|
148
|
+
// legitimate content is still served and the request is never
|
|
149
|
+
// steered at the spoofed host.
|
|
150
|
+
const response = await apos.http.get(url, {
|
|
151
|
+
headers: {
|
|
152
|
+
Host: '169.254.169.254'
|
|
153
|
+
},
|
|
154
|
+
fullResponse: true
|
|
155
|
+
});
|
|
156
|
+
assert.strictEqual(response.status, 200);
|
|
157
|
+
assert.strictEqual(response.body, attachment.data);
|
|
158
|
+
} finally {
|
|
159
|
+
apos.file.options.prettyUrls = false;
|
|
160
|
+
}
|
|
161
|
+
});
|
|
162
|
+
|
|
135
163
|
});
|
|
136
164
|
|
|
137
165
|
describe('Files with i18n locale prefixes', function() {
|
package/test/oembed.js
CHANGED
|
@@ -53,4 +53,102 @@ describe('Oembed', function() {
|
|
|
53
53
|
// const data = JSON.parse(response.body);
|
|
54
54
|
// assert(data.type === 'video');
|
|
55
55
|
// });
|
|
56
|
+
|
|
57
|
+
it('should cache successful responses and not re-fetch them', async function() {
|
|
58
|
+
const req = apos.task.getReq();
|
|
59
|
+
const url = 'https://example.com/good-video';
|
|
60
|
+
let calls = 0;
|
|
61
|
+
const original = apos.oembed.oembetter.fetch;
|
|
62
|
+
apos.oembed.oembetter.fetch = (fetchUrl, options, cb) => {
|
|
63
|
+
calls++;
|
|
64
|
+
return cb(null, {
|
|
65
|
+
type: 'video',
|
|
66
|
+
title: 'Success',
|
|
67
|
+
html: '<iframe src="//example.com/embed"></iframe>'
|
|
68
|
+
});
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
try {
|
|
72
|
+
const first = await apos.oembed.query(req, url);
|
|
73
|
+
assert.strictEqual(first.title, 'Success');
|
|
74
|
+
assert.strictEqual(calls, 1);
|
|
75
|
+
|
|
76
|
+
// Second call must be served from the cache, not re-fetched
|
|
77
|
+
const second = await apos.oembed.query(req, url);
|
|
78
|
+
assert.strictEqual(second.title, 'Success');
|
|
79
|
+
assert.strictEqual(calls, 1);
|
|
80
|
+
} finally {
|
|
81
|
+
apos.oembed.oembetter.fetch = original;
|
|
82
|
+
}
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it('should cache failures so a bad URL is not requested repeatedly, and log the original error', async function() {
|
|
86
|
+
const req = apos.task.getReq();
|
|
87
|
+
const url = 'https://example.com/bad-video';
|
|
88
|
+
let calls = 0;
|
|
89
|
+
const original = apos.oembed.oembetter.fetch;
|
|
90
|
+
apos.oembed.oembetter.fetch = (fetchUrl, options, cb) => {
|
|
91
|
+
calls++;
|
|
92
|
+
return cb(new Error('simulated provider failure'));
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
// Capture structured log output to confirm the original error is logged
|
|
96
|
+
const originalLogError = apos.oembed.logError;
|
|
97
|
+
const logged = [];
|
|
98
|
+
apos.oembed.logError = (...args) => logged.push(args);
|
|
99
|
+
|
|
100
|
+
try {
|
|
101
|
+
await assert.rejects(
|
|
102
|
+
() => apos.oembed.query(req, url),
|
|
103
|
+
{ name: 'invalid' }
|
|
104
|
+
);
|
|
105
|
+
assert.strictEqual(calls, 1);
|
|
106
|
+
|
|
107
|
+
// The underlying cause is logged before the higher-level error is thrown
|
|
108
|
+
assert.strictEqual(logged.length, 1);
|
|
109
|
+
const [ loggedReq, eventType, message ] = logged[0];
|
|
110
|
+
assert.strictEqual(loggedReq, req);
|
|
111
|
+
assert.strictEqual(eventType, 'query-failed');
|
|
112
|
+
assert.strictEqual(message, 'simulated provider failure');
|
|
113
|
+
|
|
114
|
+
// Second call must be served from the cached failure, not re-fetched
|
|
115
|
+
await assert.rejects(
|
|
116
|
+
() => apos.oembed.query(req, url),
|
|
117
|
+
{ name: 'invalid' }
|
|
118
|
+
);
|
|
119
|
+
assert.strictEqual(calls, 1);
|
|
120
|
+
} finally {
|
|
121
|
+
apos.oembed.oembetter.fetch = original;
|
|
122
|
+
apos.oembed.logError = originalLogError;
|
|
123
|
+
}
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it('should tolerate legacy (unwrapped) cache entries without crashing', async function() {
|
|
127
|
+
const req = apos.task.getReq();
|
|
128
|
+
const url = 'https://example.com/legacy-video';
|
|
129
|
+
const legacyResponse = {
|
|
130
|
+
type: 'video',
|
|
131
|
+
title: 'Legacy',
|
|
132
|
+
html: '<iframe src="//example.com/legacy"></iframe>'
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
// Simulate a raw response cached by an older version of this module
|
|
136
|
+
const originalGet = apos.cache.get;
|
|
137
|
+
apos.cache.get = async () => legacyResponse;
|
|
138
|
+
|
|
139
|
+
// Fetching must not happen when the cache already has data
|
|
140
|
+
const originalFetch = apos.oembed.oembetter.fetch;
|
|
141
|
+
apos.oembed.oembetter.fetch = (fetchUrl, options, cb) => {
|
|
142
|
+
return cb(new Error('should not be called for a cache hit'));
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
try {
|
|
146
|
+
const result = await apos.oembed.query(req, url);
|
|
147
|
+
assert.strictEqual(result.title, 'Legacy');
|
|
148
|
+
assert.strictEqual(result.type, 'video');
|
|
149
|
+
} finally {
|
|
150
|
+
apos.cache.get = originalGet;
|
|
151
|
+
apos.oembed.oembetter.fetch = originalFetch;
|
|
152
|
+
}
|
|
153
|
+
});
|
|
56
154
|
});
|
package/test/utils.js
CHANGED
|
@@ -372,6 +372,109 @@ describe('Utils', async function() {
|
|
|
372
372
|
assert(data.shoes[0].size === 8);
|
|
373
373
|
});
|
|
374
374
|
|
|
375
|
+
// Server-Side Prototype Pollution (CWE-1321) regression coverage.
|
|
376
|
+
// apos.util.set and apos.util.get traverse user-supplied dot-notation
|
|
377
|
+
// paths. A segment of `__proto__`, `constructor` or `prototype` must
|
|
378
|
+
// never be followed, or an authenticated editor could write to
|
|
379
|
+
// Object.prototype (for example via the $pullAll patch operator) and
|
|
380
|
+
// poison authorization checks process-wide. See GHSA-6h5j-32cf-4253.
|
|
381
|
+
|
|
382
|
+
it('utils.set must reject a __proto__ segment instead of polluting Object.prototype', function() {
|
|
383
|
+
const data = {};
|
|
384
|
+
try {
|
|
385
|
+
assert.throws(() => {
|
|
386
|
+
apos.util.set(data, '__proto__.polluted', 'yes');
|
|
387
|
+
}, { name: 'invalid' });
|
|
388
|
+
assert.strictEqual({}.polluted, undefined);
|
|
389
|
+
assert.strictEqual(data.polluted, undefined);
|
|
390
|
+
} finally {
|
|
391
|
+
// Belt and suspenders: if the guard ever regresses, do not leak a
|
|
392
|
+
// polluted prototype into the rest of the suite.
|
|
393
|
+
delete Object.prototype.polluted;
|
|
394
|
+
}
|
|
395
|
+
});
|
|
396
|
+
|
|
397
|
+
it('utils.set must reject a constructor.prototype segment', function() {
|
|
398
|
+
const data = {};
|
|
399
|
+
try {
|
|
400
|
+
assert.throws(() => {
|
|
401
|
+
apos.util.set(data, 'constructor.prototype.polluted', 'yes');
|
|
402
|
+
}, { name: 'invalid' });
|
|
403
|
+
assert.strictEqual({}.polluted, undefined);
|
|
404
|
+
} finally {
|
|
405
|
+
delete Object.prototype.polluted;
|
|
406
|
+
}
|
|
407
|
+
});
|
|
408
|
+
|
|
409
|
+
it('utils.set must reject a trailing __proto__ segment rather than replacing the prototype', function() {
|
|
410
|
+
const data = {};
|
|
411
|
+
assert.throws(() => {
|
|
412
|
+
apos.util.set(data, '__proto__', { polluted: 'yes' });
|
|
413
|
+
}, { name: 'invalid' });
|
|
414
|
+
assert.strictEqual(Object.getPrototypeOf(data), Object.prototype);
|
|
415
|
+
assert.strictEqual(data.polluted, undefined);
|
|
416
|
+
});
|
|
417
|
+
|
|
418
|
+
it('utils.set must reject a dangerous segment exposed after an @ reference', function() {
|
|
419
|
+
const data = {
|
|
420
|
+
items: [
|
|
421
|
+
{
|
|
422
|
+
_id: 'abc',
|
|
423
|
+
sub: {}
|
|
424
|
+
}
|
|
425
|
+
]
|
|
426
|
+
};
|
|
427
|
+
try {
|
|
428
|
+
assert.throws(() => {
|
|
429
|
+
apos.util.set(data, '@abc.__proto__.polluted', 'yes');
|
|
430
|
+
}, { name: 'invalid' });
|
|
431
|
+
assert.strictEqual({}.polluted, undefined);
|
|
432
|
+
} finally {
|
|
433
|
+
delete Object.prototype.polluted;
|
|
434
|
+
}
|
|
435
|
+
});
|
|
436
|
+
|
|
437
|
+
it('utils.get must not traverse into the prototype chain via __proto__', function() {
|
|
438
|
+
// Without the guard this returns Object.prototype.toString (a function).
|
|
439
|
+
assert.strictEqual(apos.util.get({ a: 1 }, '__proto__.toString'), undefined);
|
|
440
|
+
assert.strictEqual(apos.util.get({ a: 1 }, '__proto__'), undefined);
|
|
441
|
+
});
|
|
442
|
+
|
|
443
|
+
it('implementPatchOperators must not let a $pullAll key pollute Object.prototype', function() {
|
|
444
|
+
// The reported attack vector: an authenticated editor PATCH body of
|
|
445
|
+
// { $pullAll: { '__proto__.publicApiProjection': [] } } reached
|
|
446
|
+
// apos.util.set with a fully attacker-controlled key.
|
|
447
|
+
const patch = {
|
|
448
|
+
$pullAll: {
|
|
449
|
+
'__proto__.publicApiProjection': []
|
|
450
|
+
}
|
|
451
|
+
};
|
|
452
|
+
try {
|
|
453
|
+
assert.throws(() => {
|
|
454
|
+
apos.schema.implementPatchOperators(patch, {});
|
|
455
|
+
}, { name: 'invalid' });
|
|
456
|
+
assert.strictEqual({}.publicApiProjection, undefined);
|
|
457
|
+
} finally {
|
|
458
|
+
delete Object.prototype.publicApiProjection;
|
|
459
|
+
}
|
|
460
|
+
});
|
|
461
|
+
|
|
462
|
+
it('implementPatchOperators must not let a direct dot-notation key pollute Object.prototype', function() {
|
|
463
|
+
// The second documented entry point: a top-level dotted key whose value
|
|
464
|
+
// is fully attacker-controlled.
|
|
465
|
+
const patch = {
|
|
466
|
+
'__proto__.publicApiProjection': { title: 1 }
|
|
467
|
+
};
|
|
468
|
+
try {
|
|
469
|
+
assert.throws(() => {
|
|
470
|
+
apos.schema.implementPatchOperators(patch, {});
|
|
471
|
+
}, { name: 'invalid' });
|
|
472
|
+
assert.strictEqual({}.publicApiProjection, undefined);
|
|
473
|
+
} finally {
|
|
474
|
+
delete Object.prototype.publicApiProjection;
|
|
475
|
+
}
|
|
476
|
+
});
|
|
477
|
+
|
|
375
478
|
it('should slugify', function () {
|
|
376
479
|
// Basic
|
|
377
480
|
assert.equal(
|