apostrophe 3.63.1 → 3.63.2

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,19 @@
1
1
  # Changelog
2
2
 
3
+ ## 3.63.2 (2024-03-01)
4
+
5
+ ### Security
6
+
7
+ * Always validate that method names passed to the `external-condition` API actually appear in `if` or `requiredIf`
8
+ clauses for the field in question. This fix addresses a serious security risk in which arbitrary methods of
9
+ Apostrophe modules could be called over the network, without arguments, and the results returned to the caller.
10
+ While the lack of arguments mitigates the data exfiltration risk, it is possible to cause data loss by
11
+ invoking the right method. Therefore this is an urgent upgrade for all Apostrophe 3.x users. Our thanks to the Michelin
12
+ penetration test red team for disclosing this vulnerability. All are welcome to disclose security vulnerabilities
13
+ in ApostropheCMS code via [security@apostrophecms.com](mailto:security@apostrophecms.com).
14
+ * Disable the `alwaysIframe` query parameter of the oembed proxy. This feature was never used in Apostrophe core, and could be misused to carry out arbitrary GET requests in the context of an iframe, although it could not be used to exfiltrate any information other than the success or failure of the request, and the request was still performed by the user's browser only. Thanks to the Michelin team.
15
+ * Remove vestigial A2 code relating to polymorphic relationship fields. The code in question had no relevance to the way such a feature would be implemented in A3, and could be used to cause a denial of service by crashing and restarting the process. Thanks to the Michelin team.
16
+
3
17
  ## 3.63.1 (2024-02-22)
4
18
 
5
19
  ### Security
