@webex/webex-core 3.12.0-next.5 → 3.12.0-next.51

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 (56) hide show
  1. package/README.md +5 -2
  2. package/dist/config.js +38 -0
  3. package/dist/config.js.map +1 -1
  4. package/dist/credentials-config.js +12 -0
  5. package/dist/credentials-config.js.map +1 -1
  6. package/dist/index.js +7 -0
  7. package/dist/index.js.map +1 -1
  8. package/dist/interceptors/catalog-url.js +87 -0
  9. package/dist/interceptors/catalog-url.js.map +1 -0
  10. package/dist/interceptors/payload-transformer.js +25 -4
  11. package/dist/interceptors/payload-transformer.js.map +1 -1
  12. package/dist/lib/batcher.js +23 -7
  13. package/dist/lib/batcher.js.map +1 -1
  14. package/dist/lib/credentials/credentials.js +103 -4
  15. package/dist/lib/credentials/credentials.js.map +1 -1
  16. package/dist/lib/credentials/token.js +1 -1
  17. package/dist/lib/domains.js +90 -0
  18. package/dist/lib/domains.js.map +1 -0
  19. package/dist/lib/services/service-catalog.js +108 -18
  20. package/dist/lib/services/service-catalog.js.map +1 -1
  21. package/dist/lib/services/services.js +232 -34
  22. package/dist/lib/services/services.js.map +1 -1
  23. package/dist/lib/services-v2/service-catalog.js +14 -13
  24. package/dist/lib/services-v2/service-catalog.js.map +1 -1
  25. package/dist/lib/services-v2/services-v2.js +241 -37
  26. package/dist/lib/services-v2/services-v2.js.map +1 -1
  27. package/dist/plugins/logger.js +1 -1
  28. package/dist/webex-core.js +11 -3
  29. package/dist/webex-core.js.map +1 -1
  30. package/package.json +13 -13
  31. package/src/config.js +42 -0
  32. package/src/credentials-config.js +13 -0
  33. package/src/index.js +1 -0
  34. package/src/interceptors/catalog-url.js +66 -0
  35. package/src/interceptors/payload-transformer.js +23 -1
  36. package/src/lib/batcher.js +25 -10
  37. package/src/lib/credentials/credentials.js +93 -3
  38. package/src/lib/domains.ts +94 -0
  39. package/src/lib/services/service-catalog.js +112 -16
  40. package/src/lib/services/services.js +208 -20
  41. package/src/lib/services-v2/service-catalog.ts +16 -12
  42. package/src/lib/services-v2/services-v2.ts +218 -21
  43. package/src/webex-core.js +8 -0
  44. package/test/fixtures/activation-email.ts +22 -0
  45. package/test/integration/spec/services/services.js +183 -112
  46. package/test/integration/spec/services-v2/services-v2.js +169 -98
  47. package/test/unit/spec/credentials/credentials.js +247 -3
  48. package/test/unit/spec/interceptors/auth.js +56 -0
  49. package/test/unit/spec/interceptors/catalog-url.js +224 -0
  50. package/test/unit/spec/interceptors/payload-transformer.js +119 -1
  51. package/test/unit/spec/lib/batcher.js +56 -0
  52. package/test/unit/spec/services/service-catalog.js +368 -11
  53. package/test/unit/spec/services/services.js +440 -2
  54. package/test/unit/spec/services-v2/service-catalog.ts +134 -11
  55. package/test/unit/spec/services-v2/services-v2.ts +467 -0
  56. package/test/unit/spec/webex-core.js +77 -1
@@ -5,6 +5,178 @@
5
5
  import {assert} from '@webex/test-helper-chai';
6
6
  import MockWebex from '@webex/test-helper-mock-webex';
7
7
  import {Services} from '@webex/webex-core';
