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.
- package/CHANGELOG.md +43 -2
- package/claude-tools/run-mocha-file.sh +29 -0
- package/modules/@apostrophecms/area/index.js +14 -5
- package/modules/@apostrophecms/area/ui/apos/apps/AposAreas.js +72 -3
- package/modules/@apostrophecms/asset/index.js +170 -21
- package/modules/@apostrophecms/asset/lib/build/external-module-api.js +7 -16
- package/modules/@apostrophecms/asset/lib/build/internals.js +1 -3
- package/modules/@apostrophecms/asset/lib/build/task.js +7 -13
- package/modules/@apostrophecms/command-menu/ui/apos/components/TheAposCommandMenu.vue +21 -1
- package/modules/@apostrophecms/doc-type/index.js +73 -15
- package/modules/@apostrophecms/http/index.js +175 -139
- package/modules/@apostrophecms/image/index.js +16 -7
- package/modules/@apostrophecms/image/ui/apos/components/AposMediaManager.vue +9 -1
- package/modules/@apostrophecms/job/index.js +3 -1
- package/modules/@apostrophecms/oembed/index.js +12 -45
- package/modules/@apostrophecms/page/index.js +17 -1
- package/modules/@apostrophecms/rich-text-widget/ui/apos/components/AposRichTextWidgetEditor.vue +10 -1
- package/modules/@apostrophecms/rich-text-widget/ui/apos/components/AposTiptapInsertItem.vue +3 -14
- package/modules/@apostrophecms/rich-text-widget/ui/apos/lib/remove-slash.js +18 -0
- package/modules/@apostrophecms/schema/ui/apos/components/AposInputDateAndTime.vue +37 -6
- package/modules/@apostrophecms/url/index.js +54 -0
- package/package.json +11 -12
- package/test/asset-external.js +3 -0
- package/test/asset-lock-cache.js +243 -0
- package/test/assets.js +25 -2
- package/test/config-cascade-merge.js +73 -0
- package/test/files.js +10 -11
- package/test/image-tags.js +103 -0
- package/test/modules/collision-piece/index.js +23 -0
- package/test/oembed.js +0 -98
- package/test/pages-move-permissions.js +317 -0
- package/test/pages.js +12 -15
- package/test/pieces-public-api-relationship-choices.js +245 -0
- package/test/pieces.js +12 -15
- 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
|
|
1091
|
-
|
|
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
|
|
1129
|
-
|
|
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
|
|
1169
|
-
|
|
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
|
|
1811
|
-
|
|
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
|
|
1849
|
-
|
|
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
|
|
1889
|
-
|
|
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(':');
|