apostrophe 4.29.0 → 4.30.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/.claude/settings.local.json +15 -0
- package/CHANGELOG.md +34 -0
- package/modules/@apostrophecms/admin-bar/ui/apos/components/TheAposAdminBar.vue +0 -1
- package/modules/@apostrophecms/admin-bar/ui/apos/components/TheAposAdminBarMenu.vue +25 -8
- package/modules/@apostrophecms/admin-bar/ui/apos/components/TheAposContextBreakpointPreviewMode.vue +9 -0
- package/modules/@apostrophecms/admin-bar/ui/apos/components/TheAposContextTitle.vue +20 -2
- package/modules/@apostrophecms/area/index.js +10 -5
- package/modules/@apostrophecms/area/ui/apos/components/AposAreaWidget.vue +2 -0
- package/modules/@apostrophecms/command-menu/ui/apos/components/TheAposCommandMenu.vue +11 -1
- package/modules/@apostrophecms/i18n/i18n/en.json +8 -0
- package/modules/@apostrophecms/i18n/index.js +1 -8
- package/modules/@apostrophecms/image-widget/index.js +29 -1
- package/modules/@apostrophecms/layout-widget/index.js +124 -2
- package/modules/@apostrophecms/layout-widget/ui/apos/components/AposAreaLayoutEditor.vue +89 -6
- package/modules/@apostrophecms/layout-widget/ui/apos/components/AposGridLayout.vue +2 -2
- package/modules/@apostrophecms/layout-widget/ui/apos/components/AposGridManager.vue +2 -2
- package/modules/@apostrophecms/layout-widget/ui/apos/layout.css +8 -0
- package/modules/@apostrophecms/layout-widget/ui/src/layout.css +1 -1
- package/modules/@apostrophecms/layout-widget/views/widget.html +3 -3
- package/modules/@apostrophecms/login/index.js +13 -15
- package/modules/@apostrophecms/modal/ui/apos/components/AposModal.vue +2 -1
- package/modules/@apostrophecms/oembed/index.js +18 -13
- package/modules/@apostrophecms/recently-edited/ui/apos/components/AposRecentlyEditedIcon.vue +2 -0
- package/modules/@apostrophecms/rich-text-widget/index.js +36 -0
- package/modules/@apostrophecms/schema/ui/apos/logic/AposInputCheckboxes.js +1 -1
- package/modules/@apostrophecms/schema/ui/apos/logic/AposInputRadio.js +1 -1
- package/modules/@apostrophecms/styles/index.js +16 -0
- package/modules/@apostrophecms/styles/lib/handlers.js +6 -0
- package/modules/@apostrophecms/styles/lib/methods.js +93 -0
- package/modules/@apostrophecms/styles/lib/presets.js +17 -0
- package/modules/@apostrophecms/styles/ui/apos/universal/render.mjs +10 -1
- package/modules/@apostrophecms/submitted-draft/ui/apos/components/AposSubmittedDraftIcon.vue +1 -0
- package/modules/@apostrophecms/template/views/outerLayoutBase.html +1 -1
- package/modules/@apostrophecms/ui/ui/apos/components/AposButton.vue +14 -2
- package/modules/@apostrophecms/ui/ui/apos/components/AposContextMenu.vue +29 -5
- package/modules/@apostrophecms/ui/ui/apos/components/AposContextMenuDialog.vue +8 -0
- package/modules/@apostrophecms/ui/ui/apos/components/AposContextMenuItem.vue +5 -2
- package/modules/@apostrophecms/ui/ui/apos/components/AposLocalePicker.vue +14 -1
- package/modules/@apostrophecms/ui/ui/apos/scss/global/_utilities.scss +13 -1
- package/modules/@apostrophecms/util/index.js +4 -0
- package/modules/@apostrophecms/widget-type/index.js +6 -0
- package/modules/@apostrophecms/widget-type/ui/apos/components/AposWidgetEditor.vue +32 -0
- package/package.json +5 -5
- package/test/add-missing-schema-fields-project/node_modules/.package-lock.json +2 -2
- package/test/layout-widget-gap.js +530 -0
- package/test/login.js +122 -1
- package/test/rich-text-widget.js +200 -0
- package/test/styles.js +50 -0
package/test/rich-text-widget.js
CHANGED
|
@@ -293,4 +293,204 @@ describe('Rich Text Widget', function () {
|
|
|
293
293
|
assert(text2.includes('src="/uploads/attachments/attachment-1-attachment-1.max.jpg" alt="Updated Test Image 1"'));
|
|
294
294
|
assert(text2.includes('src="/uploads/attachments/attachment-2-attachment-2.max.jpg" alt="Updated Test Image 2"'));
|
|
295
295
|
});
|
|
296
|
+
|
|
297
|
+
describe('image import allowlist (SSRF mitigation)', function () {
|
|
298
|
+
let originalConsoleError;
|
|
299
|
+
|
|
300
|
+
before(function () {
|
|
301
|
+
// The sanitize method intentionally console.errors thrown errors to
|
|
302
|
+
// surface stack traces during development; silence that noise during
|
|
303
|
+
// these negative tests.
|
|
304
|
+
// eslint-disable-next-line no-console
|
|
305
|
+
originalConsoleError = console.error;
|
|
306
|
+
// eslint-disable-next-line no-console
|
|
307
|
+
console.error = () => {};
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
after(function () {
|
|
311
|
+
// eslint-disable-next-line no-console
|
|
312
|
+
console.error = originalConsoleError;
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
it('allows rich text HTML import without `<img>` tags even with no allowlist configured', async function () {
|
|
316
|
+
apos = await t.create({
|
|
317
|
+
root: module,
|
|
318
|
+
modules: {}
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
const manager = apos.modules['@apostrophecms/rich-text-widget'];
|
|
322
|
+
const req = apos.task.getReq();
|
|
323
|
+
|
|
324
|
+
const output = await manager.sanitize(req, {
|
|
325
|
+
type: '@apostrophecms/rich-text',
|
|
326
|
+
content: '<p>seed</p>',
|
|
327
|
+
import: {
|
|
328
|
+
html: '<p>Hello <strong>world</strong></p>'
|
|
329
|
+
}
|
|
330
|
+
}, {});
|
|
331
|
+
|
|
332
|
+
assert.match(output.content, /Hello/);
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
it('rejects an image fetch for a hostname not in the allowlist and never calls fetch', async function () {
|
|
336
|
+
apos = await t.create({
|
|
337
|
+
root: module,
|
|
338
|
+
modules: {
|
|
339
|
+
'@apostrophecms/rich-text-widget': {
|
|
340
|
+
options: {
|
|
341
|
+
imageImportAllowedHostnames: [ 'images.example.com' ]
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
});
|
|
346
|
+
|
|
347
|
+
const manager = apos.modules['@apostrophecms/rich-text-widget'];
|
|
348
|
+
const req = apos.task.getReq();
|
|
349
|
+
|
|
350
|
+
const originalFetch = global.fetch;
|
|
351
|
+
let fetchCalled = false;
|
|
352
|
+
global.fetch = async () => {
|
|
353
|
+
fetchCalled = true;
|
|
354
|
+
throw new Error('fetch should not be called');
|
|
355
|
+
};
|
|
356
|
+
|
|
357
|
+
try {
|
|
358
|
+
await assert.rejects(
|
|
359
|
+
() => manager.sanitize(req, {
|
|
360
|
+
type: '@apostrophecms/rich-text',
|
|
361
|
+
content: '<p>seed</p>',
|
|
362
|
+
import: {
|
|
363
|
+
html: '<img src="http://127.0.0.1:7777/secret.png">',
|
|
364
|
+
baseUrl: 'http://127.0.0.1:7777'
|
|
365
|
+
}
|
|
366
|
+
}, {}),
|
|
367
|
+
(err) => {
|
|
368
|
+
assert.equal(err.name, 'forbidden');
|
|
369
|
+
assert.match(err.message, /127\.0\.0\.1/);
|
|
370
|
+
assert.match(err.message, /imageImportAllowedHostnames/);
|
|
371
|
+
return true;
|
|
372
|
+
}
|
|
373
|
+
);
|
|
374
|
+
} finally {
|
|
375
|
+
global.fetch = originalFetch;
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
assert.equal(fetchCalled, false);
|
|
379
|
+
});
|
|
380
|
+
|
|
381
|
+
it('rejects rich text HTML import for non-http(s) protocols even if hostname matches', async function () {
|
|
382
|
+
apos = await t.create({
|
|
383
|
+
root: module,
|
|
384
|
+
modules: {
|
|
385
|
+
'@apostrophecms/rich-text-widget': {
|
|
386
|
+
options: {
|
|
387
|
+
imageImportAllowedHostnames: [ 'localhost' ]
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
});
|
|
392
|
+
|
|
393
|
+
const manager = apos.modules['@apostrophecms/rich-text-widget'];
|
|
394
|
+
const req = apos.task.getReq();
|
|
395
|
+
|
|
396
|
+
await assert.rejects(
|
|
397
|
+
() => manager.sanitize(req, {
|
|
398
|
+
type: '@apostrophecms/rich-text',
|
|
399
|
+
content: '<p>seed</p>',
|
|
400
|
+
import: {
|
|
401
|
+
html: '<img src="file:///etc/passwd">',
|
|
402
|
+
baseUrl: 'file://localhost'
|
|
403
|
+
}
|
|
404
|
+
}, {}),
|
|
405
|
+
(err) => {
|
|
406
|
+
assert.equal(err.name, 'forbidden');
|
|
407
|
+
return true;
|
|
408
|
+
}
|
|
409
|
+
);
|
|
410
|
+
});
|
|
411
|
+
|
|
412
|
+
it('proceeds to fetch when import URL hostname is on the allowlist', async function () {
|
|
413
|
+
apos = await t.create({
|
|
414
|
+
root: module,
|
|
415
|
+
modules: {
|
|
416
|
+
'@apostrophecms/rich-text-widget': {
|
|
417
|
+
options: {
|
|
418
|
+
imageImportAllowedHostnames: [ 'images.example.com' ]
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
});
|
|
423
|
+
|
|
424
|
+
const manager = apos.modules['@apostrophecms/rich-text-widget'];
|
|
425
|
+
const req = apos.task.getReq();
|
|
426
|
+
|
|
427
|
+
const originalFetch = global.fetch;
|
|
428
|
+
const fetchedUrls = [];
|
|
429
|
+
// Throw a unique marker error from inside fetch. If the allowlist
|
|
430
|
+
// passes, the marker error propagates; if the allowlist rejects, we
|
|
431
|
+
// would see a `forbidden` error instead.
|
|
432
|
+
const marker = new Error('FETCH_REACHED');
|
|
433
|
+
marker.name = 'FetchReached';
|
|
434
|
+
global.fetch = async (url) => {
|
|
435
|
+
fetchedUrls.push(url.toString());
|
|
436
|
+
throw marker;
|
|
437
|
+
};
|
|
438
|
+
|
|
439
|
+
try {
|
|
440
|
+
await assert.rejects(
|
|
441
|
+
() => manager.sanitize(req, {
|
|
442
|
+
type: '@apostrophecms/rich-text',
|
|
443
|
+
content: '<p>seed</p>',
|
|
444
|
+
import: {
|
|
445
|
+
html: '<img src="https://images.example.com/foo.png">',
|
|
446
|
+
baseUrl: 'https://images.example.com'
|
|
447
|
+
}
|
|
448
|
+
}, {}),
|
|
449
|
+
(err) => err.name === 'FetchReached'
|
|
450
|
+
);
|
|
451
|
+
} finally {
|
|
452
|
+
global.fetch = originalFetch;
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
assert.deepEqual(fetchedUrls, [ 'https://images.example.com/foo.png' ]);
|
|
456
|
+
});
|
|
457
|
+
|
|
458
|
+
it('helper methods reflect option configuration', async function () {
|
|
459
|
+
apos = await t.create({
|
|
460
|
+
root: module,
|
|
461
|
+
modules: {
|
|
462
|
+
'@apostrophecms/rich-text-widget': {
|
|
463
|
+
options: {
|
|
464
|
+
imageImportAllowedHostnames: [ 'Images.Example.com', '', null, 'cdn.example.com' ]
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
});
|
|
469
|
+
|
|
470
|
+
const manager = apos.modules['@apostrophecms/rich-text-widget'];
|
|
471
|
+
|
|
472
|
+
assert.deepEqual(
|
|
473
|
+
manager.getImageImportAllowedHostnames(),
|
|
474
|
+
[ 'images.example.com', 'cdn.example.com' ]
|
|
475
|
+
);
|
|
476
|
+
|
|
477
|
+
const allowed = manager.getImageImportAllowedHostnames();
|
|
478
|
+
assert.equal(
|
|
479
|
+
manager.isImageImportHostnameAllowed(new URL('https://images.example.com/foo.png'), allowed),
|
|
480
|
+
true
|
|
481
|
+
);
|
|
482
|
+
assert.equal(
|
|
483
|
+
manager.isImageImportHostnameAllowed(new URL('https://IMAGES.example.com/foo.png'), allowed),
|
|
484
|
+
true
|
|
485
|
+
);
|
|
486
|
+
assert.equal(
|
|
487
|
+
manager.isImageImportHostnameAllowed(new URL('https://attacker.example.com/foo.png'), allowed),
|
|
488
|
+
false
|
|
489
|
+
);
|
|
490
|
+
assert.equal(
|
|
491
|
+
manager.isImageImportHostnameAllowed(new URL('file:///etc/passwd'), allowed),
|
|
492
|
+
false
|
|
493
|
+
);
|
|
494
|
+
});
|
|
495
|
+
});
|
|
296
496
|
});
|
package/test/styles.js
CHANGED
|
@@ -1102,6 +1102,56 @@ describe('Styles', function () {
|
|
|
1102
1102
|
);
|
|
1103
1103
|
});
|
|
1104
1104
|
|
|
1105
|
+
it('should fall back to field.def when the doc has no value', async function () {
|
|
1106
|
+
const { renderGlobalStyles, renderScopedStyles } = await universal;
|
|
1107
|
+
|
|
1108
|
+
const schema = [
|
|
1109
|
+
{
|
|
1110
|
+
name: 'gap',
|
|
1111
|
+
type: 'range',
|
|
1112
|
+
selector: ':root',
|
|
1113
|
+
property: '--apos-layout-gap',
|
|
1114
|
+
unit: 'px',
|
|
1115
|
+
def: 24
|
|
1116
|
+
},
|
|
1117
|
+
{
|
|
1118
|
+
name: 'noDef',
|
|
1119
|
+
type: 'range',
|
|
1120
|
+
selector: ':root',
|
|
1121
|
+
property: '--no-def',
|
|
1122
|
+
unit: 'px'
|
|
1123
|
+
}
|
|
1124
|
+
];
|
|
1125
|
+
|
|
1126
|
+
// Doc carries no `gap` value (e.g. field added after doc was
|
|
1127
|
+
// created). The field's `def` is used.
|
|
1128
|
+
const fromDef = renderGlobalStyles(schema, {});
|
|
1129
|
+
assert.ok(
|
|
1130
|
+
fromDef.css.includes('--apos-layout-gap: 24px'),
|
|
1131
|
+
'renderGlobalStyles should emit field.def when doc value is absent'
|
|
1132
|
+
);
|
|
1133
|
+
assert.ok(
|
|
1134
|
+
!fromDef.css.includes('--no-def'),
|
|
1135
|
+
'fields without def should still be skipped'
|
|
1136
|
+
);
|
|
1137
|
+
|
|
1138
|
+
// Saved value overrides def.
|
|
1139
|
+
const fromDoc = renderGlobalStyles(schema, { gap: 12 });
|
|
1140
|
+
assert.ok(
|
|
1141
|
+
fromDoc.css.includes('--apos-layout-gap: 12px'),
|
|
1142
|
+
'doc value should override field.def'
|
|
1143
|
+
);
|
|
1144
|
+
|
|
1145
|
+
// Scoped renderer behaves the same way.
|
|
1146
|
+
const scoped = renderScopedStyles(schema, {}, {
|
|
1147
|
+
rootSelector: '#id'
|
|
1148
|
+
});
|
|
1149
|
+
assert.ok(
|
|
1150
|
+
scoped.css.includes('--apos-layout-gap: 24px'),
|
|
1151
|
+
'renderScopedStyles should emit field.def when doc value is absent'
|
|
1152
|
+
);
|
|
1153
|
+
});
|
|
1154
|
+
|
|
1105
1155
|
it('should include imageSizes in attachment getBrowserData', function () {
|
|
1106
1156
|
const browserData = apos.attachment.getBrowserData(apos.task.getReq());
|
|
1107
1157
|
assert.ok(
|