apostrophe 4.31.1-beta.1 → 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 (35) hide show
  1. package/CHANGELOG.md +43 -2
  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/oembed/index.js +12 -45
  16. package/modules/@apostrophecms/page/index.js +17 -1
  17. package/modules/@apostrophecms/rich-text-widget/ui/apos/components/AposRichTextWidgetEditor.vue +10 -1
  18. package/modules/@apostrophecms/rich-text-widget/ui/apos/components/AposTiptapInsertItem.vue +3 -14
  19. package/modules/@apostrophecms/rich-text-widget/ui/apos/lib/remove-slash.js +18 -0
  20. package/modules/@apostrophecms/schema/ui/apos/components/AposInputDateAndTime.vue +37 -6
  21. package/modules/@apostrophecms/url/index.js +54 -0
  22. package/package.json +11 -12
  23. package/test/asset-external.js +3 -0
  24. package/test/asset-lock-cache.js +243 -0
  25. package/test/assets.js +25 -2
  26. package/test/config-cascade-merge.js +73 -0
  27. package/test/files.js +10 -11
  28. package/test/image-tags.js +103 -0
  29. package/test/modules/collision-piece/index.js +23 -0
  30. package/test/oembed.js +0 -98
  31. package/test/pages-move-permissions.js +317 -0
  32. package/test/pages.js +12 -15
  33. package/test/pieces-public-api-relationship-choices.js +245 -0
  34. package/test/pieces.js +12 -15
  35. package/test-lib/test.js +32 -0
