apostrophe 4.31.0-alpha.1 → 4.31.0

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,30 @@
1
1
  # Changelog
2
2
 
3
- ## 4.31.0
4
-
5
- ### Minor Changes
6
-
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.
11
-
12
- Fixed Tab navigation escaping out of modals when the form contained hidden sections or elements that became disabled after editing.
13
-
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
3
+ ## 4.31.0 (2026-06-10)
4
+
5
+ ### Adds
6
+
7
+ - 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
+ - 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
+ - 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).
10
+ - 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
+
12
+ ### Fixes
13
+
14
+ - 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.
15
+ Fixed Tab navigation escaping out of modals when the form contained hidden sections or elements that became disabled after editing.
16
+ - Fixed adding or removing an area field from a schema breaking existing documents on an external front such as Astro.
17
+ - 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.
18
+ - 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.
19
+ - `apos.util.getManagerOf` accepts a `{ log }` option to suppress its error log when probing objects that may not have a manager.
20
+ - Fix more admin UI a11y issues.
21
+ - 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.
22
+ - 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.
23
+
24
+ ### Security
25
+
26
+ - 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.
27
+ - 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
28
 
69
29
  ## 4.30.0
70
30
 
@@ -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;
@@ -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.0",
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",
124
123
  "broadband": "^1.1.0",
125
- "express-cache-on-demand": "^1.0.4",
126
- "launder": "^1.7.2-alpha.1",
124
+ "launder": "^1.7.1",
125
+ "@apostrophecms/db-connect": "^1.0.1",
127
126
  "oembetter": "^1.2.0",
128
- "sanitize-html": "^2.17.5-alpha.1",
129
- "postcss-viewport-to-container-toggle": "^2.3.0",
127
+ "boring": "^1.1.1",
128
+ "express-cache-on-demand": "^1.0.4",
129
+ "sanitize-html": "^2.17.5",
130
130
  "uploadfs": "^1.26.1",
131
- "@apostrophecms/db-connect": "^1.0.0"
131
+ "postcss-viewport-to-container-toggle": "^2.3.0"
132
132
  },
133
133
  "devDependencies": {
134
134
  "chai": "^4.3.10",
@@ -137,8 +137,8 @@
137
137
  "mocha": "^11.7.5",
138
138
  "nyc": "^17.1.0",
139
139
  "stylelint": "^16.5.0",
140
- "stylelint-config-apostrophe": "^4.4.0",
141
- "eslint-config-apostrophe": "^6.0.2"
140
+ "eslint-config-apostrophe": "^6.0.2",
141
+ "stylelint-config-apostrophe": "^4.4.0"
142
142
  },
143
143
  "browserslist": [
144
144
  "ie >= 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/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(