8
+ import {
9
+ matchesCatalogUrl,
10
+ matchesParsedCatalogUrl,
11
+ parseCatalogUrl,
12
+ } from '../../../../src/lib/services/service-catalog';
13
+
14
+ describe('matchesCatalogUrl()', () => {
15
+ describe('origin validation', () => {
16
+ it('returns true when origins match exactly', () => {
17
+ assert.isTrue(
18
+ matchesCatalogUrl('https://example.com/api/v1/users', 'https://example.com/api/v1')
19
+ );
20
+ });
21
+
22
+ it('returns false when hosts differ', () => {
23
+ assert.isFalse(
24
+ matchesCatalogUrl('https://other.com/api/v1/users', 'https://example.com/api/v1')
25
+ );
26
+ });
27
+
28
+ it('returns false when catalog host is prefix of candidate host (SECURITY)', () => {
29
+ // Attack: trusted.example.attacker.com should NOT match trusted.example
30
+ assert.isFalse(
31
+ matchesCatalogUrl(
32
+ 'https://trusted.example.attacker.com/activities/id',
33
+ 'https://trusted.example'
34
+ )
35
+ );
36
+ });
37
+
38
+ it('returns false when schemes differ', () => {
39
+ assert.isFalse(
40
+ matchesCatalogUrl('http://example.com/api/v1/users', 'https://example.com/api/v1')
41
+ );
42
+ });
43
+
44
+ it('returns false when ports differ', () => {
45
+ assert.isFalse(
46
+ matchesCatalogUrl('https://example.com:8443/api/v1/users', 'https://example.com/api/v1')
47
+ );
48
+ });
49
+ });
50
+
51
+ describe('path validation', () => {
52
+ it('returns true for root path catalog entry', () => {
53
+ assert.isTrue(matchesCatalogUrl('https://example.com/any/path/here', 'https://example.com/'));
54
+ });
55
+
56
+ it('returns true when paths match exactly', () => {
57
+ assert.isTrue(matchesCatalogUrl('https://example.com/api/v1', 'https://example.com/api/v1'));
58
+ });
59
+
60
+ it('returns true when candidate path extends catalog path at boundary', () => {
61
+ assert.isTrue(
62
+ matchesCatalogUrl('https://example.com/api/v1/users/123', 'https://example.com/api/v1')
63
+ );
64
+ });
65
+
66
+ it('returns true when catalog path has trailing slash', () => {
67
+ assert.isTrue(
68
+ matchesCatalogUrl('https://example.com/api/v1/users', 'https://example.com/api/v1/')
69
+ );
70
+ });
71
+
72
+ it('returns false when catalog path is prefix but not at path boundary (SECURITY)', () => {
73
+ // /api/v1 should NOT match /api/v1extra
74
+ assert.isFalse(
75
+ matchesCatalogUrl('https://example.com/api/v1extra/something', 'https://example.com/api/v1')
76
+ );
77
+ });
78
+
79
+ it('returns false when candidate path does not start with catalog path', () => {
80
+ assert.isFalse(
81
+ matchesCatalogUrl('https://example.com/other/path', 'https://example.com/api/v1')
82
+ );
83
+ });
84
+ });
85
+
86
+ describe('error handling', () => {
87
+ it('returns false for invalid candidate URL', () => {
88
+ assert.isFalse(matchesCatalogUrl('not-a-url', 'https://example.com/api'));
89
+ });
90
+
91
+ it('returns false for invalid catalog URL', () => {
92
+ assert.isFalse(matchesCatalogUrl('https://example.com/api', 'not-a-url'));
93
+ });
94
+
95
+ it('returns false when both URLs are invalid', () => {
96
+ assert.isFalse(matchesCatalogUrl('not-a-url', 'also-not-a-url'));
97
+ });
98
+ });
99
+ });
100
+
101
+ describe('parseCatalogUrl()', () => {
102
+ it('returns the origin and pathname for a valid url', () => {
103
+ assert.deepEqual(parseCatalogUrl('https://example.com/api/v1'), {
104
+ origin: 'https://example.com',
105
+ path: '/api/v1',
106
+ });
107
+ });
108
+
109
+ it('strips a trailing slash from the path', () => {
110
+ assert.deepEqual(parseCatalogUrl('https://example.com/api/v1/'), {
111
+ origin: 'https://example.com',
112
+ path: '/api/v1',
113
+ });
114
+ });
115
+
116
+ it('normalizes a root url to the "/" path', () => {
117
+ assert.deepEqual(parseCatalogUrl('https://example.com'), {
118
+ origin: 'https://example.com',
119
+ path: '/',
120
+ });
121
+ });
122
+
123
+ it('returns null for an unparsable url', () => {
124
+ assert.isNull(parseCatalogUrl('not-a-url'));
125
+ });
126
+
127
+ it('memoizes results, returning the same reference for repeated calls', () => {
128
+ const first = parseCatalogUrl('https://memoized.example.com/api/v1');
129
+ const second = parseCatalogUrl('https://memoized.example.com/api/v1');
130
+
131
+ assert.strictEqual(first, second);
132
+ });
133
+ });
134
+
135
+ describe('matchesParsedCatalogUrl()', () => {
136
+ it('returns true when the candidate is under the catalog origin and path', () => {
137
+ assert.isTrue(
138
+ matchesParsedCatalogUrl(
139
+ new URL('https://example.com/api/v1/users'),
140
+ parseCatalogUrl('https://example.com/api/v1')
141
+ )
142
+ );
143
+ });
144
+
145
+ it('returns false when origins differ (SECURITY)', () => {
146
+ assert.isFalse(
147
+ matchesParsedCatalogUrl(
148
+ new URL('https://trusted.example.attacker.com/activities/id'),
149
+ parseCatalogUrl('https://trusted.example')
150
+ )
151
+ );
152
+ });
153
+
154
+ it('returns false when the catalog path is not matched at a boundary (SECURITY)', () => {
155
+ assert.isFalse(
156
+ matchesParsedCatalogUrl(
157
+ new URL('https://example.com/api/v1extra/something'),
158
+ parseCatalogUrl('https://example.com/api/v1')
159
+ )
160
+ );
161
+ });
162
+
163
+ it('returns true for a root catalog path', () => {
164
+ assert.isTrue(
165
+ matchesParsedCatalogUrl(
166
+ new URL('https://example.com/any/path'),
167
+ parseCatalogUrl('https://example.com/')
168
+ )
169
+ );
170
+ });
171
+
172
+ it('returns false when the candidate url is missing', () => {
173
+ assert.isFalse(matchesParsedCatalogUrl(undefined, parseCatalogUrl('https://example.com/api')));
174
+ });
175
+
176
+ it('returns false when the parsed catalog url is null', () => {
177
+ assert.isFalse(matchesParsedCatalogUrl(new URL('https://example.com/api'), null));
178
+ });
179
+ });
8
180
 