@@ -0,0 +1,317 @@
1
+ // Regression tests for GHSA-wr5r-wqp2-x4fh:
2
+ // "Missing destination-parent authorization in page move() allows a
3
+ // low-privileged editor to move and re-rank pages inside a restricted subtree"
4
+ //
5
+ // A regression in the move() authorization guard silently disabled the
6
+ // destination "create" permission check for every normal (non-archive) move.
7
+ // The only surviving gate was moved._edit ("can the actor edit the page being
8
+ // moved"), which an editor legitimately holds for their own ordinary pages.
9
+ // As a result a non-admin editor could relocate a page they control under a
10
+ // parent of a restricted page type (e.g. one declaring editRole: 'admin')
11
+ // that they have no create/edit rights over, and in doing so trigger an
12
+ // unchecked updateMany that re-ranks the restricted parent's existing
13
+ // children (documents the actor cannot edit).
14
+ //
15
+ // These tests exercise the real apos.page.move() code path (and the REST
16
+ // route it backs) with a non-admin request and assert that the destination
17
+ // authorization boundary is enforced, while confirming legitimate moves and
18
+ // the archive restore flow are still permitted.
19
+
20
+ const t = require('../test-lib/test.js');
21
+ const assert = require('assert/strict');
22
+
23
+ describe('pages - move destination permission (GHSA-wr5r-wqp2-x4fh)', function () {
24
+ this.timeout(t.timeout);
25
+
26
+ let apos;
27
+ let homeId;
28
+
29
+ after(async function () {
30
+ await t.destroy(apos);
31
+ apos = null;
32
+ });
33
+
34
+ before(async function () {
35
+ apos = await t.create({
36
+ root: module,
37
+ modules: {
38
+ // A restricted page type: only admins may create/edit pages of this
39
+ // type. This is the same shape as the core @apostrophecms/archive-page
40
+ // (editRole/publishRole: 'admin') and of restricted "section" page
41
+ // types projects commonly configure.
42
+ 'secret-page': {
43
+ extend: '@apostrophecms/page-type',
44
+ options: {
45
+ editRole: 'admin',
46
+ publishRole: 'admin'
47
+ }
48
+ },
49
+ // An ordinary page type any editor may create/edit.
50
+ 'public-page': {
51
+ extend: '@apostrophecms/page-type'
52
+ },
53
+ '@apostrophecms/page': {
54
+ options: {
55
+ park: [],
56
+ types: [
57
+ {
58
+ name: '@apostrophecms/home-page',
59
+ label: 'Home'
60
+ },
61
+ {
62
+ name: 'secret-page',
63
+ label: 'Secret'
64
+ },
65
+ {
66
+ name: 'public-page',
67
+ label: 'Public'
68
+ }
69
+ ]
70
+ }
71
+ }
72
+ }
73
+ });
74
+
75
+ const home = await apos.page
76
+ .find(apos.task.getReq({ role: 'admin' }), { level: 0 })
77
+ .toObject();
78
+ assert.ok(home, 'home page should exist');
79
+ homeId = home._id;
80
+ });
81
+
82
+ // A non-admin editor request. Editors may create/edit ordinary pages but
83
+ // NOT pages of a type gated by editRole: 'admin'.
84
+ function editorReq() {
85
+ return apos.task.getReq({
86
+ role: 'editor',
87
+ user: {
88
+ _id: 'editor-user',
89
+ title: 'Editor',
90
+ role: 'editor'
91
+ }
92
+ });
93
+ }
94
+
95
+ function adminReq() {
96
+ return apos.task.getReq({ role: 'admin' });
97
+ }
98
+
99
+ // Create an admin-only "secret" section with one pre-existing admin-only
100
+ // child, plus an ordinary editor-editable page under home. `slugPrefix`
101
+ // keeps each test's fixtures independent within the shared database.
102
+ async function fixtures(slugPrefix) {
103
+ const admin = adminReq();
104
+
105
+ const secret = await apos.page.insert(admin, homeId, 'lastChild', {
106
+ title: 'Secret Section',
107
+ type: 'secret-page',
108
+ slug: `/${slugPrefix}-secret`
109
+ });
110
+ const secretChild = await apos.page.insert(admin, secret._id, 'lastChild', {
111
+ title: 'Secret Child',
112
+ type: 'secret-page',
113
+ slug: `/${slugPrefix}-secret/child`
114
+ });
115
+ const mine = await apos.page.insert(admin, homeId, 'lastChild', {
116
+ title: 'My Page',
117
+ type: 'public-page',
118
+ slug: `/${slugPrefix}-mine`
119
+ });
120
+
121
+ return {
122
+ secret,
123
+ secretChild,
124
+ mine
125
+ };
126
+ }
127
+
128
+ it('precondition: an editor has no create/edit rights on an admin-only section', async function () {
129
+ const { secret } = await fixtures('precondition');
130
+
131
+ const secretForEditor = await apos.page
132
+ .find(editorReq(), { _id: secret._id })
133
+ .permission(false)
134
+ .toObject();
135
+
136
+ assert.ok(secretForEditor, 'the section is locatable regardless of rights');
137
+ assert.notEqual(
138
+ secretForEditor._create,
139
+ true,
140
+ 'editor must NOT have create rights on the admin-only section'
141
+ );
142
+ assert.notEqual(
143
+ secretForEditor._edit,
144
+ true,
145
+ 'editor must NOT have edit rights on the admin-only section'
146
+ );
147
+ });
148
+
149
+ it('forbids an editor from moving their page under an admin-only section', async function () {
150
+ const { secret, mine } = await fixtures('forbid-move');
151
+
152
+ await assert.rejects(
153
+ apos.page.move(editorReq(), mine._id, secret._id, 'firstChild'),
154
+ { name: 'forbidden' },
155
+ 'moving a page under a parent the actor cannot create within must be forbidden'
156
+ );
157
+
158
+ // The page must NOT have been relocated under the restricted branch.
159
+ const moved = await apos.page
160
+ .find(adminReq(), { _id: mine._id })
161
+ .toObject();
162
+ assert.ok(
163
+ !moved.path.includes(secret.aposDocId),
164
+ 'the editor must not have relocated their page under the admin-only section'
165
+ );
166
+ });
167
+
168
+ it('does not re-rank an admin-only section\'s protected children on a forbidden move', async function () {
169
+ const {
170
+ secret, secretChild, mine
171
+ } = await fixtures('no-rerank');
172
+
173
+ const before = await apos.page
174
+ .find(adminReq(), { _id: secretChild._id })
175
+ .toObject();
176
+
177
+ // 'firstChild' would place the moved page at rank 0 and (if the move were
178
+ // wrongly allowed) nudge the pre-existing admin-only child from rank 0 to
179
+ // rank 1 via an unchecked updateMany.
180
+ await assert.rejects(
181
+ apos.page.move(editorReq(), mine._id, secret._id, 'firstChild'),
182
+ { name: 'forbidden' }
183
+ );
184
+
185
+ const after = await apos.page
186
+ .find(adminReq(), { _id: secretChild._id })
187
+ .toObject();
188
+
189
+ assert.equal(
190
+ after.rank,
191
+ before.rank,
192
+ 'the protected sibling\'s rank must be untouched by a forbidden move'
193
+ );
194
+ });
195
+
196
+ it('still allows an editor to move their page under a parent they may create within', async function () {
197
+ const { mine } = await fixtures('allow-move');
198
+
199
+ // Another ordinary parent the editor legitimately controls.
200
+ const otherParent = await apos.page.insert(adminReq(), homeId, 'lastChild', {
201
+ title: 'Other Parent',
202
+ type: 'public-page',
203
+ slug: '/allow-move-other'
204
+ });
205
+
206
+ // Sanity: the editor genuinely has create rights on the destination.
207
+ const otherForEditor = await apos.page
208
+ .find(editorReq(), { _id: otherParent._id })
209
+ .permission(false)
210
+ .toObject();
211
+ assert.equal(
212
+ otherForEditor._create,
213
+ true,
214
+ 'precondition: editor has create rights on the ordinary destination'
215
+ );
216
+
217
+ await assert.doesNotReject(
218
+ apos.page.move(editorReq(), mine._id, otherParent._id, 'firstChild'),
219
+ 'a move into a destination the editor may create within must be allowed'
220
+ );
221
+
222
+ const moved = await apos.page.find(adminReq(), { _id: mine._id }).toObject();
223
+ assert.ok(
224
+ moved.path.includes(otherParent.aposDocId),
225
+ 'the page should now live under the permitted destination'
226
+ );
227
+ });
228
+
229
+ it('still allows an admin to move a page under the admin-only section', async function () {
230
+ const { secret, mine } = await fixtures('admin-move');
231
+
232
+ await assert.doesNotReject(
233
+ apos.page.move(adminReq(), mine._id, secret._id, 'firstChild'),
234
+ 'an admin has create rights on the section and may move pages into it'
235
+ );
236
+
237
+ const moved = await apos.page.find(adminReq(), { _id: mine._id }).toObject();
238
+ assert.ok(
239
+ moved.path.includes(secret.aposDocId),
240
+ 'the admin-moved page should live under the section'
241
+ );
242
+ });
243
+
244
+ it('still allows archiving a page and restoring it out of the archive', async function () {
245
+ // Exercises the guard\'s archive-restore branch: a move whose old parent
246
+ // is the archive page must remain permitted for a destination the actor
247
+ // may edit. Using an editor on an ordinary page they fully control.
248
+ const { mine } = await fixtures('restore');
249
+
250
+ // Archive: moves the page under the archive (old parent becomes archive).
251
+ await assert.doesNotReject(
252
+ apos.page.archive(editorReq(), mine._id),
253
+ 'an editor may archive their own ordinary page'
254
+ );
255
+
256
+ const archived = await apos.page
257
+ .find(adminReq(), { _id: mine._id })
258
+ .archived(null)
259
+ .ancestors({ archived: null })
260
+ .toObject();
261
+ assert.equal(archived.archived, true, 'the page should be archived');
262
+ assert.equal(
263
+ archived._ancestors[archived._ancestors.length - 1].type,
264
+ '@apostrophecms/archive-page',
265
+ 'the archived page\'s parent should be the archive'
266
+ );
267
+
268
+ // Restore: move it back out of the archive, under home.
269
+ await assert.doesNotReject(
270
+ apos.page.move(editorReq(), mine._id, homeId, 'lastChild'),
271
+ 'restoring a page out of the archive to a destination the actor may edit must be allowed'
272
+ );
273
+
274
+ const restored = await apos.page
275
+ .find(adminReq(), { _id: mine._id })
276
+ .toObject();
277
+ assert.ok(restored, 'the restored page should be live again');
278
+ assert.notEqual(restored.archived, true, 'the restored page should not be archived');
279
+ });
280
+
281
+ it('forbids the same move over the REST PATCH route for a logged-in editor', async function () {
282
+ const { secret, mine } = await fixtures('rest');
283
+
284
+ // A real, logged-in editor session.
285
+ await t.createUser(apos, 'editor', { username: 'rest-editor' });
286
+ const jar = await t.loginAs(apos, 'rest-editor');
287
+ // A safe request first, so the jar carries the CSRF cookie the write
288
+ // routes require. Otherwise the PATCH is rejected for CSRF before it can
289
+ // ever reach move(), which would mask the authorization check under test.
290
+ await apos.http.get('/', { jar });
291
+
292
+ const draftId = mine._id.replace(':published', ':draft');
293
+ const targetDraftId = secret._id.replace(':published', ':draft');
294
+
295
+ let status = null;
296
+ try {
297
+ await apos.http.patch(`/api/v1/@apostrophecms/page/${draftId}`, {
298
+ body: {
299
+ _targetId: targetDraftId,
300
+ _position: 'firstChild'
301
+ },
302
+ jar
303
+ });
304
+ } catch (e) {
305
+ status = e.status;
306
+ }
307
+
308
+ assert.equal(status, 403, 'the REST move must be rejected with 403 Forbidden');
309
+
310
+ // Confirm no relocation happened.
311
+ const moved = await apos.page.find(adminReq(), { _id: mine._id }).toObject();
312
+ assert.ok(
313
+ !moved.path.includes(secret.aposDocId),
314
+ 'the editor must not have relocated their page under the admin-only section via REST'
315
+ );
316
+ });
317
+ });
package/test/pages.js CHANGED
@@ -1,6 +1,12 @@
1
1
  const t = require('../test-lib/test.js');
