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.
Files changed (48) hide show
  1. package/.claude/settings.local.json +15 -0
  2. package/CHANGELOG.md +34 -0
  3. package/modules/@apostrophecms/admin-bar/ui/apos/components/TheAposAdminBar.vue +0 -1
  4. package/modules/@apostrophecms/admin-bar/ui/apos/components/TheAposAdminBarMenu.vue +25 -8
  5. package/modules/@apostrophecms/admin-bar/ui/apos/components/TheAposContextBreakpointPreviewMode.vue +9 -0
  6. package/modules/@apostrophecms/admin-bar/ui/apos/components/TheAposContextTitle.vue +20 -2
  7. package/modules/@apostrophecms/area/index.js +10 -5
  8. package/modules/@apostrophecms/area/ui/apos/components/AposAreaWidget.vue +2 -0
  9. package/modules/@apostrophecms/command-menu/ui/apos/components/TheAposCommandMenu.vue +11 -1
  10. package/modules/@apostrophecms/i18n/i18n/en.json +8 -0
  11. package/modules/@apostrophecms/i18n/index.js +1 -8
  12. package/modules/@apostrophecms/image-widget/index.js +29 -1
  13. package/modules/@apostrophecms/layout-widget/index.js +124 -2
  14. package/modules/@apostrophecms/layout-widget/ui/apos/components/AposAreaLayoutEditor.vue +89 -6
  15. package/modules/@apostrophecms/layout-widget/ui/apos/components/AposGridLayout.vue +2 -2
  16. package/modules/@apostrophecms/layout-widget/ui/apos/components/AposGridManager.vue +2 -2
  17. package/modules/@apostrophecms/layout-widget/ui/apos/layout.css +8 -0
  18. package/modules/@apostrophecms/layout-widget/ui/src/layout.css +1 -1
  19. package/modules/@apostrophecms/layout-widget/views/widget.html +3 -3
  20. package/modules/@apostrophecms/login/index.js +13 -15
  21. package/modules/@apostrophecms/modal/ui/apos/components/AposModal.vue +2 -1
  22. package/modules/@apostrophecms/oembed/index.js +18 -13
  23. package/modules/@apostrophecms/recently-edited/ui/apos/components/AposRecentlyEditedIcon.vue +2 -0
  24. package/modules/@apostrophecms/rich-text-widget/index.js +36 -0
  25. package/modules/@apostrophecms/schema/ui/apos/logic/AposInputCheckboxes.js +1 -1
  26. package/modules/@apostrophecms/schema/ui/apos/logic/AposInputRadio.js +1 -1
  27. package/modules/@apostrophecms/styles/index.js +16 -0
  28. package/modules/@apostrophecms/styles/lib/handlers.js +6 -0
  29. package/modules/@apostrophecms/styles/lib/methods.js +93 -0
  30. package/modules/@apostrophecms/styles/lib/presets.js +17 -0
  31. package/modules/@apostrophecms/styles/ui/apos/universal/render.mjs +10 -1
  32. package/modules/@apostrophecms/submitted-draft/ui/apos/components/AposSubmittedDraftIcon.vue +1 -0
  33. package/modules/@apostrophecms/template/views/outerLayoutBase.html +1 -1
  34. package/modules/@apostrophecms/ui/ui/apos/components/AposButton.vue +14 -2
  35. package/modules/@apostrophecms/ui/ui/apos/components/AposContextMenu.vue +29 -5
  36. package/modules/@apostrophecms/ui/ui/apos/components/AposContextMenuDialog.vue +8 -0
  37. package/modules/@apostrophecms/ui/ui/apos/components/AposContextMenuItem.vue +5 -2
  38. package/modules/@apostrophecms/ui/ui/apos/components/AposLocalePicker.vue +14 -1
  39. package/modules/@apostrophecms/ui/ui/apos/scss/global/_utilities.scss +13 -1
  40. package/modules/@apostrophecms/util/index.js +4 -0
  41. package/modules/@apostrophecms/widget-type/index.js +6 -0
  42. package/modules/@apostrophecms/widget-type/ui/apos/components/AposWidgetEditor.vue +32 -0
  43. package/package.json +5 -5
  44. package/test/add-missing-schema-fields-project/node_modules/.package-lock.json +2 -2
  45. package/test/layout-widget-gap.js +530 -0
  46. package/test/login.js +122 -1
  47. package/test/rich-text-widget.js +200 -0
  48. package/test/styles.js +50 -0
@@ -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(