@webex/webex-core 3.12.0-auth-prejoin-fetch.1 → 3.12.0-llmrefactor.10

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 (39) hide show
  1. package/dist/config.js +14 -0
  2. package/dist/config.js.map +1 -1
  3. package/dist/index.js +7 -0
  4. package/dist/index.js.map +1 -1
  5. package/dist/interceptors/catalog-url.js +87 -0
  6. package/dist/interceptors/catalog-url.js.map +1 -0
  7. package/dist/interceptors/payload-transformer.js +25 -4
  8. package/dist/interceptors/payload-transformer.js.map +1 -1
  9. package/dist/lib/batcher.js +1 -1
  10. package/dist/lib/credentials/credentials.js +56 -1
  11. package/dist/lib/credentials/credentials.js.map +1 -1
  12. package/dist/lib/credentials/token.js +1 -1
  13. package/dist/lib/services/service-catalog.js +102 -8
  14. package/dist/lib/services/service-catalog.js.map +1 -1
  15. package/dist/lib/services/services.js +1 -1
  16. package/dist/lib/services-v2/service-catalog.js +8 -1
  17. package/dist/lib/services-v2/service-catalog.js.map +1 -1
  18. package/dist/lib/services-v2/services-v2.js +3 -2
  19. package/dist/lib/services-v2/services-v2.js.map +1 -1
  20. package/dist/plugins/logger.js +1 -1
  21. package/dist/webex-core.js +11 -3
  22. package/dist/webex-core.js.map +1 -1
  23. package/package.json +13 -13
  24. package/src/config.js +15 -0
  25. package/src/index.js +1 -0
  26. package/src/interceptors/catalog-url.js +66 -0
  27. package/src/interceptors/payload-transformer.js +23 -1
  28. package/src/lib/credentials/credentials.js +43 -0
  29. package/src/lib/services/service-catalog.js +106 -6
  30. package/src/lib/services-v2/service-catalog.ts +10 -1
  31. package/src/lib/services-v2/services-v2.ts +5 -1
  32. package/src/webex-core.js +8 -0
  33. package/test/unit/spec/credentials/credentials.js +114 -1
  34. package/test/unit/spec/interceptors/catalog-url.js +224 -0
  35. package/test/unit/spec/interceptors/payload-transformer.js +119 -1
  36. package/test/unit/spec/services/service-catalog.js +275 -0
  37. package/test/unit/spec/services-v2/service-catalog.ts +41 -0
  38. package/test/unit/spec/services-v2/services-v2.ts +20 -0
  39. 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', () => {
@@ -332,6 +504,109 @@ describe('webex-core', () => {
332
504
 
333
505
  assert.equal(service, exampleService);
334
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
+ });
335
610
  });
336
611
  });
337
612
  });
@@ -365,6 +365,47 @@ describe('webex-core', () => {
365
365
  assert.equal(service, exampleService);
366
366
  }
367
367
  );
368
+
369
+ it('rejects URLs with similar-looking hostnames (SECURITY)', () => {
370
+ // Attacker URL that looks like a catalog URL but has a different origin
371
+ const maliciousUrl = 'https://example.com.attacker.com/resource/id';
372
+
373
+ const exampleService = {
374
+ serviceUrls: [{baseUrl: 'https://example.com/resource'}],
375
+ };
376
+
377
+ catalog.serviceGroups.postauth.push(exampleService);
378
+
379
+ const service = catalog.findServiceDetailFromUrl(maliciousUrl);
380
+
381
+ // Should NOT match - origins are different
382
+ assert.isUndefined(service);
383
+ });
384
+
385
+ it('rejects URLs where catalog URL is a prefix but not at path boundary', () => {
386
+ // e.g., /api/v1 should not match /api/v1extra
387
+ const url = 'https://example.com/resourceextra/id';
388
+
389
+ const exampleService = {
390
+ serviceUrls: [{baseUrl: 'https://example.com/resource'}],
391
+ };
392
+
393
+ catalog.serviceGroups.postauth.push(exampleService);
394
+
395
+ const service = catalog.findServiceDetailFromUrl(url);
396
+
397
+ assert.isUndefined(service);
398
+ });
399
+
400
+ it('returns undefined for an unparsable url', () => {
401
+ const exampleService = {
402
+ serviceUrls: [{baseUrl: 'https://example.com/resource'}],
403
+ };
404
+
405
+ catalog.serviceGroups.postauth.push(exampleService);
406
+
407
+ assert.isUndefined(catalog.findServiceDetailFromUrl('not-a-url'));
408
+ });
368
409
  });
369
410
  });
370
411
  });
@@ -896,6 +896,26 @@ describe('webex-core', () => {
896
896
  });
897
897
  });
898
898
 