2
2
  const assert = require('assert');
3
3
  const _ = require('lodash');
4
+ // The REST API etag tests below issue their conditional request via rawGet
5
+ // (raw node:http): the built-in fetch used by apos.http adds Cache-Control:
6
+ // no-cache to any request carrying a conditional header (Fetch standard), which
7
+ // would suppress the asserted 304s. The page-serving etag tests stay on
8
+ // apos.http (those routes set 304 explicitly).
9
+ const { rawGet } = t;
4
10
 
5
11
  describe('Pages', function() {
6
12
  let apos;
@@ -1087,11 +1093,8 @@ describe('Pages', function() {
1087
1093
  };
1088
1094
 
1089
1095
  const response1 = await apos.http.get(`/api/v1/@apostrophecms/page/${homeId}`, { fullResponse: true });
1090
- const response2 = await apos.http.get(`/api/v1/@apostrophecms/page/${homeId}`, {
1091
- fullResponse: true,
1092
- headers: {
1093
- 'if-none-match': response1.headers.etag
1094
- }
1096
+ const response2 = await rawGet(apos, `/api/v1/@apostrophecms/page/${homeId}`, {
1097
+ 'if-none-match': response1.headers.etag
1095
1098
  });
1096
1099
 
1097
1100
  assert(response1.status === 200);
@@ -1125,11 +1128,8 @@ describe('Pages', function() {
1125
1128
  // so requesting it again should not return a 304 status code
1126
1129
  const pageUpdateResponse = await apos.doc.update(apos.task.getReq(), pageDoc);
1127
1130
 
1128
- const response2 = await apos.http.get(`/api/v1/@apostrophecms/page/${homeId}`, {
1129
- fullResponse: true,
1130
- headers: {
1131
- 'if-none-match': response1.headers.etag
1132
- }
1131
+ const response2 = await rawGet(apos, `/api/v1/@apostrophecms/page/${homeId}`, {
1132
+ 'if-none-match': response1.headers.etag
1133
1133
  });
1134
1134
 
1135
1135
  const eTag1Parts = response1.headers.etag.split(':');
@@ -1165,11 +1165,8 @@ describe('Pages', function() {
1165
1165
  outOfDateETagParts[2] = Number(outOfDateETagParts[2]) -
1166
1166
  (4444 + 1) * 1000; // 1s outdated
1167
1167
 
1168
- const response2 = await apos.http.get(`/api/v1/@apostrophecms/page/${homeId}`, {
1169
- fullResponse: true,
1170
- headers: {
1171
- 'if-none-match': outOfDateETagParts.join(':')
1172
- }
1168
+ const response2 = await rawGet(apos, `/api/v1/@apostrophecms/page/${homeId}`, {
1169
+ 'if-none-match': outOfDateETagParts.join(':')
1173
1170
  });
1174
1171
 
1175
1172
  const eTag1Parts = response1.headers.etag.split(':');
@@ -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(':');