@@ -67,7 +67,9 @@ module.exports = {
67
67
  //
68
68
  // If `options.alwaysIframe` is true, the result is a simple
69
69
  // iframe of the URL. If `options.iframeHeight` is set, the iframe
70
- // has that height in pixels, otherwise it is left to CSS.
70
+ // has that height in pixels, otherwise it is left to CSS. These iframe-related
71
+ // options are not used in core Apostrophe and remain available to project-level
72
+ // application code for backwards compatibility purposes only.
71
73
  //
72
74
  // The `options` object is passed on to `oembetter.fetch`.
73
75
  //
@@ -127,6 +129,9 @@ module.exports = {
127
129
  await self.apos.cache.set('@apostrophecms/oembed', key, response, self.options.cacheLifetime);
128
130
  return response;
129
131
  },
132
+ // Not currently used. Present for backwards compatibility
133
+ // in the event that application code used it directly.
134
+ //
130
135
  // Given a URL, return an oembed response for it
131
136
  // which just iframes the URL given. Fetches the page
132
137
  // first to get the title property.
@@ -219,8 +224,10 @@ module.exports = {
219
224
  async query(req) {
220
225
  const url = self.apos.launder.string(req.query.url);
221
226
  const options = {
222
- alwaysIframe: self.apos.launder.boolean(req.query.alwaysIframe),
223
- iframeHeight: self.apos.launder.integer(req.query.iframeHeight)
227
+ // This feature is no longer available via the
228
+ // oembed proxy because it posed an SSRF risk and
229
+ // was never used in core Apostrophe widgets
230
+ alwaysIframe: false
224
231
  };
225
232
 
226
233
  const result = await self.query(req, url, options);
@@ -1,4 +1,3 @@
1
- const _ = require('lodash');
2
1
  const migrations = require('./lib/migrations.js');
3
2
 
4
3
  module.exports = {
@@ -14,23 +13,5 @@ module.exports = {
14
13
  return {
15
14
  ...migrations(self)
16
15
  };
17
- },
18
- routes(self) {
19
- return {
20
- post: {
21
- polymorphicChooserModal(req, res) {
22
- const limit = self.apos.launder.integer(req.body.limit);
23
- const field = req.body.field;
24
- const types = _.map(field.withType, function (name) {
25
- return self.apos.doc.getManager(name);
26
- });
27
- return self.send(req, 'chooserModal', {
28
- options: self.options,
29
- limit: limit,
30
- types: types
31
- });
32
- }
33
- }
34
- };
35
16
  }
36
17
  };
@@ -709,6 +709,7 @@ module.exports = {
709
709
  }
710
710
  if (hasParenthesis && !methodKey.endsWith('()')) {
711
711
  self.apos.util.warn(`The method "${methodDefinition}" defined in the "${fieldName}" field should be written without argument: "${methodDefinition}()".`);
712
+ methodKey = methodDefinition + '()';
712
713
  }
713
714
 
714
715
  const [ methodName, moduleName = fieldModuleName ] = methodDefinition
@@ -1743,7 +1744,11 @@ module.exports = {
1743
1744
  const conditionKey = self.apos.launder.string(req.query.conditionKey);
1744
1745
 
1745
1746
  const field = self.getFieldById(fieldId);
1746
-
1747
+ const allowedKeys = getFieldExternalConditionKeys(field);
1748
+ // We must tolerate arguments at this stage as we only warn about them later
1749
+ if (!allowedKeys.includes(conditionKey.replace(/\(.*\)/, '()'))) {
1750
+ throw self.apos.error('forbidden', `${conditionKey} is not registered as an external condition.`);
1751
+ }
1747
1752
  try {
1748
1753
  const result = await self.evaluateMethod(req, conditionKey, field.name, field.moduleName, docId);
1749
1754
  return { result };
@@ -1776,3 +1781,27 @@ module.exports = {
1776
1781
  };
1777
1782
  }
1778
1783
  };
1784
+
1785
+ function getFieldExternalConditionKeys(field) {
1786
+ const conditionTypes = [ 'if', 'requiredIf' ];
1787
+ return [
1788
+ ...new Set(conditionTypes.map(conditionType => getConditionTypeExternalConditionKeys(field[conditionType] || {})).flat())
1789
+ ];
1790
+ }
1791
+
1792
+ function getConditionTypeExternalConditionKeys(conditions) {
1793
+ let results = [];
1794
+ if (conditions.$or) {
1795
+ results = conditions.$or.map(getConditionTypeExternalConditionKeys).flat();
1796
+ }
1797
+ for (const key of Object.keys(conditions)) {
1798
+ if (key === '$or') {
1799
+ results = [ ...results, conditions.$or.map(getConditionTypeExternalConditionKeys).flat() ];
1800
+ } else {
1801
+ if (key.endsWith('()')) {
1802
+ results.push(key);
1803
+ }
1804
+ }
1805
+ }
1806
+ return results;
1807
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "apostrophe",
3
- "version": "3.63.1",
3
+ "version": "3.63.2",
4
4
  "description": "The Apostrophe Content Management System.",
5
5
  "main": "index.js",
6
6
  "scripts": {
package/test/schemas.js CHANGED
@@ -2052,7 +2052,10 @@ describe('Schemas', function() {
2052
2052
  it('should call the evaluate-external-condition API successfully', async function() {
2053
2053
  apos.schema.fieldsById['some-field-id'] = {
2054
2054
  name: 'someField',
2055
- moduleName: 'external-condition'
2055
+ moduleName: 'external-condition',
2056
+ if: {
2057
+ 'externalCondition()': 'yes'
2058
+ }
2056
2059
  };
2057
2060
 
2058
2061
  const res = await apos.http.get('/api/v1/@apostrophecms/schema/evaluate-external-condition?fieldId=some-field-id&docId=some-doc-id&conditionKey=externalCondition()', {});
@@ -2062,7 +2065,10 @@ describe('Schemas', function() {
2062
2065
  it('should warn when an argument is passed in the external condition key via the evaluate-external-condition API', async function() {
2063
2066
  apos.schema.fieldsById['some-field-id'] = {
2064
2067
  name: 'someField',
2065
- moduleName: 'external-condition'
2068
+ moduleName: 'external-condition',
2069
+ if: {
2070
+ 'externalCondition()': 'yes'
2071
+ }
2066
2072
  };
2067
2073
 
2068
2074
  const res = await apos.http.get('/api/v1/@apostrophecms/schema/evaluate-external-condition?fieldId=some-field-id&docId=some-doc-id&conditionKey=externalCondition(letsNotArgue)', {});
@@ -2081,7 +2087,7 @@ describe('Schemas', function() {
2081
2087
  await apos.http.get('/api/v1/@apostrophecms/schema/evaluate-external-condition?fieldId=some-field-id&docId=some-doc-id&conditionKey=externalCondition()', {});
2082
2088
  } catch (error) {
2083
2089
  assert(error.status = 400);
2084
- assert(error.body.message === 'The "unknown-module" module defined in the "someField" field does not exist.');
2090
+ assert.strictEqual(error.body.message, 'externalCondition() is not registered as an external condition.');
2085
2091
  return;
2086
2092
  }
2087
2093
  throw new Error('should have thrown');
@@ -2097,7 +2103,7 @@ describe('Schemas', function() {
2097
2103
  await apos.http.get('/api/v1/@apostrophecms/schema/evaluate-external-condition?fieldId=some-field-id&docId=some-doc-id&conditionKey=unknownMethod()', {});
2098
2104
  } catch (error) {
2099
2105
  assert(error.status = 400);
2100
- assert(error.body.message === 'The "unknownMethod" method from "external-condition" module defined in the "someField" field does not exist.');
2106
+ assert.strictEqual(error.body.message, 'unknownMethod() is not registered as an external condition.');
2101
2107
  return;
2102
2108
  }
2103
2109
  throw new Error('should have thrown');