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.
@@ -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.0
3
+ ## 4.31.1-beta.1
4
4
 
5
- ### Minor Changes
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
- - 33bb4c0: 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.
8
- - dfd25cf: 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.
9
- - 950927d: 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.
10
- - cf1a639: 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.
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
- - 77a2968: Fix layout widget not regaining full focus on switching back to Edit content mode.
15
- - f67c272: Fixed adding or removing an area field from a schema breaking existing documents on an external front such as Astro.
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
- // For relative URLs (local uploadfs, not CDN), resolve
252
- // against the current server's origin so the proxy can
253
- // make the self-request. During static builds
254
- // `attachment.url()` may return only a path.
255
- const proxyUrl = uglyUrl.startsWith('/')
256
- ? `${req.protocol}://${req.get('host')}${uglyUrl}`
257
- : uglyUrl;
258
- return await streamProxy(req, proxyUrl, { error: self.apos.util.error });
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
- let response = await self.apos.cache.get('@apostrophecms/oembed', key);
116
- if (response !== undefined) {
117
- return response;
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
- if (options.alwaysIframe) {
120
- response = await self.iframe(req, url, options);
121
- } else {
122
- try {
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
- // cache oembed responses for one hour
141
- await self.apos.cache.set('@apostrophecms/oembed', key, response, self.options.cacheLifetime);
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
@@ -372,6 +372,8 @@
372
372
  @include type-base;
373
373
 
374
374
  & {
375
+ // Do not remove - it fixes unintended `sr-only` side effect.
376
+ position: relative;
375
377
  display: flex;
376
378
  align-items: center;
377
379
  color: var(--a-base-2);
@@ -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.0-alpha.1",
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
- "boring": "^1.1.1",
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.2-alpha.1",
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
- "uploadfs": "^1.26.1",
131
- "@apostrophecms/db-connect": "^1.0.0"
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(