@webex/webex-core 3.12.0-next.44 → 3.12.0-next.46
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 +9 -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/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 +51 -6
- package/dist/lib/services/service-catalog.js.map +1 -1
- package/dist/lib/services/services.js +1 -1
- package/dist/lib/services-v2/service-catalog.js +2 -1
- package/dist/lib/services-v2/service-catalog.js.map +1 -1
- package/dist/lib/services-v2/services-v2.js +3 -2
- 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 +3 -3
- package/src/config.js +10 -0
- package/src/index.js +1 -0
- package/src/interceptors/catalog-url.js +66 -0
- package/src/lib/credentials/credentials.js +43 -0
- package/src/lib/services/service-catalog.js +54 -6
- package/src/lib/services-v2/service-catalog.ts +2 -1
- package/src/lib/services-v2/services-v2.ts +5 -1
- package/src/webex-core.js +8 -0
- package/test/unit/spec/credentials/credentials.js +114 -1
- package/test/unit/spec/interceptors/catalog-url.js +224 -0
- package/test/unit/spec/services/service-catalog.js +191 -0
- package/test/unit/spec/services-v2/service-catalog.ts +31 -0
- package/test/unit/spec/services-v2/services-v2.ts +20 -0
- package/test/unit/spec/webex-core.js +53 -1
|
@@ -7,7 +7,7 @@ import {assert} from '@webex/test-helper-chai';
|
|
|
7
7
|
import sinon from 'sinon';
|
|
8
8
|
import MockWebex from '@webex/test-helper-mock-webex';
|
|
9
9
|
import {Credentials, Token, grantErrors} from '@webex/webex-core';
|
|
10
|
-
import {inBrowser} from '@webex/common';
|
|
10
|
+
import {inBrowser, base64} from '@webex/common';
|
|
11
11
|
import FakeTimers from '@sinonjs/fake-timers';
|
|
12
12
|
import {skipInBrowser} from '@webex/test-helper-mocha';
|
|
13
13
|
import Logger from '@webex/plugin-logger';
|
|
@@ -506,6 +506,88 @@ describe('webex-core', () => {
|
|
|
506
506
|
expect(() => credentials.extractOrgIdFromUserToken()).toThrow());
|
|
507
507
|
});
|
|
508
508
|
|
|
509
|
+
describe('#extractUserIdFromToken()', () => {
|
|
510
|
+
let credentials;
|
|
511
|
+
let webex;
|
|
512
|
+
|
|
513
|
+
beforeEach(() => {
|
|
514
|
+
webex = new MockWebex();
|
|
515
|
+
credentials = new Credentials(undefined, {parent: webex});
|
|
516
|
+
});
|
|
517
|
+
|
|
518
|
+
const buildToken = (payload) =>
|
|
519
|
+
`header.${base64.toBase64Url(JSON.stringify(payload))}.signature`;
|
|
520
|
+
|
|
521
|
+
it('should return the cis_uuid from the provided token', () => {
|
|
522
|
+
const token = buildToken({cis_uuid: 'my-user-id'});
|
|
523
|
+
|
|
524
|
+
assert.equal(credentials.extractUserIdFromToken(token), 'my-user-id');
|
|
525
|
+
});
|
|
526
|
+
|
|
527
|
+
it('should throw if the token does not contain a cis_uuid', () => {
|
|
528
|
+
const token = buildToken({foo: 'bar'});
|
|
529
|
+
|
|
530
|
+
expect(() => credentials.extractUserIdFromToken(token)).toThrow(
|
|
531
|
+
'the provided token does not contain a user ID'
|
|
532
|
+
);
|
|
533
|
+
});
|
|
534
|
+
|
|
535
|
+
it('should throw when provided an unparseable token', () =>
|
|
536
|
+
expect(() => credentials.extractUserIdFromToken('not-a-valid-token')).toThrow());
|
|
537
|
+
|
|
538
|
+
it('should throw when no token is provided', () =>
|
|
539
|
+
expect(() => credentials.extractUserIdFromToken()).toThrow());
|
|
540
|
+
});
|
|
541
|
+
|
|
542
|
+
describe('#getUserId()', () => {
|
|
543
|
+
let credentials;
|
|
544
|
+
let webex;
|
|
545
|
+
|
|
546
|
+
const buildToken = (userId) =>
|
|
547
|
+
`header.${base64.toBase64Url(JSON.stringify({cis_uuid: userId}))}.signature`;
|
|
548
|
+
|
|
549
|
+
beforeEach(() => {
|
|
550
|
+
webex = new MockWebex();
|
|
551
|
+
credentials = new Credentials(undefined, {parent: webex});
|
|
552
|
+
});
|
|
553
|
+
|
|
554
|
+
it('should return the userId from the supertoken', () => {
|
|
555
|
+
credentials.supertoken = makeToken(webex, {
|
|
556
|
+
access_token: buildToken('supertoken-user-id'),
|
|
557
|
+
});
|
|
558
|
+
|
|
559
|
+
assert.equal(credentials.getUserId(), 'supertoken-user-id');
|
|
560
|
+
});
|
|
561
|
+
|
|
562
|
+
it('should fall back to a user token when the supertoken has no userId', () => {
|
|
563
|
+
credentials.supertoken = makeToken(webex, {access_token: 'AT'});
|
|
564
|
+
credentials.userTokens.add(
|
|
565
|
+
makeToken(webex, {access_token: buildToken('user-token-user-id'), scope: 'scope1'})
|
|
566
|
+
);
|
|
567
|
+
|
|
568
|
+
assert.equal(credentials.getUserId(), 'user-token-user-id');
|
|
569
|
+
});
|
|
570
|
+
|
|
571
|
+
it('should prefer the supertoken over the user tokens', () => {
|
|
572
|
+
credentials.supertoken = makeToken(webex, {
|
|
573
|
+
access_token: buildToken('supertoken-user-id'),
|
|
574
|
+
});
|
|
575
|
+
credentials.userTokens.add(
|
|
576
|
+
makeToken(webex, {access_token: buildToken('user-token-user-id'), scope: 'scope1'})
|
|
577
|
+
);
|
|
578
|
+
|
|
579
|
+
assert.equal(credentials.getUserId(), 'supertoken-user-id');
|
|
580
|
+
});
|
|
581
|
+
|
|
582
|
+
it('should throw if no available token contains a userId', () => {
|
|
583
|
+
credentials.supertoken = makeToken(webex, {access_token: 'AT'});
|
|
584
|
+
|
|
585
|
+
expect(() => credentials.getUserId()).toThrow(
|
|
586
|
+
'could not extract the user ID from any available token'
|
|
587
|
+
);
|
|
588
|
+
});
|
|
589
|
+
});
|
|
590
|
+
|
|
509
591
|
describe('#initialize()', () => {
|
|
510
592
|
it('turns a portal auth string into a configuration object', () => {
|
|
511
593
|
const webex = new MockWebex();
|
|
@@ -630,6 +712,37 @@ describe('webex-core', () => {
|
|
|
630
712
|
});
|
|
631
713
|
});
|
|
632
714
|
|
|
715
|
+
describe('#getClientToken()', () => {
|
|
716
|
+
it('requests a client token without bypassing catalog URL validation', async () => {
|
|
717
|
+
const webex = new MockWebex();
|
|
718
|
+
const credentials = new Credentials(undefined, {parent: webex});
|
|
719
|
+
const uri = 'https://idbroker.webex.com/idb/oauth2/v1/access_token';
|
|
720
|
+
const scope = 'spark:all';
|
|
721
|
+
|
|
722
|
+
webex.request.resolves({body: {access_token: 'AT', token_type: 'Bearer'}});
|
|
723
|
+
|
|
724
|
+
const token = await credentials.getClientToken({uri, scope});
|
|
725
|
+
|
|
726
|
+
assert.calledOnceWithExactly(webex.request, {
|
|
727
|
+
method: 'POST',
|
|
728
|
+
uri,
|
|
729
|
+
form: {
|
|
730
|
+
grant_type: 'client_credentials',
|
|
731
|
+
scope,
|
|
732
|
+
self_contained_token: true,
|
|
733
|
+
},
|
|
734
|
+
auth: {
|
|
735
|
+
user: 'fake',
|
|
736
|
+
pass: 'fake',
|
|
737
|
+
sendImmediately: true,
|
|
738
|
+
},
|
|
739
|
+
shouldRefreshAccessToken: false,
|
|
740
|
+
});
|
|
741
|
+
assert.instanceOf(token, Token);
|
|
742
|
+
assert.equal(token.access_token, 'AT');
|
|
743
|
+
});
|
|
744
|
+
});
|
|
745
|
+
|
|
633
746
|
describe('#getUserToken()', () => {
|
|
634
747
|
it('resolves with the supertoken if the supertoken matches the requested scopes', () => {
|
|
635
748
|
const webex = new MockWebex();
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
/*!
|
|
2
|
+
* Copyright (c) 2015-2020 Cisco Systems, Inc. See LICENSE file.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import chai from 'chai';
|
|
6
|
+
import chaiAsPromised from 'chai-as-promised';
|
|
7
|
+
import sinon from 'sinon';
|
|
8
|
+
import {CatalogUrlInterceptor} from '@webex/webex-core';
|
|
9
|
+
|
|
10
|
+
const {assert} = chai;
|
|
11
|
+
|
|
12
|
+
chai.use(chaiAsPromised);
|
|
13
|
+
sinon.assert.expose(chai.assert, {prefix: ''});
|
|
14
|
+
|
|
15
|
+
describe('webex-core', () => {
|
|
16
|
+
describe('Interceptors', () => {
|
|
17
|
+
describe('CatalogUrlInterceptor', () => {
|
|
18
|
+
let interceptor;
|
|
19
|
+
let webex;
|
|
20
|
+
|
|
21
|
+
beforeEach(() => {
|
|
22
|
+
webex = {
|
|
23
|
+
config: {
|
|
24
|
+
services: {},
|
|
25
|
+
},
|
|
26
|
+
internal: {
|
|
27
|
+
services: {
|
|
28
|
+
getServiceFromUrl: sinon.stub(),
|
|
29
|
+
},
|
|
30
|
+
},
|
|
31
|
+
logger: {
|
|
32
|
+
warn: sinon.stub(),
|
|
33
|
+
},
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
interceptor = Reflect.apply(CatalogUrlInterceptor.create, webex, []);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
describe('#onRequest()', () => {
|
|
40
|
+
it('allows catalog URLs', async () => {
|
|
41
|
+
const options = {uri: 'https://conv-a.wbx2.com/conversation/api/v1/messages'};
|
|
42
|
+
webex.internal.services.getServiceFromUrl.returns({name: 'conversation'});
|
|
43
|
+
|
|
44
|
+
const result = await interceptor.onRequest(options);
|
|
45
|
+
|
|
46
|
+
assert.deepEqual(result, options);
|
|
47
|
+
assert.calledOnceWithExactly(webex.internal.services.getServiceFromUrl, options.uri);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it('blocks non-catalog URLs with error', async () => {
|
|
51
|
+
const options = {uri: 'https://evil.attacker.com/steal-data'};
|
|
52
|
+
webex.internal.services.getServiceFromUrl.returns(undefined);
|
|
53
|
+
|
|
54
|
+
await assert.isRejected(
|
|
55
|
+
interceptor.onRequest(options),
|
|
56
|
+
/Request blocked: URL not in service catalog/
|
|
57
|
+
);
|
|
58
|
+
assert.calledOnce(webex.internal.services.getServiceFromUrl);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
describe('service parameter bypass', () => {
|
|
62
|
+
it('skips validation when service has not been resolved to a URL', async () => {
|
|
63
|
+
const options = {
|
|
64
|
+
service: 'conversation',
|
|
65
|
+
resource: '/messages',
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
const result = await interceptor.onRequest(options);
|
|
69
|
+
|
|
70
|
+
assert.deepEqual(result, options);
|
|
71
|
+
assert.notCalled(webex.internal.services.getServiceFromUrl);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it('blocks a non-catalog URL when service is also present', async () => {
|
|
75
|
+
const options = {
|
|
76
|
+
service: 'conversation',
|
|
77
|
+
resource: '/messages',
|
|
78
|
+
uri: 'https://attacker.com/steal',
|
|
79
|
+
headers: {authorization: 'Bearer token'},
|
|
80
|
+
};
|
|
81
|
+
webex.internal.services.getServiceFromUrl.returns(undefined);
|
|
82
|
+
|
|
83
|
+
await assert.isRejected(interceptor.onRequest(options), /Request blocked/);
|
|
84
|
+
assert.calledOnceWithExactly(webex.internal.services.getServiceFromUrl, options.uri);
|
|
85
|
+
});
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
describe('url parameter support', () => {
|
|
89
|
+
it('validates options.url when options.uri is not present', async () => {
|
|
90
|
+
const options = {url: 'https://conv-a.wbx2.com/conversation/api/v1/messages'};
|
|
91
|
+
webex.internal.services.getServiceFromUrl.returns({name: 'conversation'});
|
|
92
|
+
|
|
93
|
+
const result = await interceptor.onRequest(options);
|
|
94
|
+
|
|
95
|
+
assert.deepEqual(result, options);
|
|
96
|
+
assert.calledOnceWithExactly(webex.internal.services.getServiceFromUrl, options.url);
|
|
97
|
+
});
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
describe('edge cases', () => {
|
|
101
|
+
it('allows request when no URL is present', async () => {
|
|
102
|
+
const options = {method: 'GET'};
|
|
103
|
+
|
|
104
|
+
const result = await interceptor.onRequest(options);
|
|
105
|
+
|
|
106
|
+
assert.deepEqual(result, options);
|
|
107
|
+
assert.notCalled(webex.internal.services.getServiceFromUrl);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it('handles missing services plugin gracefully', async () => {
|
|
111
|
+
webex.internal = undefined;
|
|
112
|
+
const options = {uri: 'https://example.com/api'};
|
|
113
|
+
|
|
114
|
+
const result = await interceptor.onRequest(options);
|
|
115
|
+
|
|
116
|
+
assert.deepEqual(result, options);
|
|
117
|
+
});
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
describe('security scenarios', () => {
|
|
121
|
+
it('blocks Mercury-injected malicious URLs', async () => {
|
|
122
|
+
// This is the SSRF attack vector: Mercury event contains attacker URL
|
|
123
|
+
// that the flag plugin would POST to without validation
|
|
124
|
+
const options = {
|
|
125
|
+
method: 'POST',
|
|
126
|
+
uri: 'https://attacker-controlled.com/exfiltrate',
|
|
127
|
+
body: {sensitiveData: 'secrets'},
|
|
128
|
+
};
|
|
129
|
+
webex.internal.services.getServiceFromUrl.returns(undefined);
|
|
130
|
+
|
|
131
|
+
await assert.isRejected(interceptor.onRequest(options), /Request blocked/);
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it('allows legitimate activity service URLs', async () => {
|
|
135
|
+
const options = {
|
|
136
|
+
method: 'POST',
|
|
137
|
+
uri: 'https://conv-a.wbx2.com/conversation/api/v1/activities/12345',
|
|
138
|
+
body: {verb: 'flag'},
|
|
139
|
+
};
|
|
140
|
+
webex.internal.services.getServiceFromUrl.returns({name: 'conversation'});
|
|
141
|
+
|
|
142
|
+
const result = await interceptor.onRequest(options);
|
|
143
|
+
|
|
144
|
+
assert.deepEqual(result, options);
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it('blocks URLs with similar-looking hostnames', async () => {
|
|
148
|
+
// Attacker might try to use a URL that looks similar to a catalog URL
|
|
149
|
+
const options = {
|
|
150
|
+
uri: 'https://conv-a.wbx2.com.attacker.com/api',
|
|
151
|
+
};
|
|
152
|
+
webex.internal.services.getServiceFromUrl.returns(undefined);
|
|
153
|
+
|
|
154
|
+
await assert.isRejected(interceptor.onRequest(options), /Request blocked/);
|
|
155
|
+
});
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
describe('allowedDomains support', () => {
|
|
159
|
+
beforeEach(() => {
|
|
160
|
+
// Add allowedDomains methods to mock
|
|
161
|
+
webex.internal.services.validateDomains = true;
|
|
162
|
+
webex.internal.services.hasAllowedDomains = sinon.stub();
|
|
163
|
+
webex.internal.services.isAllowedDomainUrl = sinon.stub();
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
it('allows URLs in allowedDomains when not in catalog', async () => {
|
|
167
|
+
const options = {uri: 'https://cdn.example.com/files/encrypted-attachment.bin'};
|
|
168
|
+
webex.internal.services.getServiceFromUrl.returns(undefined);
|
|
169
|
+
webex.internal.services.hasAllowedDomains.returns(true);
|
|
170
|
+
webex.internal.services.isAllowedDomainUrl.returns(true);
|
|
171
|
+
|
|
172
|
+
const result = await interceptor.onRequest(options);
|
|
173
|
+
|
|
174
|
+
assert.deepEqual(result, options);
|
|
175
|
+
assert.calledOnceWithExactly(webex.internal.services.isAllowedDomainUrl, options.uri);
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
it('blocks URLs not in catalog and not in allowedDomains', async () => {
|
|
179
|
+
const options = {uri: 'https://attacker.com/steal'};
|
|
180
|
+
webex.internal.services.getServiceFromUrl.returns(undefined);
|
|
181
|
+
webex.internal.services.hasAllowedDomains.returns(true);
|
|
182
|
+
webex.internal.services.isAllowedDomainUrl.returns(false);
|
|
183
|
+
|
|
184
|
+
await assert.isRejected(
|
|
185
|
+
interceptor.onRequest(options),
|
|
186
|
+
/Request blocked: URL not in service catalog or allowed domains/
|
|
187
|
+
);
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
it('blocks when validateDomains is false', async () => {
|
|
191
|
+
const options = {uri: 'https://cdn.example.com/files/attachment.bin'};
|
|
192
|
+
webex.internal.services.validateDomains = false;
|
|
193
|
+
webex.internal.services.getServiceFromUrl.returns(undefined);
|
|
194
|
+
webex.internal.services.hasAllowedDomains.returns(true);
|
|
195
|
+
webex.internal.services.isAllowedDomainUrl.returns(true);
|
|
196
|
+
|
|
197
|
+
await assert.isRejected(interceptor.onRequest(options), /Request blocked/);
|
|
198
|
+
assert.notCalled(webex.internal.services.isAllowedDomainUrl);
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
it('blocks when no allowedDomains configured', async () => {
|
|
202
|
+
const options = {uri: 'https://cdn.example.com/files/attachment.bin'};
|
|
203
|
+
webex.internal.services.getServiceFromUrl.returns(undefined);
|
|
204
|
+
webex.internal.services.hasAllowedDomains.returns(false);
|
|
205
|
+
|
|
206
|
+
await assert.isRejected(interceptor.onRequest(options), /Request blocked/);
|
|
207
|
+
assert.notCalled(webex.internal.services.isAllowedDomainUrl);
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
it('skips allowedDomains check when URL is in catalog', async () => {
|
|
211
|
+
const options = {uri: 'https://conv-a.wbx2.com/conversation/api/v1/messages'};
|
|
212
|
+
webex.internal.services.getServiceFromUrl.returns({name: 'conversation'});
|
|
213
|
+
|
|
214
|
+
const result = await interceptor.onRequest(options);
|
|
215
|
+
|
|
216
|
+
assert.deepEqual(result, options);
|
|
217
|
+
assert.notCalled(webex.internal.services.hasAllowedDomains);
|
|
218
|
+
assert.notCalled(webex.internal.services.isAllowedDomainUrl);
|
|
219
|
+
});
|
|
220
|
+
});
|
|
221
|
+
});
|
|
222
|
+
});
|
|
223
|
+
});
|
|
224
|
+
});
|
|
@@ -5,6 +5,94 @@
|
|
|
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 {matchesCatalogUrl} from '../../../../src/lib/services/service-catalog';
|
|
9
|
+
|
|
10
|
+
describe('matchesCatalogUrl()', () => {
|
|
11
|
+
describe('origin validation', () => {
|
|
12
|
+
it('returns true when origins match exactly', () => {
|
|
13
|
+
assert.isTrue(
|
|
14
|
+
matchesCatalogUrl('https://example.com/api/v1/users', 'https://example.com/api/v1')
|
|
15
|
+
);
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it('returns false when hosts differ', () => {
|
|
19
|
+
assert.isFalse(
|
|
20
|
+
matchesCatalogUrl('https://other.com/api/v1/users', 'https://example.com/api/v1')
|
|
21
|
+
);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it('returns false when catalog host is prefix of candidate host (SECURITY)', () => {
|
|
25
|
+
// Attack: trusted.example.attacker.com should NOT match trusted.example
|
|
26
|
+
assert.isFalse(
|
|
27
|
+
matchesCatalogUrl(
|
|
28
|
+
'https://trusted.example.attacker.com/activities/id',
|
|
29
|
+
'https://trusted.example'
|
|
30
|
+
)
|
|
31
|
+
);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it('returns false when schemes differ', () => {
|
|
35
|
+
assert.isFalse(
|
|
36
|
+
matchesCatalogUrl('http://example.com/api/v1/users', 'https://example.com/api/v1')
|
|
37
|
+
);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it('returns false when ports differ', () => {
|
|
41
|
+
assert.isFalse(
|
|
42
|
+
matchesCatalogUrl('https://example.com:8443/api/v1/users', 'https://example.com/api/v1')
|
|
43
|
+
);
|
|
44
|
+
});
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
describe('path validation', () => {
|
|
48
|
+
it('returns true for root path catalog entry', () => {
|
|
49
|
+
assert.isTrue(matchesCatalogUrl('https://example.com/any/path/here', 'https://example.com/'));
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it('returns true when paths match exactly', () => {
|
|
53
|
+
assert.isTrue(matchesCatalogUrl('https://example.com/api/v1', 'https://example.com/api/v1'));
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it('returns true when candidate path extends catalog path at boundary', () => {
|
|
57
|
+
assert.isTrue(
|
|
58
|
+
matchesCatalogUrl('https://example.com/api/v1/users/123', 'https://example.com/api/v1')
|
|
59
|
+
);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it('returns true when catalog path has trailing slash', () => {
|
|
63
|
+
assert.isTrue(
|
|
64
|
+
matchesCatalogUrl('https://example.com/api/v1/users', 'https://example.com/api/v1/')
|
|
65
|
+
);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it('returns false when catalog path is prefix but not at path boundary (SECURITY)', () => {
|
|
69
|
+
// /api/v1 should NOT match /api/v1extra
|
|
70
|
+
assert.isFalse(
|
|
71
|
+
matchesCatalogUrl('https://example.com/api/v1extra/something', 'https://example.com/api/v1')
|
|
72
|
+
);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it('returns false when candidate path does not start with catalog path', () => {
|
|
76
|
+
assert.isFalse(
|
|
77
|
+
matchesCatalogUrl('https://example.com/other/path', 'https://example.com/api/v1')
|
|
78
|
+
);
|
|
79
|
+
});
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
describe('error handling', () => {
|
|
83
|
+
it('returns false for invalid candidate URL', () => {
|
|
84
|
+
assert.isFalse(matchesCatalogUrl('not-a-url', 'https://example.com/api'));
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it('returns false for invalid catalog URL', () => {
|
|
88
|
+
assert.isFalse(matchesCatalogUrl('https://example.com/api', 'not-a-url'));
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it('returns false when both URLs are invalid', () => {
|
|
92
|
+
assert.isFalse(matchesCatalogUrl('not-a-url', 'also-not-a-url'));
|
|
93
|
+
});
|
|
94
|
+
});
|
|
95
|
+
});
|
|
8
96
|
|
|
9
97
|
/* eslint-disable no-underscore-dangle */
|
|
10
98
|
describe('webex-core', () => {
|
|
@@ -332,6 +420,109 @@ describe('webex-core', () => {
|
|
|
332
420
|
|
|
333
421
|
assert.equal(service, exampleService);
|
|
334
422
|
});
|
|
423
|
+
|
|
424
|
+
describe('security: origin validation', () => {
|
|
425
|
+
it('rejects URLs where the catalog host is a prefix of the candidate host (SECURITY)', () => {
|
|
426
|
+
// Attack: https://trusted.example.attacker.com should NOT match https://trusted.example
|
|
427
|
+
const exampleService = {
|
|
428
|
+
defaultUrl: 'https://trusted.example',
|
|
429
|
+
hosts: [],
|
|
430
|
+
};
|
|
431
|
+
|
|
432
|
+
catalog.serviceGroups.postauth.push(exampleService);
|
|
433
|
+
|
|
434
|
+
// Attacker registers trusted.example.attacker.com
|
|
435
|
+
const attackerUrl = 'https://trusted.example.attacker.com/activities/id';
|
|
436
|
+
const service = catalog.findServiceUrlFromUrl(attackerUrl);
|
|
437
|
+
|
|
438
|
+
assert.isUndefined(service);
|
|
439
|
+
});
|
|
440
|
+
|
|
441
|
+
it('rejects URLs with different ports even if host matches', () => {
|
|
442
|
+
const exampleService = {
|
|
443
|
+
defaultUrl: 'https://example.com/resource',
|
|
444
|
+
hosts: [],
|
|
445
|
+
};
|
|
446
|
+
|
|
447
|
+
catalog.serviceGroups.postauth.push(exampleService);
|
|
448
|
+
|
|
449
|
+
const differentPortUrl = 'https://example.com:8443/resource/id';
|
|
450
|
+
const service = catalog.findServiceUrlFromUrl(differentPortUrl);
|
|
451
|
+
|
|
452
|
+
assert.isUndefined(service);
|
|
453
|
+
});
|
|
454
|
+
|
|
455
|
+
it('rejects URLs with different schemes', () => {
|
|
456
|
+
const exampleService = {
|
|
457
|
+
defaultUrl: 'https://example.com/resource',
|
|
458
|
+
hosts: [],
|
|
459
|
+
};
|
|
460
|
+
|
|
461
|
+
catalog.serviceGroups.postauth.push(exampleService);
|
|
462
|
+
|
|
463
|
+
const httpUrl = 'http://example.com/resource/id';
|
|
464
|
+
const service = catalog.findServiceUrlFromUrl(httpUrl);
|
|
465
|
+
|
|
466
|
+
assert.isUndefined(service);
|
|
467
|
+
});
|
|
468
|
+
|
|
469
|
+
it('rejects URLs where catalog path is a prefix but not at path boundary', () => {
|
|
470
|
+
// /api/v1 should NOT match /api/v1extra
|
|
471
|
+
const exampleService = {
|
|
472
|
+
defaultUrl: 'https://example.com/api/v1',
|
|
473
|
+
hosts: [],
|
|
474
|
+
};
|
|
475
|
+
|
|
476
|
+
catalog.serviceGroups.postauth.push(exampleService);
|
|
477
|
+
|
|
478
|
+
const nonBoundaryUrl = 'https://example.com/api/v1extra/something';
|
|
479
|
+
const service = catalog.findServiceUrlFromUrl(nonBoundaryUrl);
|
|
480
|
+
|
|
481
|
+
assert.isUndefined(service);
|
|
482
|
+
});
|
|
483
|
+
|
|
484
|
+
it('accepts URLs at exact path boundary', () => {
|
|
485
|
+
const exampleService = {
|
|
486
|
+
defaultUrl: 'https://example.com/api/v1',
|
|
487
|
+
hosts: [],
|
|
488
|
+
};
|
|
489
|
+
|
|
490
|
+
catalog.serviceGroups.postauth.push(exampleService);
|
|
491
|
+
|
|
492
|
+
const boundaryUrl = 'https://example.com/api/v1/users/123';
|
|
493
|
+
const service = catalog.findServiceUrlFromUrl(boundaryUrl);
|
|
494
|
+
|
|
495
|
+
assert.equal(service, exampleService);
|
|
496
|
+
});
|
|
497
|
+
|
|
498
|
+
it('returns undefined for invalid URLs', () => {
|
|
499
|
+
const exampleService = {
|
|
500
|
+
defaultUrl: 'https://example.com/resource',
|
|
501
|
+
hosts: [],
|
|
502
|
+
};
|
|
503
|
+
|
|
504
|
+
catalog.serviceGroups.postauth.push(exampleService);
|
|
505
|
+
|
|
506
|
+
const invalidUrl = 'not-a-valid-url';
|
|
507
|
+
const service = catalog.findServiceUrlFromUrl(invalidUrl);
|
|
508
|
+
|
|
509
|
+
assert.isUndefined(service);
|
|
510
|
+
});
|
|
511
|
+
|
|
512
|
+
it('matches root path catalog entries correctly', () => {
|
|
513
|
+
const exampleService = {
|
|
514
|
+
defaultUrl: 'https://example.com/',
|
|
515
|
+
hosts: [],
|
|
516
|
+
};
|
|
517
|
+
|
|
518
|
+
catalog.serviceGroups.postauth.push(exampleService);
|
|
519
|
+
|
|
520
|
+
const subpathUrl = 'https://example.com/any/path/here';
|
|
521
|
+
const service = catalog.findServiceUrlFromUrl(subpathUrl);
|
|
522
|
+
|
|
523
|
+
assert.equal(service, exampleService);
|
|
524
|
+
});
|
|
525
|
+
});
|
|
335
526
|
});
|
|
336
527
|
});
|
|
337
528
|
});
|
|
@@ -365,6 +365,37 @@ 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
|
+
});
|
|
368
399
|
});
|
|
369
400
|
});
|
|
370
401
|
});
|
|
@@ -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;
|