9
181
  /* eslint-disable no-underscore-dangle */
10
182
  describe('webex-core', () => {
@@ -101,7 +273,24 @@ describe('webex-core', () => {
101
273
  const domains = [];
102
274
 
103
275
  beforeEach(() => {
104
- domains.push('example-a', 'example-b', 'example-c');
276
+ // Shaped like a real catalog rather than a tidy list: the commercial
277
+ // domains the sdk ships with, a multi-part suffix, sites added at
278
+ // runtime from meeting preferences, an entry that overlaps another,
279
+ // and an unset entry, which callers can add and which must match
280
+ // nothing.
281
+ domains.push(
282
+ 'wbx2.com',
283
+ 'ciscospark.com',
284
+ 'webex.com',
285
+ 'webexapis.com',
286
+ 'broadcloud.com.au',
287
+ 'webexgov.us',
288
+ 'example-a.com',
289
+ 'example-b.com',
290
+ 'example-c.com',
291
+ 'go.example-a.com',
292
+ ''
293
+ );
105
294
 
106
295
  catalog.setAllowedDomains(domains);
107
296
  });
@@ -110,10 +299,60 @@ describe('webex-core', () => {
110
299
  domains.length = 0;
111
300
  });
112
301
 
113
- it('finds an allowed domain that matches a specific url', () => {
114
- const domain = catalog.findAllowedDomain('http://example-a.com/resource/id');
115
-
116
- assert.include(domains, domain);
302
+ [
303
+ // real service hosts resolve to the entry that covers them
304
+ ['https://u2c-a.wbx2.com/u2c/api/v1/limited/catalog', 'wbx2.com'],
305
+ ['https://conv-a.wbx2.com/conversation/api/v1/conversations', 'wbx2.com'],
306
+ ['https://foobar.ciscospark.com/resource/id', 'ciscospark.com'],
307
+ ['https://idbroker.webex.com/idb/token', 'webex.com'],
308
+ ['https://webexapis.com/v1/people/me', 'webexapis.com'],
309
+ // an entry is not required to be a bare registrable domain
310
+ ['https://foo.broadcloud.com.au/resource/id', 'broadcloud.com.au'],
311
+ ['https://a.b.webexgov.us/resource/id', 'webexgov.us'],
312
+ // the domain itself and its subdomains match
313
+ ['https://example-a.com/resource/id', 'example-a.com'],
314
+ ['https://sub.example-b.com/resource/id', 'example-b.com'],
315
+ ['https://deep.sub.example-c.com/resource/id', 'example-c.com'],
316
+ // overlapping entries resolve to the first covering entry in the list
317
+ ['https://go.example-a.com/resource/id', 'example-a.com'],
318
+ // an explicit port must not defeat the match
319
+ ['https://example-a.com:8443/resource/id', 'example-a.com'],
320
+ ['https://u2c-a.wbx2.com:8000/resource/id', 'wbx2.com'],
321
+ // hostname comparison is case insensitive
322
+ ['https://U2C-A.WBX2.COM/resource/id', 'wbx2.com'],
323
+ // a trailing dot is the same name
324
+ ['https://u2c-a.wbx2.com./resource/id', 'wbx2.com'],
325
+ // a sibling registrable domain must not match
326
+ ['https://notwebex.com/resource/id', undefined],
327
+ ['https://mywebexgov.us/resource/id', undefined],
328
+ ['https://webex.company/resource/id', undefined],
329
+ ['https://webex.com-unrelated.example/resource/id', undefined],
330
+ ['https://example-a.community/resource/id', undefined],
331
+ // a partial label must not match, even against a multi-part entry
332
+ ['https://broadcloud.com/resource/id', undefined],
333
+ // the domain matches only as a suffix, on a label boundary
334
+ ['https://webex.com.unrelated.example/resource/id', undefined],
335
+ ['https://unrelated.example/webex.com/resource/id', undefined],
336
+ ['https://unrelated.example/?next=https://webex.com', undefined],
337
+ // userinfo is not the host
338
+ ['https://webex.com@unrelated.example/resource/id', undefined],
339
+ // an encoded or unusual separator is not a label boundary, and the url
340
+ // parsers used by the transports do not agree on where these end the
341
+ // host, so they must not resolve to an allowed domain
342
+ ['https://webex.com%2eunrelated.example/resource/id', undefined],
343
+ ['https://webex.com%2Eunrelated.example/resource/id', undefined],
344
+ ['https://unrelated.example%2ewebex.com/resource/id', undefined],
345
+ ['https://unrelated.example%2Ewebex.com/resource/id', undefined],
346
+ ['https://unrelated.example;.webex.com/resource/id', undefined],
347
+ // an empty allowed domain entry matches nothing
348
+ ['https://unrelated.example/resource/id', undefined],
349
+ // unparseable urls
350
+ ['', undefined],
351
+ ['not a url', undefined],
352
+ ].forEach(([url, expected]) => {
353
+ it(`returns ${expected} for ${url || '<empty>'}`, () => {
354
+ assert.equal(catalog.findAllowedDomain(url), expected);
355
+ });
117
356
  });
118
357
  });
119
358
 
@@ -121,7 +360,7 @@ describe('webex-core', () => {
121
360
  const domains = [];
122
361
 
123
362
  beforeEach(() => {
124
- domains.push('example-a', 'example-b', 'example-c');
363
+ domains.push('example-a.com', 'example-b.com', 'example-c.com');
125
364
 
126
365
  catalog.setAllowedDomains(domains);
127
366
  });
@@ -160,7 +399,7 @@ describe('webex-core', () => {
160
399
  const domains = [];
161
400
 
162
401
  beforeEach(() => {
163
- domains.push('example-a', 'example-b', 'example-c');
402
+ domains.push('example-a.com', 'example-b.com', 'example-c.com');
164
403
 
165
404
  catalog.setAllowedDomains(domains);
166
405
  });
@@ -170,19 +409,31 @@ describe('webex-core', () => {
170
409
  });
171
410
 
172
411
  it('sets the allowed domain entries to new values', () => {
173
- const newValues = ['example-d', 'example-e', 'example-f'];
412
+ const newValues = ['example-d.com', 'example-e.com', 'example-f.com'];
174
413
 
175
414
  catalog.setAllowedDomains(newValues);
176
415
 
177
416
  assert.notDeepInclude(domains, newValues);
178
417
  });
418
+
419
+ it('canonicalizes entries and discards those that are not usable', () => {
420
+ catalog.setAllowedDomains([
421
+ 'Example-D.COM',
422
+ 'example-d.com',
423
+ 'example-e.com.',
424
+ '',
425
+ undefined,
426
+ ]);
427
+
428
+ assert.deepEqual(catalog.getAllowedDomains(), ['example-d.com', 'example-e.com']);
429
+ });
179
430
  });
180
431
 
181
432
  describe('#addAllowedDomains()', () => {
182
433
  const domains = [];
183
434
 
184
435
  beforeEach(() => {
185
- domains.push('example-a', 'example-b', 'example-c');
436
+ domains.push('example-a.com', 'example-b.com', 'example-c.com');
186
437
 
187
438
  catalog.setAllowedDomains(domains);
188
439
  });
@@ -192,13 +443,16 @@ describe('webex-core', () => {
192
443
  });
193
444
 
194
445
  it('merge the allowed domain entries with new values', () => {
195
- const newValues = ['example-c', 'example-e', 'example-f'];
446
+ const newValues = ['example-c.com', 'example-e.com', 'example-f.com'];
196
447
 
197
448
  catalog.addAllowedDomains(newValues);
198
449
 
199
450
  const list = catalog.getAllowedDomains();
200
451
 
201
- assert.match(['example-a', 'example-b', 'example-c', 'example-e', 'example-f'], list);
452
+ assert.match(
453
+ ['example-a.com', 'example-b.com', 'example-c.com', 'example-e.com', 'example-f.com'],
454
+ list
455
+ );
202
456
  });
203
457
  });
204
458
 
@@ -250,6 +504,109 @@ describe('webex-core', () => {
250
504
 
251
505
  assert.equal(service, exampleService);
252
506
  });
507
+
508
+ describe('security: origin validation', () => {
509
+ it('rejects URLs where the catalog host is a prefix of the candidate host (SECURITY)', () => {
510
+ // Attack: https://trusted.example.attacker.com should NOT match https://trusted.example
511
+ const exampleService = {
512
+ defaultUrl: 'https://trusted.example',
513
+ hosts: [],
514
+ };
515
+
516
+ catalog.serviceGroups.postauth.push(exampleService);
517
+
518
+ // Attacker registers trusted.example.attacker.com
519
+ const attackerUrl = 'https://trusted.example.attacker.com/activities/id';
520
+ const service = catalog.findServiceUrlFromUrl(attackerUrl);
521
+
522
+ assert.isUndefined(service);
523
+ });
524
+
525
+ it('rejects URLs with different ports even if host matches', () => {
526
+ const exampleService = {
527
+ defaultUrl: 'https://example.com/resource',
528
+ hosts: [],
529
+ };
530
+
531
+ catalog.serviceGroups.postauth.push(exampleService);
532
+
533
+ const differentPortUrl = 'https://example.com:8443/resource/id';
534
+ const service = catalog.findServiceUrlFromUrl(differentPortUrl);
535
+
536
+ assert.isUndefined(service);
537
+ });
538
+
539
+ it('rejects URLs with different schemes', () => {
540
+ const exampleService = {
541
+ defaultUrl: 'https://example.com/resource',
542
+ hosts: [],
543
+ };
544
+
545
+ catalog.serviceGroups.postauth.push(exampleService);
546
+
547
+ const httpUrl = 'http://example.com/resource/id';
548
+ const service = catalog.findServiceUrlFromUrl(httpUrl);
549
+
550
+ assert.isUndefined(service);
551
+ });
552
+
553
+ it('rejects URLs where catalog path is a prefix but not at path boundary', () => {
554
+ // /api/v1 should NOT match /api/v1extra
555
+ const exampleService = {
556
+ defaultUrl: 'https://example.com/api/v1',
557
+ hosts: [],
558
+ };
559
+
560
+ catalog.serviceGroups.postauth.push(exampleService);
561
+
562
+ const nonBoundaryUrl = 'https://example.com/api/v1extra/something';
563
+ const service = catalog.findServiceUrlFromUrl(nonBoundaryUrl);
564
+
565
+ assert.isUndefined(service);
566
+ });
567
+
568
+ it('accepts URLs at exact path boundary', () => {
569
+ const exampleService = {
570
+ defaultUrl: 'https://example.com/api/v1',
571
+ hosts: [],
572
+ };
573
+
574
+ catalog.serviceGroups.postauth.push(exampleService);
575
+
576
+ const boundaryUrl = 'https://example.com/api/v1/users/123';
577
+ const service = catalog.findServiceUrlFromUrl(boundaryUrl);
578
+
579
+ assert.equal(service, exampleService);
580
+ });
581
+
582
+ it('returns undefined for invalid URLs', () => {
583
+ const exampleService = {
584
+ defaultUrl: 'https://example.com/resource',
585
+ hosts: [],
586
+ };
587
+
588
+ catalog.serviceGroups.postauth.push(exampleService);
589
+
590
+ const invalidUrl = 'not-a-valid-url';
591
+ const service = catalog.findServiceUrlFromUrl(invalidUrl);
592
+
593
+ assert.isUndefined(service);
594
+ });
595
+
596
+ it('matches root path catalog entries correctly', () => {
597
+ const exampleService = {
598
+ defaultUrl: 'https://example.com/',
599
+ hosts: [],
600
+ };
601
+
602
+ catalog.serviceGroups.postauth.push(exampleService);
603
+
604
+ const subpathUrl = 'https://example.com/any/path/here';
605
+ const service = catalog.findServiceUrlFromUrl(subpathUrl);
606
+
607
+ assert.equal(service, exampleService);
608
+ });
609
+ });
253
610
  });
254
611
  });
255
612
  });