apostrophe 2.223.0 → 2.224.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 CHANGED
@@ -1,5 +1,18 @@
1
1
  # Changelog
2
2
 
3
+ ## 2.224.0 (2023-02-01)
4
+
5
+ ### Adds
6
+
7
+ * If about to 404, redirect uppercase URLs automatically to their lowercase version - can be disabled with `redirectFailedUpperCaseUrls: false` in `apostrophe-pages/index.js` options.
8
+
9
+ ## 2.223.1 (2022-12-21)
10
+
11
+ ### Fixes
12
+
13
+ * Replace [`credential`](https://www.npmjs.com/package/credential) package with [`credentials`](https://www.npmjs.com/package/credentials) to fix the [`mout` Prototype Pollution vulnerability scanner warning](https://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2020-7792). There was no actual vulnerability in Apostrophe or credential due to the way the module was used.
14
+ * Fix vulnerability scanner warning by removing package `deep-get-set` used in areas. There was no actual vulnerability in Apostrophe due to the way the module was used.
15
+
3
16
  ## 2.223.0 (2022-11-28)
4
17
 
5
18
  ### Adds
@@ -1,6 +1,5 @@
1
1
  var _ = require('@sailshq/lodash');
2
2
  var async = require('async');
3
- var deep = require('deep-get-set');
4
3
 
5
4
  module.exports = function(self, options) {
6
5
 
@@ -213,13 +212,13 @@ module.exports = function(self, options) {
213
212
  // and is not an area.
214
213
 
215
214
  if (components.length > 1) {
216
- var existing = deep(doc, dotPath);
215
+ var existing = _.get(doc, dotPath);
217
216
  if (existing && (existing.type !== 'area')) {
218
217
  return callback(new Error('forbidden'));
219
218
  }
220
219
  }
221
220
 
222
- var existingArea = deep(doc, dotPath);
221
+ var existingArea = _.get(doc, dotPath);
223
222
  var existingItems = (existingArea && existingArea.items) || [];
224
223
  if (_.isEqual(self.apos.utils.clonePermanent(items), self.apos.utils.clonePermanent(existingItems))) {
225
224
  // No real change — don't waste a version and clutter the database.
@@ -228,7 +227,7 @@ module.exports = function(self, options) {
228
227
  return setImmediate(callback);
229
228
  }
230
229
 
231
- deep(doc, dotPath, {
230
+ _.set(doc, dotPath, {
232
231
  type: 'area',
233
232
  items: items
234
233
  });
@@ -289,7 +289,7 @@ module.exports = {
289
289
  // Users with the `disabled` property set to true may not log in.
290
290
  // Passwords are verified via the `verifyPassword` method of
291
291
  // [apostrophe-users](/reference/modules/apostrophe-users), which is
292
- // powered by the [credential](https://npmjs.org/package/credential) module.
292
+ // powered by the [credentials](https://npmjs.org/package/credentials) module.
293
293
 
294
294
  self.enableLocalStrategy = function() {
295
295
  self.passport.use(new LocalStrategy(self.verifyLogin));
@@ -255,6 +255,8 @@ module.exports = {
255
255
 
256
256
  alias: 'pages',
257
257
 
258
+ redirectFailedUpperCaseUrls: true,
259
+
258
260
  types: [
259
261
  {
260
262
  // So that the minimum parked pages don't result in an error when home has no manager. -Tom
@@ -324,12 +326,14 @@ module.exports = {
324
326
  name: 'trash',
325
327
  label: 'Trash'
326
328
  }
327
- ].concat(options.apos.docs.trashInSchema ? [
328
- {
329
- name: 'rescue',
330
- label: 'Rescue'
331
- }
332
- ] : []).concat([
329
+ ].concat(options.apos.docs.trashInSchema
330
+ ? [
331
+ {
332
+ name: 'rescue',
333
+ label: 'Rescue'
334
+ }
335
+ ]
336
+ : []).concat([
333
337
  {
334
338
  name: 'publish',
335
339
  label: 'Publish'
@@ -429,7 +429,13 @@ module.exports = function(self, options) {
429
429
  .trash(null)
430
430
  .published(null)
431
431
  .areas(false)
432
- .ancestors(_.assign({ depth: 1, trash: null, published: null, areas: false, permission: false }, options.filters || {}))
432
+ .ancestors(_.assign({
433
+ depth: 1,
434
+ trash: null,
435
+ published: null,
436
+ areas: false,
437
+ permission: false
438
+ }, options.filters || {}))
433
439
  .applyFilters(options.filters || {})
434
440
  .toObject(function(err, page) {
435
441
  if (err) {
@@ -460,7 +466,13 @@ module.exports = function(self, options) {
460
466
  .trash(null)
461
467
  .published(null)
462
468
  .areas(false)
463
- .ancestors(_.assign({ depth: 1, trash: null, published: null, areas: false, permission: false }, options.filters || {}))
469
+ .ancestors(_.assign({
470
+ depth: 1,
471
+ trash: null,
472
+ published: null,
473
+ areas: false,
474
+ permission: false
475
+ }, options.filters || {}))
464
476
  .applyFilters(options.filters || {})
465
477
  .toObject(function(err, page) {
466
478
  if (err) {
@@ -516,7 +528,11 @@ module.exports = function(self, options) {
516
528
  function nudgeNewPeers(callback) {
517
529
  // Nudge down the pages that should now follow us
518
530
  // Always remember multi: true
519
- self.apos.docs.db.update(mergeCriteria({ path: self.matchDescendants(parent), level: parent.level + 1, rank: { $gte: rank } }), { $inc: { rank: 1 } }, { multi: true }, function(err, count) {
531
+ self.apos.docs.db.update(mergeCriteria({
532
+ path: self.matchDescendants(parent),
533
+ level: parent.level + 1,
534
+ rank: { $gte: rank }
535
+ }), { $inc: { rank: 1 } }, { multi: true }, function(err, count) {
520
536
  return callback(err);
521
537
  });
522
538
  }
@@ -738,7 +754,12 @@ module.exports = function(self, options) {
738
754
  [
739
755
  function getPage(cb) {
740
756
  // check permissions and load page to trash/untrash
741
- return self.find(req, { _id: _id }).permission('edit').trash(null).ancestors({ depth: 1, published: null, trash: null, areas: false }).toObject((err, _page) => {
757
+ return self.find(req, { _id: _id }).permission('edit').trash(null).ancestors({
758
+ depth: 1,
759
+ published: null,
760
+ trash: null,
761
+ areas: false
762
+ }).toObject((err, _page) => {
742
763
  page = _page;
743
764
  tree.push(page);
744
765
  parent = page._ancestors[0];
@@ -876,14 +897,17 @@ module.exports = function(self, options) {
876
897
  var parent;
877
898
  var changed = [];
878
899
 
879
- return async.series([findTrash, findPage, movePage], function(err) {
900
+ return async.series([ findTrash, findPage, movePage ], function(err) {
880
901
  return callback(err, parent && parent.slug, changed);
881
902
  });
882
903
 
883
904
  function findTrash(callback) {
884
905
  // Always only one trash page at level 1, so we don't have
885
906
  // to hardcode the slug
886
- return self.find(req, { trash: true, level: 1 })
907
+ return self.find(req, {
908
+ trash: true,
909
+ level: 1
910
+ })
887
911
  .permission(false)
888
912
  .published(null)
889
913
  .trash(null)
@@ -899,7 +923,12 @@ module.exports = function(self, options) {
899
923
 
900
924
  function findPage(callback) {
901
925
  // Also checks permissions
902
- return self.find(req, { _id: _id }).permission('edit').ancestors({ depth: 1, published: null, trash: null, areas: false }).toObject(function(err, _page) {
926
+ return self.find(req, { _id: _id }).permission('edit').ancestors({
927
+ depth: 1,
928
+ published: null,
929
+ trash: null,
930
+ areas: false
931
+ }).toObject(function(err, _page) {
903
932
  if (err || (!_page)) {
904
933
  return callback('Page not found');
905
934
  }
@@ -943,7 +972,12 @@ module.exports = function(self, options) {
943
972
 
944
973
  function findPage(callback) {
945
974
  // Also checks permissions
946
- return self.find(req, { _id: _id }).permission('publish').trash(true).ancestors({ depth: 1, published: null, trash: null, areas: false }).toObject(function(err, _page) {
975
+ return self.find(req, { _id: _id }).permission('publish').trash(true).ancestors({
976
+ depth: 1,
977
+ published: null,
978
+ trash: null,
979
+ areas: false
980
+ }).toObject(function(err, _page) {
947
981
  if (err || (!_page)) {
948
982
  return callback('Page not found');
949
983
  }
@@ -1134,6 +1168,12 @@ module.exports = function(self, options) {
1134
1168
  if (self.isFound(req)) {
1135
1169
  return callback(null);
1136
1170
  }
1171
+
1172
+ if (options.redirectFailedUpperCaseUrls && /[A-Z]/.test(req.path)) {
1173
+ req.redirect = self.apos.urls.build(req.path.toLowerCase(), req.query);
1174
+ return callback(null);
1175
+ }
1176
+
1137
1177
  req.data.suggestedSearch = self.apos.utils.slugify(req.url, { separator: ' ' });
1138
1178
  req.notFound = true;
1139
1179
  req.res.statusCode = 404;
@@ -1141,7 +1181,7 @@ module.exports = function(self, options) {
1141
1181
  // Give the browser a chance to do something interesting with a 404.
1142
1182
  // This is often a better idea than doing a heavy fallback search
1143
1183
  // server side, because those are triggered heavily by bots
1144
- req.browserCall("apos.emit('notfound', ?)", {
1184
+ req.browserCall('apos.emit(\'notfound\', ?)', {
1145
1185
  suggestedSearch: req.data.suggestedSearch,
1146
1186
  url: req.url
1147
1187
  });
@@ -1421,7 +1461,10 @@ module.exports = function(self, options) {
1421
1461
 
1422
1462
  self.ensurePathIndex = function(callback) {
1423
1463
  var params = self.getPathIndexParams();
1424
- return self.apos.docs.db.ensureIndex(params, { unique: true, sparse: true }, callback);
1464
+ return self.apos.docs.db.ensureIndex(params, {
1465
+ unique: true,
1466
+ sparse: true
1467
+ }, callback);
1425
1468
  };
1426
1469
 
1427
1470
  self.getPathIndexParams = function() {
@@ -1502,8 +1545,14 @@ module.exports = function(self, options) {
1502
1545
  var matchParentPathPrefix = new RegExp('^' + self.apos.utils.regExpQuote(originalPath + '/'));
1503
1546
  var matchParentSlugPrefix = new RegExp('^' + self.apos.utils.regExpQuote(originalSlug + '/'));
1504
1547
  var done = false;
1505
- var cursor = self.apos.docs.db.findWithProjection(mergeCriteria({ path: matchParentPathPrefix }), { slug: 1, path: 1, level: 1 });
1506
- return async.whilst(function() { return !done; }, function(callback) {
1548
+ var cursor = self.apos.docs.db.findWithProjection(mergeCriteria({ path: matchParentPathPrefix }), {
1549
+ slug: 1,
1550
+ path: 1,
1551
+ level: 1
1552
+ });
1553
+ return async.whilst(function() {
1554
+ return !done;
1555
+ }, function(callback) {
1507
1556
  return cursor.nextObject(function(err, desc) {
1508
1557
  if (err) {
1509
1558
  return callback(err);
@@ -1996,10 +2045,10 @@ module.exports = function(self, options) {
1996
2045
  self.validateTypeChoices = function() {
1997
2046
  _.each(self.typeChoices, function(choice) {
1998
2047
  if (!choice.name) {
1999
- throw new Error("One of the page types specified for your 'types' option has no 'name' property.");
2048
+ throw new Error('One of the page types specified for your \'types\' option has no \'name\' property.');
2000
2049
  }
2001
2050
  if (!choice.label) {
2002
- throw new Error("One of the page types specified for your 'types' option has no 'label' property.");
2051
+ throw new Error('One of the page types specified for your \'types\' option has no \'label\' property.');
2003
2052
  }
2004
2053
  });
2005
2054
  };
@@ -2060,7 +2109,10 @@ module.exports = function(self, options) {
2060
2109
 
2061
2110
  self.removeSlugFromHomepageSchema = function(page, schema) {
2062
2111
  if (page.level === 0) {
2063
- schema = _.reject(schema, { type: 'slug', name: 'slug' });
2112
+ schema = _.reject(schema, {
2113
+ type: 'slug',
2114
+ name: 'slug'
2115
+ });
2064
2116
  }
2065
2117
  return schema;
2066
2118
  };
@@ -37,7 +37,7 @@
37
37
  //
38
38
  // For security the `password` property is not stored as plaintext and
39
39
  // is not kept in the aposDocs collection. Instead, it is hashed and salted
40
- // using the `credential` module and the resulting hash is stored
40
+ // using the `credentials` module and the resulting hash is stored
41
41
  // in a separate `aposUsersSafe` collection.
42
42
  //
43
43
  // Additional secrets may be hashed in this way. If you set the
@@ -62,7 +62,7 @@
62
62
 
63
63
  var async = require('async');
64
64
  var _ = require('@sailshq/lodash');
65
- var credential = require('credential');
65
+ var credentials = require('credentials');
66
66
  var Promise = require('bluebird');
67
67
 
68
68
  module.exports = {
@@ -82,7 +82,6 @@ module.exports = {
82
82
  slugPrefix: 'user-',
83
83
 
84
84
  afterConstruct: function(self, callback) {
85
- self.initializeCredential();
86
85
  self.addOurTrashPrefixFields();
87
86
  self.enableSecrets();
88
87
  self.addNonNullJoinMigration();
@@ -447,14 +446,17 @@ module.exports = {
447
446
  if (!doc[secret]) {
448
447
  return callback(null);
449
448
  }
450
- return self.pw.hash(doc[secret], function(err, hash) {
451
- if (err) {
452
- return callback(err);
453
- }
454
- delete doc[secret];
455
- safeUser[secret + 'Hash'] = hash;
456
- return callback(null);
457
- });
449
+ return credentials
450
+ .hash(doc[secret])
451
+ .then(hash => {
452
+ const annotatedHash = JSON.stringify(
453
+ Object.assign(JSON.parse(hash), { credentials3: true })
454
+ );
455
+ delete doc[secret];
456
+ safeUser[secret + 'Hash'] = annotatedHash;
457
+ callback(null);
458
+ })
459
+ .catch(callback);
458
460
  };
459
461
 
460
462
  // Verify the given password by checking it against the
@@ -473,7 +475,7 @@ module.exports = {
473
475
  // in `options.secrets` when configuring this module or via
474
476
  // `addSecrets` are not stored as plaintext and are not kept in the
475
477
  // aposDocs collection. Instead, they are hashed and salted using the
476
- // `credential` module and the resulting hash is stored
478
+ // `credentials` module and the resulting hash is stored
477
479
  // in a separate `aposUsersSafe` collection. This method
478
480
  // can be used to verify that `attempt` matches the
479
481
  // previously hashed value for the property named `secret`,
@@ -504,18 +506,33 @@ module.exports = {
504
506
  if (!safeUser) {
505
507
  return callback(new Error('No such user in the safe.'));
506
508
  }
507
- return self.pw.verify(safeUser[secret + 'Hash'], attempt, function(err, isValid) {
508
- if (err) {
509
- return callback(err);
510
- }
511
- if (!isValid) {
512
- return callback(new Error('Incorrect ' + secret));
513
- }
514
- return callback(null);
515
- });
509
+ return credentials
510
+ .verify(migrate(safeUser[secret + 'Hash']), attempt)
511
+ .then(isValid => {
512
+ if (!isValid) {
513
+ throw new Error('Incorrect ' + secret);
514
+ }
515
+ callback(null);
516
+ })
517
+ .catch(callback);
516
518
  }
517
519
  }, callback);
518
520
  }
521
+ function migrate(json) {
522
+ const data = JSON.parse(json);
523
+
524
+ // Do not re-encode salt generated by credentials@3
525
+ if (data.credentials3) {
526
+ return json;
527
+ }
528
+
529
+ return JSON.stringify(
530
+ Object.assign(
531
+ data,
532
+ { salt: Buffer.from(data.salt, 'utf8').toString('base64') }
533
+ )
534
+ );
535
+ }
519
536
  };
520
537
 
521
538
  // Forget the secret associated with the property name
@@ -651,11 +668,6 @@ module.exports = {
651
668
  });
652
669
  };
653
670
 
654
- // Initialize the [credential](https://npmjs.org/package/credential) module.
655
- self.initializeCredential = function() {
656
- self.pw = credential();
657
- };
658
-
659
671
  self.apos.tasks.add('apostrophe-users', 'add',
660
672
  'Usage: node app apostrophe-users:add username groupname\n\n' +
661
673
  'This adds a new user and assigns them to a group.\n' +
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "apostrophe",
3
- "version": "2.223.0",
3
+ "version": "2.224.0",
4
4
  "description": "The Apostrophe Content Management System.",
5
5
  "main": "index.js",
6
6
  "scripts": {
@@ -36,9 +36,8 @@
36
36
  "connect-flash": "^0.1.1",
37
37
  "connect-multiparty": "^2.2.0",
38
38
  "cookie-parser": "^1.4.5",
39
- "credential": "^2.0.0",
39
+ "credentials": "^3.0.2",
40
40
  "cuid": "^1.3.8",
41
- "deep-get-set": "^1.1.1",
42
41
  "diff": "^4.0.1",
43
42
  "emulate-mongo-2-driver": "^1.2.3",
44
43
  "express": "^4.17.1",
package/test/pages.js CHANGED
@@ -433,7 +433,6 @@ describe('Pages', function() {
433
433
  assert(body.match(/Home: \//));
434
434
  // Does the response prove that data.home._children was available?
435
435
  assert(body.match(/Tab: \/another-parent/));
436
- // console.log(body);
437
436
  return done();
438
437
  });
439
438
  });
@@ -447,7 +446,6 @@ describe('Pages', function() {
447
446
  assert(body.match(/Home: \//));
448
447
  // Does the response prove that data.home._children was available?
449
448
  assert(body.match(/Tab: \/another-parent/));
450
- // console.log(body);
451
449
  return done();
452
450
  });
453
451
  });
@@ -475,6 +473,29 @@ describe('Pages', function() {
475
473
  });
476
474
  });
477
475
 
476
+ it('should redirect to url with path in lower case by default', function(done) {
477
+ return request('http://localhost:7900/chiLD', function (err, response, body) {
478
+ assert(!err);
479
+ assert.equal(response.statusCode, 200);
480
+ assert(body.match(/Sing to me, Oh Muse./));
481
+ assert(body.match(/Home: \//));
482
+ assert(body.match(/Tab: \/another-parent/));
483
+ return done();
484
+ });
485
+ });
486
+
487
+ it('should not redirect to url with path in lower case if the option is disabled', function(done) {
488
+ apos.pages.options.redirectFailedUpperCaseUrls = false;
489
+
490
+ return request('http://localhost:7900/chiLD', function (err, response, body) {
491
+ assert(!err);
492
+ assert.equal(response.statusCode, 404);
493
+ apos.pages.options.redirectFailedUpperCaseUrls = true;
494
+ return done();
495
+ });
496
+
497
+ });
498
+
478
499
  it('should detect that the home page is an ancestor of any page except itself', function() {
479
500
  assert(
480
501
  apos.pages.isAncestorOf({
@@ -561,8 +582,8 @@ describe('Pages', function() {
561
582
  assert.equal(page.path, '/newish-page');
562
583
  done();
563
584
  });
564
- });
565
585
 
586
+ });
566
587
  });
567
588
 
568
589
  describe('Pages with trashInSchema', function() {
package/test/users.js CHANGED
@@ -103,6 +103,35 @@ describe('Users', function() {
103
103
  });
104
104
  });
105
105
 
106
+ it('should verify a user password created with former credential package', function(done) {
107
+ var req = apos.tasks.getReq();
108
+ var user = apos.users.newInstance();
109
+
110
+ user.firstName = 'Old';
111
+ user.lastName = 'User';
112
+ user.title = 'Old User';
113
+ user.username = 'olduser';
114
+ user.password = 'passwordThatThroughOldCredentialPackageHashing';
115
+ user.email = 'old@user.com';
116
+
117
+ apos.users.insert(req, user, async function(err) {
118
+ assert(!err);
119
+
120
+ // A password hash that were generated by the former credential package:
121
+ var oldPasswordHashSimulated =
122
+ '{"hash":"HKBAyPWKKnKnXzF0yflRUEeeJZk1njKaX3IqT6Ml056OdMWIsDRqfJeHCqxI3jA9HEFNzuPEhw0m98dA8ju8xRpj","salt":"P2X4+Ex0rrHSPBRv0TCGOTqXmuT2JDspNLc/0Uln6jcZWACUpgBz+DDpfP9DFZcPG9cMlwMaHEKw3MVq02af8RSn","keyLength":66,"hashMethod":"pbkdf2","iterations":2853010}';
123
+
124
+ apos.users.safe.update({ username: 'olduser' }, { $set: { passwordHash: oldPasswordHashSimulated } }, function() {
125
+ apos.users.verifyPassword(user, 'passwordThatThroughOldCredentialPackageHashing', function(err) {
126
+ assert(!err);
127
+ apos.users.safe.remove({ username: 'olduser' }, function() {
128
+ done();
129
+ });
130
+ });
131
+ });
132
+ });
133
+ });
134
+
106
135
  it('should not verify an incorrect user password', function(done) {
107
136
  apos.users.find(apos.tasks.getReq(), { username: 'JaneD' })
108
137
  .toObject(function(err, user) {