899
+ describe('#getServiceFromUrl()', () => {
900
+ it('matches an exact service URL when the catalog URL has a trailing slash', () => {
901
+ const baseUrl = 'https://example.com/api/v1/';
902
+
903
+ catalog.updateServiceGroups('preauth', [
904
+ {
905
+ id: 'example',
906
+ serviceName: 'example',
907
+ serviceUrls: [{host: 'example.com', baseUrl, priority: 1}],
908
+ },
909
+ ]);
910
+
911
+ assert.deepEqual(services.getServiceFromUrl('https://example.com/api/v1'), {
912
+ name: 'example',
913
+ priorityUrl: baseUrl,
914
+ defaultUrl: baseUrl,
915
+ });
916
+ });
917
+ });
918
+
899
919
  describe('#_formatReceivedHostmap()', () => {
900
920
  let serviceHostmap;
901
921
  let formattedHM;
@@ -91,6 +91,30 @@ describe('Webex', () => {
91
91
  assert.isFalse(webex.canAuthorize);
92
92
  });
93
93
 
94
+ it('initializes network telemetry after applying the configured metrics options', async () => {
95
+ const configuredWebex = new WebexCore({
96
+ config: {
97
+ metrics: {
98
+ networkTelemetry: {
99
+ enabled: true,
100
+ intervalMs: 60 * 1_000,
101
+ },
102
+ },
103
+ },
104
+ });
105
+ const submitClientMetrics = sinon
106
+ .stub(configuredWebex.internal.metrics, 'submitClientMetrics')
107
+ .resolves();
108
+
109
+ assert.exists(configuredWebex.internal.metrics.networkTelemetry);
110
+ configuredWebex.trigger('request:start', {service: 'hydra', resource: 'rooms'});
111
+ await configuredWebex.internal.metrics.flushNetworkTelemetry();
112
+
113
+ assert.calledOnce(submitClientMetrics);
114
+
115
+ await configuredWebex.internal.metrics.stopNetworkTelemetry();
116
+ });
117
+
94
118
  [
95
119
  'data',
96
120
  'data.access_token',
@@ -179,7 +203,7 @@ describe('Webex', () => {
179
203
 
180
204
  describe('initializes with interceptors', () => {
181
205
  [
182
- // 4 pre, 4 post, 10 remaining default = 18
206
+ // 4 pre (CatalogUrlInterceptor is opt-in), 4 post, 10 remaining default = 18
183
207
  [
184
208
  'defaults to existing interceptors if undefined',
185
209
  undefined,
@@ -205,6 +229,58 @@ describe('Webex', () => {
205
229
  'RateLimitInterceptor',
206
230
  ],
207
231
  ],
232
+ [
233
+ 'does not include CatalogUrlInterceptor for a truthy non-boolean value',
234
+ {services: {validateCatalogUrls: 'true'}},
235
+ 18,
236
+ [
237
+ 'RequestTimingInterceptor',
238
+ 'RequestEventInterceptor',
239
+ 'WebexTrackingIdInterceptor',
240
+ 'RateLimitInterceptor',
241
+ 'ServiceInterceptor',
242
+ 'UserAgentInterceptor',
243
+ 'ProxyInterceptor',
244
+ 'WebexUserAgentInterceptor',
245
+ 'AuthInterceptor',
246
+ 'PayloadTransformerInterceptor',
247
+ 'RedirectInterceptor',
248
+ 'DefaultOptionsInterceptor',
249
+ 'HostMapInterceptor',
250
+ 'ServerErrorInterceptor',
251
+ 'HttpStatusInterceptor',
252
+ 'NetworkTimingInterceptor',
253
+ 'EmbargoInterceptor',
254
+ 'RateLimitInterceptor',
255
+ ],
256
+ ],
257
+ // CatalogUrlInterceptor is opt-in via services.validateCatalogUrls
258
+ [
259
+ 'includes CatalogUrlInterceptor when validateCatalogUrls is enabled',
260
+ {services: {validateCatalogUrls: true}},
261
+ 19,
262
+ [
263
+ 'RequestTimingInterceptor',
264
+ 'RequestEventInterceptor',
265
+ 'WebexTrackingIdInterceptor',
266
+ 'RateLimitInterceptor',
267
+ 'CatalogUrlInterceptor',
268
+ 'ServiceInterceptor',
269
+ 'UserAgentInterceptor',
270
+ 'ProxyInterceptor',
271
+ 'WebexUserAgentInterceptor',
272
+ 'AuthInterceptor',
273
+ 'PayloadTransformerInterceptor',
274
+ 'RedirectInterceptor',
275
+ 'DefaultOptionsInterceptor',
276
+ 'HostMapInterceptor',
277
+ 'ServerErrorInterceptor',
278
+ 'HttpStatusInterceptor',
279
+ 'NetworkTimingInterceptor',
280
+ 'EmbargoInterceptor',
281
+ 'RateLimitInterceptor',
282
+ ],
283
+ ],
208
284
  [
209
285
  'only adds PayloadTransformerInterceptor',
210
286
  {interceptors: {PayloadTransformerInterceptor: PayloadTransformerInterceptor.create}},