@webex/webex-core 3.12.0-llmrefactor.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.
- package/dist/config.js +23 -0
- package/dist/config.js.map +1 -1
- package/dist/index.js +7 -0
- package/dist/index.js.map +1 -1
- package/dist/interceptors/catalog-url.js +87 -0
- package/dist/interceptors/catalog-url.js.map +1 -0
- package/dist/interceptors/payload-transformer.js +25 -4
- package/dist/interceptors/payload-transformer.js.map +1 -1
- package/dist/lib/batcher.js +1 -1
- package/dist/lib/credentials/credentials.js +56 -1
- package/dist/lib/credentials/credentials.js.map +1 -1
- package/dist/lib/credentials/token.js +1 -1
- package/dist/lib/services/service-catalog.js +102 -8
- package/dist/lib/services/service-catalog.js.map +1 -1
- package/dist/lib/services/services.js +74 -44
- package/dist/lib/services/services.js.map +1 -1
- package/dist/lib/services-v2/service-catalog.js +8 -1
- package/dist/lib/services-v2/service-catalog.js.map +1 -1
- package/dist/lib/services-v2/services-v2.js +76 -45
- package/dist/lib/services-v2/services-v2.js.map +1 -1
- package/dist/plugins/logger.js +1 -1
- package/dist/webex-core.js +11 -3
- package/dist/webex-core.js.map +1 -1
- package/package.json +12 -12
- package/src/config.js +25 -0
- package/src/index.js +1 -0
- package/src/interceptors/catalog-url.js +66 -0
- package/src/interceptors/payload-transformer.js +23 -1
- package/src/lib/credentials/credentials.js +43 -0
- package/src/lib/services/service-catalog.js +106 -6
- package/src/lib/services/services.js +65 -30
- package/src/lib/services-v2/service-catalog.ts +10 -1
- package/src/lib/services-v2/services-v2.ts +70 -30
- package/src/webex-core.js +8 -0
- package/test/integration/spec/services/services.js +148 -113
- package/test/integration/spec/services-v2/services-v2.js +134 -99
- package/test/unit/spec/credentials/credentials.js +114 -1
- package/test/unit/spec/interceptors/catalog-url.js +224 -0
- package/test/unit/spec/interceptors/payload-transformer.js +119 -1
- package/test/unit/spec/services/service-catalog.js +275 -0
- package/test/unit/spec/services/services.js +135 -2
- package/test/unit/spec/services-v2/service-catalog.ts +41 -0
- package/test/unit/spec/services-v2/services-v2.ts +158 -2
- 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
|
});
|
|
@@ -149,9 +149,55 @@ describe('webex-core', () => {
|
|
|
149
149
|
const [, event] = services.listenToOnce.getCall(1).args;
|
|
150
150
|
assert.equal(event, 'ready', 'default path listens on webex ready');
|
|
151
151
|
});
|
|
152
|
+
|
|
153
|
+
it('skips preauth catalog collection when skipPreauthCatalogOnUnauthenticated is true and no credentials', async () => {
|
|
154
|
+
services.webex.config = {
|
|
155
|
+
...(services.webex.config || {}),
|
|
156
|
+
services: {skipPreauthCatalogOnUnauthenticated: true},
|
|
157
|
+
};
|
|
158
|
+
services.listenToOnce = sinon.stub();
|
|
159
|
+
services.collectPreauthCatalog = sinon.stub().returns(Promise.resolve());
|
|
160
|
+
services.logger.info = sinon.stub();
|
|
161
|
+
|
|
162
|
+
services.initialize();
|
|
163
|
+
services.listenToOnce.getCall(0).args[2]();
|
|
164
|
+
services.listenToOnce.getCall(1).args[2]();
|
|
165
|
+
await waitForAsync();
|
|
166
|
+
|
|
167
|
+
assert.isFalse(
|
|
168
|
+
services.collectPreauthCatalog.called,
|
|
169
|
+
'must not collect preauth catalog while unauthenticated when option is enabled'
|
|
170
|
+
);
|
|
171
|
+
assert.isFalse(services.initFailed);
|
|
172
|
+
sinon.assert.calledWith(
|
|
173
|
+
services.logger.info,
|
|
174
|
+
'services: skipping preauth catalog collection while unauthenticated as per the config'
|
|
175
|
+
);
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
it('still collects preauth catalog when skipPreauthCatalogOnUnauthenticated is false (default)', async () => {
|
|
179
|
+
services.webex.config = {
|
|
180
|
+
...(services.webex.config || {}),
|
|
181
|
+
services: {skipPreauthCatalogOnUnauthenticated: false},
|
|
182
|
+
};
|
|
183
|
+
services.listenToOnce = sinon.stub();
|
|
184
|
+
services.collectPreauthCatalog = sinon.stub().returns(Promise.resolve());
|
|
185
|
+
|
|
186
|
+
services.initialize();
|
|
187
|
+
services.listenToOnce.getCall(0).args[2]();
|
|
188
|
+
services.listenToOnce.getCall(1).args[2]();
|
|
189
|
+
await waitForAsync();
|
|
190
|
+
|
|
191
|
+
assert.isTrue(
|
|
192
|
+
services.collectPreauthCatalog.called,
|
|
193
|
+
'must collect preauth catalog when option is disabled'
|
|
194
|
+
);
|
|
195
|
+
});
|
|
152
196
|
});
|
|
153
197
|
|
|
154
198
|
describe('#initialize (waitForCatalogInit=true)', () => {
|
|
199
|
+
let clock;
|
|
200
|
+
|
|
155
201
|
beforeEach(() => {
|
|
156
202
|
services.webex.config = {
|
|
157
203
|
...(services.webex.config || {}),
|
|
@@ -162,6 +208,15 @@ describe('webex-core', () => {
|
|
|
162
208
|
// true). We reset here so tests that call initialize() a second time
|
|
163
209
|
// with the flag enabled observe the gated behavior from a clean state.
|
|
164
210
|
services.ready = false;
|
|
211
|
+
// The gated path schedules a real 15s setTimeout via _makeInitTimeout()
|
|
212
|
+
// that is not cleared when the init race resolves. Fake only setTimeout/
|
|
213
|
+
// clearTimeout (leaving setImmediate real so waitForAsync still works)
|
|
214
|
+
// so restoring in afterEach clears the pending timer and Jest can exit.
|
|
215
|
+
clock = sinon.useFakeTimers({toFake: ['setTimeout', 'clearTimeout']});
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
afterEach(() => {
|
|
219
|
+
clock.restore();
|
|
165
220
|
});
|
|
166
221
|
|
|
167
222
|
it('leaves services.ready=false after initialize (does not flip until finalize)', () => {
|
|
@@ -242,7 +297,6 @@ describe('webex-core', () => {
|
|
|
242
297
|
});
|
|
243
298
|
|
|
244
299
|
it('flips services.ready=true when init times out', async () => {
|
|
245
|
-
const clock = sinon.useFakeTimers();
|
|
246
300
|
services.listenToOnce = sinon.stub();
|
|
247
301
|
// Never resolves - the timeout must win the race.
|
|
248
302
|
services.initServiceCatalogs = sinon.stub().returns(new Promise(() => {}));
|
|
@@ -259,7 +313,6 @@ describe('webex-core', () => {
|
|
|
259
313
|
// rejected timeout can propagate through .catch and .finally into
|
|
260
314
|
// _finalizeReady().
|
|
261
315
|
await clock.tickAsync(15_001);
|
|
262
|
-
clock.restore();
|
|
263
316
|
|
|
264
317
|
assert.isTrue(services.initFailed);
|
|
265
318
|
assert.isTrue(services.ready);
|
|
@@ -382,6 +435,86 @@ describe('webex-core', () => {
|
|
|
382
435
|
});
|
|
383
436
|
});
|
|
384
437
|
|
|
438
|
+
describe('#initialize (waitForCatalogInit=true, skipPreauthCatalogOnUnauthenticated=true)', () => {
|
|
439
|
+
beforeEach(() => {
|
|
440
|
+
services.webex.config = {
|
|
441
|
+
...(services.webex.config || {}),
|
|
442
|
+
services: {
|
|
443
|
+
waitForCatalogInit: true,
|
|
444
|
+
catalogInitTimeout: 15000,
|
|
445
|
+
skipPreauthCatalogOnUnauthenticated: true,
|
|
446
|
+
},
|
|
447
|
+
};
|
|
448
|
+
services.ready = false;
|
|
449
|
+
});
|
|
450
|
+
|
|
451
|
+
it('skips preauth catalog collection but still finalizes services.ready when unauthenticated', async () => {
|
|
452
|
+
services.listenToOnce = sinon.stub();
|
|
453
|
+
services.collectPreauthCatalog = sinon.stub().returns(Promise.resolve());
|
|
454
|
+
services.logger.info = sinon.stub();
|
|
455
|
+
|
|
456
|
+
services.initialize();
|
|
457
|
+
services.listenToOnce.getCall(0).args[2]();
|
|
458
|
+
services.listenToOnce.getCall(1).args[2]();
|
|
459
|
+
await waitForAsync();
|
|
460
|
+
|
|
461
|
+
assert.isFalse(
|
|
462
|
+
services.collectPreauthCatalog.called,
|
|
463
|
+
'must not collect preauth catalog while unauthenticated when option is enabled'
|
|
464
|
+
);
|
|
465
|
+
assert.isTrue(services.ready, 'must still finalize ready so webex.ready is not stalled');
|
|
466
|
+
sinon.assert.calledWith(
|
|
467
|
+
services.logger.info,
|
|
468
|
+
'services: skipping preauth catalog collection while unauthenticated as per the config'
|
|
469
|
+
);
|
|
470
|
+
});
|
|
471
|
+
|
|
472
|
+
it('still registers a change:canAuthorize listener when skipping preauth (fresh-login path)', async () => {
|
|
473
|
+
services.listenToOnce = sinon.stub();
|
|
474
|
+
services.collectPreauthCatalog = sinon.stub().returns(Promise.resolve());
|
|
475
|
+
services.initServiceCatalogs = sinon.stub().returns(Promise.resolve());
|
|
476
|
+
|
|
477
|
+
services.initialize();
|
|
478
|
+
const currentCatalog = services._getCatalog();
|
|
479
|
+
services.listenToOnce.getCall(0).args[2]();
|
|
480
|
+
services.listenToOnce.getCall(1).args[2]();
|
|
481
|
+
await waitForAsync();
|
|
482
|
+
|
|
483
|
+
const freshLoginCall = services.listenToOnce
|
|
484
|
+
.getCalls()
|
|
485
|
+
.find((call) => call.args[1] === 'change:canAuthorize');
|
|
486
|
+
assert.isDefined(freshLoginCall, 'expected change:canAuthorize listener to be registered');
|
|
487
|
+
|
|
488
|
+
services.webex.canAuthorize = true;
|
|
489
|
+
currentCatalog.status.postauth.ready = false;
|
|
490
|
+
freshLoginCall.args[2]();
|
|
491
|
+
|
|
492
|
+
assert.isTrue(
|
|
493
|
+
services.initServiceCatalogs.called,
|
|
494
|
+
'expected postauth catalog init after canAuthorize flips'
|
|
495
|
+
);
|
|
496
|
+
await waitForAsync();
|
|
497
|
+
});
|
|
498
|
+
|
|
499
|
+
it('does not schedule an init timeout when skipping preauth (no stray timer/rejection)', async () => {
|
|
500
|
+
const clock = sinon.useFakeTimers();
|
|
501
|
+
services.listenToOnce = sinon.stub();
|
|
502
|
+
services.collectPreauthCatalog = sinon.stub().returns(Promise.resolve());
|
|
503
|
+
services.logger.error = sinon.stub();
|
|
504
|
+
|
|
505
|
+
services.initialize();
|
|
506
|
+
services.listenToOnce.getCall(0).args[2]();
|
|
507
|
+
services.listenToOnce.getCall(1).args[2]();
|
|
508
|
+
|
|
509
|
+
// Advance well past the configured init timeout; since no timeout was
|
|
510
|
+
// scheduled in skip mode, no timeout error should ever be logged.
|
|
511
|
+
await clock.tickAsync(30_000);
|
|
512
|
+
clock.restore();
|
|
513
|
+
|
|
514
|
+
sinon.assert.neverCalledWithMatch(services.logger.error, /services: init timed out/);
|
|
515
|
+
});
|
|
516
|
+
});
|
|
517
|
+
|
|
385
518
|
describe('#_finalizeReady', () => {
|
|
386
519
|
beforeEach(() => {
|
|
387
520
|
// Outer beforeEach construction already ran initialize() (ungated,
|
|
@@ -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
|
});
|