apostrophe 4.31.0 → 4.32.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.
Files changed (33) hide show
  1. package/CHANGELOG.md +48 -1
  2. package/claude-tools/run-mocha-file.sh +29 -0
  3. package/modules/@apostrophecms/area/index.js +14 -5
  4. package/modules/@apostrophecms/area/ui/apos/apps/AposAreas.js +72 -3
  5. package/modules/@apostrophecms/asset/index.js +170 -21
  6. package/modules/@apostrophecms/asset/lib/build/external-module-api.js +7 -16
  7. package/modules/@apostrophecms/asset/lib/build/internals.js +1 -3
  8. package/modules/@apostrophecms/asset/lib/build/task.js +7 -13
  9. package/modules/@apostrophecms/command-menu/ui/apos/components/TheAposCommandMenu.vue +21 -1
  10. package/modules/@apostrophecms/doc-type/index.js +73 -15
  11. package/modules/@apostrophecms/http/index.js +175 -139
  12. package/modules/@apostrophecms/image/index.js +16 -7
  13. package/modules/@apostrophecms/image/ui/apos/components/AposMediaManager.vue +9 -1
  14. package/modules/@apostrophecms/job/index.js +3 -1
  15. package/modules/@apostrophecms/page/index.js +17 -1
  16. package/modules/@apostrophecms/rich-text-widget/ui/apos/components/AposRichTextWidgetEditor.vue +10 -1
  17. package/modules/@apostrophecms/rich-text-widget/ui/apos/components/AposTiptapInsertItem.vue +3 -14
  18. package/modules/@apostrophecms/rich-text-widget/ui/apos/lib/remove-slash.js +18 -0
  19. package/modules/@apostrophecms/schema/ui/apos/components/AposInputDateAndTime.vue +37 -6
  20. package/modules/@apostrophecms/url/index.js +54 -0
  21. package/package.json +11 -12
  22. package/test/asset-external.js +3 -0
  23. package/test/asset-lock-cache.js +243 -0
  24. package/test/assets.js +25 -2
  25. package/test/config-cascade-merge.js +73 -0
  26. package/test/files.js +10 -11
  27. package/test/image-tags.js +103 -0
  28. package/test/modules/collision-piece/index.js +23 -0
  29. package/test/pages-move-permissions.js +317 -0
  30. package/test/pages.js +12 -15
  31. package/test/pieces-public-api-relationship-choices.js +245 -0
  32. package/test/pieces.js +12 -15
  33. package/test-lib/test.js +32 -0
