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.
- package/CHANGELOG.md +48 -1
- 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/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/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,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(':');
|
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;
|