@@ -0,0 +1,245 @@
1
+ const t = require('../test-lib/test.js');
2
+ const assert = require('assert');
3
+
4
+ // A blocked filter contributes no entry (or an empty array) to the
5
+ // choices/counts bag the REST API returns.
6
+ function assertNoLeak(bag, key, message) {
7
+ assert(!bag || !bag[key] || bag[key].length === 0, message);
8
+ }
9
+
10
+ // Regression tests for GHSA-xmpp-f9v3-r7qh — the incomplete fix for
11
+ // CVE-2026-39857. The projection guard that stops `?choices=` / `?counts=`
12
+ // from leaking distinct values of fields excluded from `publicApiProjection`
13
+ // resolved the schema field by an EXACT-NAME match. Relationship fields
14
+ // register additional query builders whose names differ from the schema field
15
+ // name — the "slug" alias builders (`author` / `authorAnd` for a relationship
16
+ // field named `_author`) and the `_authorAnd` operation builder. Because none
17
+ // of those names match `_author` exactly, the guard failed open and an
18
+ // unauthenticated caller could still extract the relationship's distinct
19
+ // choices (referenced, publicly-visible related docs by title/slug, plus
20
+ // per-value counts via `?counts=`).
21
+
22
+ describe('Pieces Public API — relationship choices/counts projection guard (GHSA-xmpp-f9v3-r7qh)', function() {
23
+
24
+ let apos;
25
+
26
+ this.timeout(t.timeout);
27
+
28
+ after(async function () {
29
+ return t.destroy(apos);
30
+ });
31
+
32
+ it('should initialize with article + author piece types', async function() {
33
+ apos = await t.create({
34
+ root: module,
35
+ modules: {
36
+ author: {
37
+ extend: '@apostrophecms/piece-type',
38
+ options: {
39
+ alias: 'author',
40
+ label: 'Author'
41
+ }
42
+ },
43
+ article: {
44
+ extend: '@apostrophecms/piece-type',
45
+ options: {
46
+ alias: 'article',
47
+ label: 'Article'
48
+ },
49
+ fields: {
50
+ add: {
51
+ foo: {
52
+ label: 'Foo',
53
+ type: 'string'
54
+ },
55
+ // Deliberately named with a leading underscore, as recommended.
56
+ // Its slug-alias builders are `author` / `authorAnd`; its
57
+ // operation builders are `_author` / `_authorAnd`.
58
+ _author: {
59
+ label: 'Author',
60
+ type: 'relationship',
61
+ withType: 'author'
62
+ }
63
+ }
64
+ }
65
+ }
66
+ }
67
+ });
68
+ assert(apos.modules.article);
69
+ assert(apos.modules.author);
70
+
71
+ // Sanity: confirm the relationship registered the extra query builders
72
+ // whose names differ from the `_author` schema field name. These are the
73
+ // aliases the guard must also gate.
74
+ const builders = apos.article.find(apos.task.getReq()).builders;
75
+ assert(builders._author, 'expected relationship builder "_author"');
76
+ assert(builders._authorAnd, 'expected relationship builder "_authorAnd"');
77
+ assert(builders.author, 'expected relationship slug-alias builder "author"');
78
+ assert(builders.authorAnd, 'expected relationship slug-alias builder "authorAnd"');
79
+ });
80
+
81
+ let jane;
82
+
83
+ it('should insert a public author and a public article that references it', async function() {
84
+ const req = apos.task.getReq();
85
+ jane = await apos.author.insert(req, {
86
+ ...apos.author.newInstance(),
87
+ title: 'Jane Author',
88
+ visibility: 'public'
89
+ });
90
+ await apos.article.insert(req, {
91
+ ...apos.article.newInstance(),
92
+ title: 'My Article',
93
+ foo: 'bar',
94
+ visibility: 'public',
95
+ _author: [ jane ]
96
+ });
97
+
98
+ // Confirm the relationship persisted so that, absent the guard, the
99
+ // distinct-values leak would actually have data to expose. This is what
100
+ // makes the "must not leak" assertions below meaningful.
101
+ const article = await apos.article.find(req, { title: 'My Article' }).toObject();
102
+ assert(article);
103
+ assert(Array.isArray(article._author) && article._author.length === 1);
104
+ assert.strictEqual(article._author[0].title, 'Jane Author');
105
+ assert(Array.isArray(article.authorIds) && article.authorIds.length === 1);
106
+ });
107
+
108
+ it('baseline: an anonymous list with a projection excluding _author does not expose the relationship', async function() {
109
+ apos.article.options.publicApiProjection = {
110
+ title: 1,
111
+ slug: 1,
112
+ _url: 1
113
+ };
114
+ const response = await apos.http.get('/api/v1/article');
115
+ assert(response);
116
+ assert(response.results);
117
+ assert.strictEqual(response.results.length, 1);
118
+ assert.strictEqual(response.results[0].title, 'My Article');
119
+ // The relationship must not be populated, and its idsStorage must not be
120
+ // exposed, when excluded from the projection. (An excluded relationship
121
+ // comes back as an empty array, so check length rather than truthiness.)
122
+ assert(
123
+ !response.results[0]._author || response.results[0]._author.length === 0,
124
+ '_author must not be populated when excluded from publicApiProjection'
125
+ );
126
+ assert(!response.results[0].authorIds, 'authorIds must not be exposed when excluded from publicApiProjection');
127
+ });
128
+
129
+ // Positive control: this proves the relationship choices machinery works and
130
+ // that the referenced author is discoverable — so the "must not leak" tests
131
+ // that follow (with the SAME data, only the projection changed) genuinely
132
+ // demonstrate the guard is doing the blocking, not an absence of data.
133
+ it('when _author IS in the publicApiProjection, ?choices=author returns the related choices', async function() {
134
+ apos.article.options.publicApiProjection = {
135
+ title: 1,
136
+ slug: 1,
137
+ _url: 1,
138
+ _author: 1
139
+ };
140
+ const response = await apos.http.get('/api/v1/article?choices=author');
141
+ assert(response);
142
+ assert(response.choices);
143
+ assert(Array.isArray(response.choices.author));
144
+ assert(
145
+ response.choices.author.some(c => c.label === 'Jane Author'),
146
+ 'expected the related author among the choices when the relationship is projected'
147
+ );
148
+ // The slug alias exposes the related doc slug as the choice value.
149
+ assert(response.choices.author.some(c => c.value === jane.slug));
150
+ });
151
+
152
+ it('SECURITY: ?choices=author must not leak choices for a relationship excluded from publicApiProjection', async function() {
153
+ apos.article.options.publicApiProjection = {
154
+ title: 1,
155
+ slug: 1,
156
+ _url: 1
157
+ };
158
+ const response = await apos.http.get('/api/v1/article?choices=author');
159
+ assert(response);
160
+ assertNoLeak(
161
+ response.choices,
162
+ 'author',
163
+ 'relationship slug-alias choices for excluded field "_author" must not be exposed publicly via ?choices=author'
164
+ );
165
+ });
166
+
167
+ it('SECURITY: ?choices=authorAnd must not leak choices for a relationship excluded from publicApiProjection', async function() {
168
+ apos.article.options.publicApiProjection = {
169
+ title: 1,
170
+ slug: 1,
171
+ _url: 1
172
+ };
173
+ const response = await apos.http.get('/api/v1/article?choices=authorAnd');
174
+ assert(response);
175
+ assertNoLeak(
176
+ response.choices,
177
+ 'authorAnd',
178
+ 'relationship slug-alias choices for excluded field "_author" must not be exposed publicly via ?choices=authorAnd'
179
+ );
180
+ });
181
+
182
+ it('SECURITY: ?choices=_authorAnd must not leak choices for a relationship excluded from publicApiProjection', async function() {
183
+ apos.article.options.publicApiProjection = {
184
+ title: 1,
185
+ slug: 1,
186
+ _url: 1
187
+ };
188
+ const response = await apos.http.get('/api/v1/article?choices=_authorAnd');
189
+ assert(response);
190
+ assertNoLeak(
191
+ response.choices,
192
+ '_authorAnd',
193
+ 'relationship operation-builder choices for excluded field "_author" must not be exposed publicly via ?choices=_authorAnd'
194
+ );
195
+ });
196
+
197
+ it('SECURITY: ?counts=author must not leak counts for a relationship excluded from publicApiProjection', async function() {
198
+ apos.article.options.publicApiProjection = {
199
+ title: 1,
200
+ slug: 1,
201
+ _url: 1
202
+ };
203
+ const response = await apos.http.get('/api/v1/article?counts=author');
204
+ assert(response);
205
+ assertNoLeak(
206
+ response.counts,
207
+ 'author',
208
+ 'relationship slug-alias counts for excluded field "_author" must not be exposed publicly via ?counts=author'
209
+ );
210
+ });
211
+
212
+ // Regression guard: the exact-name case was already blocked by the parent
213
+ // CVE fix and must stay blocked.
214
+ it('?choices=_author (exact relationship field name) remains blocked when excluded from publicApiProjection', async function() {
215
+ apos.article.options.publicApiProjection = {
216
+ title: 1,
217
+ slug: 1,
218
+ _url: 1
219
+ };
220
+ const response = await apos.http.get('/api/v1/article?choices=_author');
221
+ assert(response);
222
+ assertNoLeak(
223
+ response.choices,
224
+ '_author',
225
+ 'relationship choices for excluded field "_author" must not be exposed publicly'
226
+ );
227
+ });
228
+
229
+ // The guard is a public-API protection only; authenticated full-API callers
230
+ // (no publicApiProjection applied) must still receive relationship choices.
231
+ it('authenticated full-API callers still receive relationship choices for a non-projected relationship', async function() {
232
+ apos.article.options.publicApiProjection = {
233
+ title: 1,
234
+ slug: 1,
235
+ _url: 1
236
+ };
237
+ const req = apos.task.getReq();
238
+ const query = apos.article.find(req).choices([ 'author' ]);
239
+ await query.toArray();
240
+ const choices = query.get('choicesResults');
241
+ assert(choices);
242
+ assert(choices.author);
243
+ assert(choices.author.some(c => c.label === 'Jane Author'));
244
+ });
245
+ });
package/test/pieces.js CHANGED
@@ -6,6 +6,12 @@ const _ = require('lodash');
6
6
  const FormData = require('form-data');
7
7
  const t = require('../test-lib/test.js');
8
8
 
9
+ // The etag tests below issue their conditional request via rawGet (raw
10
+ // node:http): the built-in fetch used by apos.http adds Cache-Control: no-cache
11
+ // to any request carrying a conditional header (Fetch standard), which would
12
+ // suppress the asserted 304s.
13
+ const { rawGet } = t;
14
+
9
15
  describe('Pieces', function() {
10
16
 
11
17
  let apos;
@@ -1807,11 +1813,8 @@ describe('Pieces', function() {
1807
1813
  };
1808
1814
 
1809
1815
  const response1 = await apos.http.get('/api/v1/thing/testThing:en:published', { fullResponse: true });
1810
- const response2 = await apos.http.get('/api/v1/thing/testThing:en:published', {
1811
- fullResponse: true,
1812
- headers: {
1813
- 'if-none-match': response1.headers.etag
1814
- }
1816
+ const response2 = await rawGet(apos, '/api/v1/thing/testThing:en:published', {
1817
+ 'if-none-match': response1.headers.etag
1815
1818
  });
1816
1819
 
1817
1820
  assert(response1.status === 200);
@@ -1845,11 +1848,8 @@ describe('Pieces', function() {
1845
1848
  // so requesting it again should not return a 304 status code
1846
1849
  const pieceUpdateResponse = await apos.doc.update(apos.task.getReq(), pieceDoc);
1847
1850
 
1848
- const response2 = await apos.http.get('/api/v1/thing/testThing:en:published', {
1849
- fullResponse: true,
1850
- headers: {
1851
- 'if-none-match': response1.headers.etag
1852
- }
1851
+ const response2 = await rawGet(apos, '/api/v1/thing/testThing:en:published', {
1852
+ 'if-none-match': response1.headers.etag
1853
1853
  });
1854
1854
 
1855
1855
  const eTag1Parts = response1.headers.etag.split(':');
@@ -1885,11 +1885,8 @@ describe('Pieces', function() {
1885
1885
  outOfDateETagParts[2] = Number(outOfDateETagParts[2]) -
1886
1886
  (4444 + 1) * 1000; // 1s outdated
1887
1887
 
1888
- const response2 = await apos.http.get('/api/v1/thing/testThing:en:published', {
1889
- fullResponse: true,
1890
- headers: {
1891
- 'if-none-match': outOfDateETagParts.join(':')
1892
- }
1888
+ const response2 = await rawGet(apos, '/api/v1/thing/testThing:en:published', {
1889
+ 'if-none-match': outOfDateETagParts.join(':')
1893
1890
  });
1894
1891
 
1895
1892
  const eTag1Parts = response1.headers.etag.split(':');
package/test-lib/test.js CHANGED
@@ -1,5 +1,6 @@
1
1
  const fs = require('fs-extra');
2
2
  const path = require('path');
3
+ const http = require('node:http');
3
4
 
4
5
  const setupPackages = ({ folder = 'test' }) => {
5
6
  const testNodeModules = path.join(__dirname, '../', folder, 'node_modules/');
@@ -57,5 +58,36 @@ const setupPackages = ({ folder = 'test' }) => {
57
58
  };
58
59
  setupPackages({ folder: 'test' });
59
60
 
61
+ // Performs a GET via the raw node:http client, sending `headers` to the server
62
+ // verbatim. Use this in tests that must control headers the built-in fetch
63
+ // (used by apos.http) would otherwise refuse or rewrite: e.g. a forbidden
64
+ // `Host` header, or the `Cache-Control: no-cache` it adds to any request that
65
+ // carries a conditional header (If-None-Match / If-Modified-Since). `url` may
66
+ // be absolute or site-relative (resolved against `apos.http.getBase()`).
67
+ // Resolves with a fullResponse-shaped { status, headers, body }.
68
+ const rawGet = (apos, url, headers = {}) => {
69
+ const target = url.startsWith('/') ? `${apos.http.getBase()}${url}` : url;
70
+ return new Promise((resolve, reject) => {
71
+ // Pass the URL string (rather than a split hostname/port) so an IPv6 base
72
+ // such as `http://[::1]:3000` from getBase() is handled correctly; a
73
+ // bracketed hostname passed on its own is treated as a name to DNS-resolve.
74
+ const req = http.request(target, {
75
+ method: 'GET',
76
+ headers
77
+ }, (res) => {
78
+ const chunks = [];
79
+ res.on('data', (chunk) => chunks.push(chunk));
80
+ res.on('end', () => resolve({
81
+ status: res.statusCode,
82
+ headers: res.headers,
83
+ body: Buffer.concat(chunks).toString()
84
+ }));
85
+ });
86
+ req.on('error', reject);
87
+ req.end();
88
+ });
89
+ };
90
+
60
91
  module.exports = require('./util.js');
61
92
  module.exports.setupPackages = setupPackages;
93
+ module.exports.rawGet = rawGet;