@learncard/partner-connect 0.3.6 → 0.3.8
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/README.md +48 -16
- package/dist/index.d.ts +45 -1
- package/dist/partner-connect.esm.js +74 -6
- package/dist/partner-connect.esm.js.map +1 -1
- package/dist/partner-connect.js +74 -6
- package/dist/partner-connect.js.map +1 -1
- package/dist/partner-connect.mjs +74 -6
- package/dist/partner-connect.mjs.map +1 -1
- package/package.json +59 -57
- package/src/index.ts +1013 -0
- package/src/origin-resolution.test.ts +374 -0
- package/src/pattern-matching.test.ts +134 -0
- package/src/types.ts +666 -0
- package/LICENSE +0 -21
|
@@ -0,0 +1,374 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for the origin-resolution layer of @learncard/partner-connect.
|
|
3
|
+
*
|
|
4
|
+
* These tests run in jsdom and focus on three aspects:
|
|
5
|
+
* 1. How `hostOrigin` entries are merged with the built-in tenant list.
|
|
6
|
+
* 2. How the active host origin is selected from `ancestorOrigins` /
|
|
7
|
+
* `lc_host_override` / `sessionStorage` / configured fallbacks.
|
|
8
|
+
* 3. How wildcard patterns behave both as whitelist entries and when
|
|
9
|
+
* rejecting untrusted event origins at runtime.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { PartnerConnect } from './index';
|
|
13
|
+
|
|
14
|
+
// ---------------------------------------------------------------------------
|
|
15
|
+
// Shared helpers
|
|
16
|
+
// ---------------------------------------------------------------------------
|
|
17
|
+
|
|
18
|
+
interface WindowOverrides {
|
|
19
|
+
search?: string;
|
|
20
|
+
origin?: string;
|
|
21
|
+
ancestors?: readonly string[] | null;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Replace `window.location` and `window.location.ancestorOrigins` for a test.
|
|
26
|
+
*
|
|
27
|
+
* jsdom's `location` is read-only in recent versions, so we `defineProperty`
|
|
28
|
+
* with a brand-new object that carries the pieces `configureActiveOrigin()`
|
|
29
|
+
* actually reads.
|
|
30
|
+
*/
|
|
31
|
+
function installLocation(overrides: WindowOverrides): void {
|
|
32
|
+
const {
|
|
33
|
+
search = '',
|
|
34
|
+
origin = 'https://partner-app.example.com',
|
|
35
|
+
ancestors = null,
|
|
36
|
+
} = overrides;
|
|
37
|
+
|
|
38
|
+
const ancestorOrigins: DOMStringList | undefined =
|
|
39
|
+
ancestors === null
|
|
40
|
+
? undefined
|
|
41
|
+
: ({
|
|
42
|
+
length: ancestors.length,
|
|
43
|
+
item(index: number): string | null {
|
|
44
|
+
return ancestors[index] ?? null;
|
|
45
|
+
},
|
|
46
|
+
contains(value: string): boolean {
|
|
47
|
+
return ancestors.includes(value);
|
|
48
|
+
},
|
|
49
|
+
// Array index access used by `ancestorOrigins[0]`.
|
|
50
|
+
...Object.fromEntries(ancestors.map((value, i) => [i, value])),
|
|
51
|
+
} as unknown as DOMStringList);
|
|
52
|
+
|
|
53
|
+
const stub: Partial<Location> & { ancestorOrigins?: DOMStringList } = {
|
|
54
|
+
search,
|
|
55
|
+
origin,
|
|
56
|
+
href: `${origin}${search}`,
|
|
57
|
+
hostname: new URL(origin).hostname,
|
|
58
|
+
host: new URL(origin).host,
|
|
59
|
+
protocol: new URL(origin).protocol,
|
|
60
|
+
ancestorOrigins,
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
Object.defineProperty(window, 'location', {
|
|
64
|
+
configurable: true,
|
|
65
|
+
writable: true,
|
|
66
|
+
value: stub as Location,
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function clearSessionStorage(): void {
|
|
71
|
+
try {
|
|
72
|
+
window.sessionStorage.clear();
|
|
73
|
+
} catch {
|
|
74
|
+
/* ignore */
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Silence expected log output from the SDK in passing tests.
|
|
79
|
+
let consoleLogSpy: jest.SpyInstance;
|
|
80
|
+
let consoleWarnSpy: jest.SpyInstance;
|
|
81
|
+
let consoleErrorSpy: jest.SpyInstance;
|
|
82
|
+
|
|
83
|
+
beforeEach(() => {
|
|
84
|
+
clearSessionStorage();
|
|
85
|
+
consoleLogSpy = jest.spyOn(console, 'log').mockImplementation(() => {});
|
|
86
|
+
consoleWarnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {});
|
|
87
|
+
consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(() => {});
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
afterEach(() => {
|
|
91
|
+
consoleLogSpy.mockRestore();
|
|
92
|
+
consoleWarnSpy.mockRestore();
|
|
93
|
+
consoleErrorSpy.mockRestore();
|
|
94
|
+
installLocation({}); // reset for the next test
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
function getActiveOrigin(sdk: PartnerConnect): string {
|
|
98
|
+
return (sdk as unknown as { activeHostOrigin: string }).activeHostOrigin;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function getHostOrigins(sdk: PartnerConnect): string[] {
|
|
102
|
+
return (sdk as unknown as { hostOrigins: string[] }).hostOrigins;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function isValidOrigin(sdk: PartnerConnect, origin: string): boolean {
|
|
106
|
+
const fn = (sdk as unknown as { isValidOrigin: (o: string) => boolean }).isValidOrigin;
|
|
107
|
+
|
|
108
|
+
return fn.call(sdk, origin);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// ---------------------------------------------------------------------------
|
|
112
|
+
// DEFAULT_TRUSTED_TENANTS + disableDefaultTenants
|
|
113
|
+
// ---------------------------------------------------------------------------
|
|
114
|
+
|
|
115
|
+
describe('default tenant whitelist', () => {
|
|
116
|
+
test('is merged into hostOrigins by default', () => {
|
|
117
|
+
installLocation({});
|
|
118
|
+
const sdk = new PartnerConnect({ hostOrigin: 'https://partner.example.com' });
|
|
119
|
+
|
|
120
|
+
const whitelist = getHostOrigins(sdk);
|
|
121
|
+
|
|
122
|
+
expect(whitelist[0]).toBe('https://partner.example.com');
|
|
123
|
+
for (const entry of PartnerConnect.DEFAULT_TRUSTED_TENANTS) {
|
|
124
|
+
expect(whitelist).toContain(entry);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
sdk.destroy();
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
test('honors disableDefaultTenants: true', () => {
|
|
131
|
+
installLocation({});
|
|
132
|
+
const sdk = new PartnerConnect({
|
|
133
|
+
hostOrigin: 'https://partner.example.com',
|
|
134
|
+
disableDefaultTenants: true,
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
expect(getHostOrigins(sdk)).toEqual(['https://partner.example.com']);
|
|
138
|
+
|
|
139
|
+
sdk.destroy();
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
test('lets a LearnCard tenant override activate via query param without any partner config', () => {
|
|
143
|
+
installLocation({ search: '?lc_host_override=https://alpha.vetpass.app' });
|
|
144
|
+
const sdk = new PartnerConnect(); // no hostOrigin at all
|
|
145
|
+
|
|
146
|
+
expect(getActiveOrigin(sdk)).toBe('https://alpha.vetpass.app');
|
|
147
|
+
|
|
148
|
+
sdk.destroy();
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
test('rejects an override outside both configured and default whitelists', () => {
|
|
152
|
+
installLocation({ search: '?lc_host_override=https://evil.com' });
|
|
153
|
+
const sdk = new PartnerConnect({ hostOrigin: 'https://partner.example.com' });
|
|
154
|
+
|
|
155
|
+
expect(getActiveOrigin(sdk)).toBe('https://partner.example.com');
|
|
156
|
+
expect(consoleWarnSpy).toHaveBeenCalled();
|
|
157
|
+
|
|
158
|
+
sdk.destroy();
|
|
159
|
+
});
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
// ---------------------------------------------------------------------------
|
|
163
|
+
// Wildcard pattern matching
|
|
164
|
+
// ---------------------------------------------------------------------------
|
|
165
|
+
|
|
166
|
+
describe('wildcard pattern matching in hostOrigin', () => {
|
|
167
|
+
test.each([
|
|
168
|
+
['https://staging.learncard.app'],
|
|
169
|
+
['https://alpha.learncard.app'],
|
|
170
|
+
['https://pr-123.preview.learncard.app'],
|
|
171
|
+
])('accepts %s via https://*.learncard.app override', candidate => {
|
|
172
|
+
installLocation({ search: `?lc_host_override=${encodeURIComponent(candidate)}` });
|
|
173
|
+
const sdk = new PartnerConnect({
|
|
174
|
+
hostOrigin: ['https://learncard.app', 'https://*.learncard.app'],
|
|
175
|
+
disableDefaultTenants: true,
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
expect(getActiveOrigin(sdk)).toBe(candidate);
|
|
179
|
+
|
|
180
|
+
sdk.destroy();
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
test.each([
|
|
184
|
+
// Bare apex must not match `*.learncard.app` — caller must add the exact origin.
|
|
185
|
+
['https://learncard.app', 'https://*.learncard.app'],
|
|
186
|
+
// Protocol mismatch.
|
|
187
|
+
['http://staging.learncard.app', 'https://*.learncard.app'],
|
|
188
|
+
// Suffix confusion: the domain is the attacker's, not LearnCard's.
|
|
189
|
+
['https://learncard.app.attacker.com', 'https://*.learncard.app'],
|
|
190
|
+
// Empty label where the wildcard should go.
|
|
191
|
+
['https://.learncard.app', 'https://*.learncard.app'],
|
|
192
|
+
])('rejects %s against %s', (candidate, pattern) => {
|
|
193
|
+
installLocation({ search: `?lc_host_override=${encodeURIComponent(candidate)}` });
|
|
194
|
+
const sdk = new PartnerConnect({
|
|
195
|
+
hostOrigin: [pattern],
|
|
196
|
+
disableDefaultTenants: true,
|
|
197
|
+
// turn off native-app so localhost/capacitor don't accidentally rescue tests
|
|
198
|
+
allowNativeAppOrigins: false,
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
expect(getActiveOrigin(sdk)).toBe(pattern);
|
|
202
|
+
|
|
203
|
+
sdk.destroy();
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
test('exact-origin entries still match', () => {
|
|
207
|
+
installLocation({ search: '?lc_host_override=https://learncard.app' });
|
|
208
|
+
const sdk = new PartnerConnect({
|
|
209
|
+
hostOrigin: 'https://learncard.app',
|
|
210
|
+
disableDefaultTenants: true,
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
expect(getActiveOrigin(sdk)).toBe('https://learncard.app');
|
|
214
|
+
|
|
215
|
+
sdk.destroy();
|
|
216
|
+
});
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
// ---------------------------------------------------------------------------
|
|
220
|
+
// Priority: ancestorOrigins > lc_host_override > sessionStorage > fallback
|
|
221
|
+
// ---------------------------------------------------------------------------
|
|
222
|
+
|
|
223
|
+
describe('active-origin resolution hierarchy', () => {
|
|
224
|
+
test('prefers ancestorOrigins[0] when it is in the whitelist', () => {
|
|
225
|
+
installLocation({
|
|
226
|
+
search: '?lc_host_override=https://learncard.app',
|
|
227
|
+
ancestors: ['https://alpha.vetpass.app'],
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
const sdk = new PartnerConnect({ hostOrigin: 'https://learncard.app' });
|
|
231
|
+
|
|
232
|
+
expect(getActiveOrigin(sdk)).toBe('https://alpha.vetpass.app');
|
|
233
|
+
// Warn because override disagreed with real parent.
|
|
234
|
+
expect(consoleWarnSpy).toHaveBeenCalledWith(
|
|
235
|
+
expect.stringContaining('lc_host_override does not match'),
|
|
236
|
+
expect.any(Object)
|
|
237
|
+
);
|
|
238
|
+
|
|
239
|
+
sdk.destroy();
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
test('ignores ancestorOrigins[0] when it is NOT in the whitelist', () => {
|
|
243
|
+
installLocation({
|
|
244
|
+
search: '?lc_host_override=https://learncard.app',
|
|
245
|
+
ancestors: ['https://evil.com'],
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
const sdk = new PartnerConnect({
|
|
249
|
+
hostOrigin: 'https://learncard.app',
|
|
250
|
+
disableDefaultTenants: true,
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
// Since the ancestor isn't trusted, we fall through to the override.
|
|
254
|
+
expect(getActiveOrigin(sdk)).toBe('https://learncard.app');
|
|
255
|
+
|
|
256
|
+
sdk.destroy();
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
test('falls back to lc_host_override when ancestorOrigins is unavailable', () => {
|
|
260
|
+
installLocation({
|
|
261
|
+
search: '?lc_host_override=https://staging.learncard.app',
|
|
262
|
+
ancestors: null, // Firefox / top-level context
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
const sdk = new PartnerConnect({
|
|
266
|
+
hostOrigin: ['https://learncard.app', 'https://*.learncard.app'],
|
|
267
|
+
disableDefaultTenants: true,
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
expect(getActiveOrigin(sdk)).toBe('https://staging.learncard.app');
|
|
271
|
+
|
|
272
|
+
sdk.destroy();
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
test('falls back to sessionStorage when no query param is present', () => {
|
|
276
|
+
window.sessionStorage.setItem('lc_host_override', 'https://staging.learncard.app');
|
|
277
|
+
|
|
278
|
+
installLocation({ search: '' });
|
|
279
|
+
|
|
280
|
+
const sdk = new PartnerConnect({
|
|
281
|
+
hostOrigin: ['https://learncard.app', 'https://*.learncard.app'],
|
|
282
|
+
disableDefaultTenants: true,
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
expect(getActiveOrigin(sdk)).toBe('https://staging.learncard.app');
|
|
286
|
+
|
|
287
|
+
sdk.destroy();
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
test('falls back to the first configured origin when everything else is absent', () => {
|
|
291
|
+
installLocation({});
|
|
292
|
+
const sdk = new PartnerConnect({
|
|
293
|
+
hostOrigin: ['https://partner.example.com'],
|
|
294
|
+
disableDefaultTenants: true,
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
expect(getActiveOrigin(sdk)).toBe('https://partner.example.com');
|
|
298
|
+
|
|
299
|
+
sdk.destroy();
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
test('persists a valid override back into sessionStorage', () => {
|
|
303
|
+
installLocation({ search: '?lc_host_override=https://staging.learncard.app' });
|
|
304
|
+
|
|
305
|
+
const sdk = new PartnerConnect({
|
|
306
|
+
hostOrigin: ['https://learncard.app', 'https://*.learncard.app'],
|
|
307
|
+
disableDefaultTenants: true,
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
expect(window.sessionStorage.getItem('lc_host_override')).toBe(
|
|
311
|
+
'https://staging.learncard.app'
|
|
312
|
+
);
|
|
313
|
+
|
|
314
|
+
sdk.destroy();
|
|
315
|
+
});
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
// ---------------------------------------------------------------------------
|
|
319
|
+
// Runtime message validation
|
|
320
|
+
// ---------------------------------------------------------------------------
|
|
321
|
+
|
|
322
|
+
describe('incoming message validation', () => {
|
|
323
|
+
test('accepts messages from the active host origin and rejects everything else', () => {
|
|
324
|
+
installLocation({
|
|
325
|
+
search: '?lc_host_override=https://alpha.vetpass.app',
|
|
326
|
+
});
|
|
327
|
+
const sdk = new PartnerConnect({
|
|
328
|
+
hostOrigin: ['https://learncard.app', 'https://*.vetpass.app'],
|
|
329
|
+
disableDefaultTenants: true,
|
|
330
|
+
});
|
|
331
|
+
|
|
332
|
+
expect(getActiveOrigin(sdk)).toBe('https://alpha.vetpass.app');
|
|
333
|
+
expect(isValidOrigin(sdk, 'https://alpha.vetpass.app')).toBe(true);
|
|
334
|
+
|
|
335
|
+
// Other whitelisted origins must still be rejected — only the single
|
|
336
|
+
// active origin is trusted for event validation.
|
|
337
|
+
expect(isValidOrigin(sdk, 'https://learncard.app')).toBe(false);
|
|
338
|
+
expect(isValidOrigin(sdk, 'https://vetpass.app')).toBe(false);
|
|
339
|
+
expect(isValidOrigin(sdk, 'https://evil.com')).toBe(false);
|
|
340
|
+
|
|
341
|
+
sdk.destroy();
|
|
342
|
+
});
|
|
343
|
+
});
|
|
344
|
+
|
|
345
|
+
// ---------------------------------------------------------------------------
|
|
346
|
+
// Native-app origins
|
|
347
|
+
// ---------------------------------------------------------------------------
|
|
348
|
+
|
|
349
|
+
describe('native-app origins', () => {
|
|
350
|
+
test('are accepted by default without being in hostOrigin', () => {
|
|
351
|
+
installLocation({ search: '?lc_host_override=capacitor://localhost' });
|
|
352
|
+
const sdk = new PartnerConnect({
|
|
353
|
+
hostOrigin: 'https://learncard.app',
|
|
354
|
+
disableDefaultTenants: true,
|
|
355
|
+
});
|
|
356
|
+
|
|
357
|
+
expect(getActiveOrigin(sdk)).toBe('capacitor://localhost');
|
|
358
|
+
|
|
359
|
+
sdk.destroy();
|
|
360
|
+
});
|
|
361
|
+
|
|
362
|
+
test('can be disabled via allowNativeAppOrigins: false', () => {
|
|
363
|
+
installLocation({ search: '?lc_host_override=capacitor://localhost' });
|
|
364
|
+
const sdk = new PartnerConnect({
|
|
365
|
+
hostOrigin: 'https://learncard.app',
|
|
366
|
+
disableDefaultTenants: true,
|
|
367
|
+
allowNativeAppOrigins: false,
|
|
368
|
+
});
|
|
369
|
+
|
|
370
|
+
expect(getActiveOrigin(sdk)).toBe('https://learncard.app');
|
|
371
|
+
|
|
372
|
+
sdk.destroy();
|
|
373
|
+
});
|
|
374
|
+
});
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Direct unit tests for `PartnerConnect.matchesOriginPattern`.
|
|
3
|
+
*
|
|
4
|
+
* These complement the integration-level tests in `origin-resolution.test.ts`
|
|
5
|
+
* by exercising the pure pattern matcher in isolation, without spinning up the
|
|
6
|
+
* full SDK / jsdom location stub. The matcher is the security-critical core
|
|
7
|
+
* of the whitelist, so it gets its own focused test file.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { PartnerConnect } from './index';
|
|
11
|
+
|
|
12
|
+
const matches = PartnerConnect.matchesOriginPattern.bind(PartnerConnect);
|
|
13
|
+
|
|
14
|
+
describe('PartnerConnect.matchesOriginPattern', () => {
|
|
15
|
+
describe('exact matches (no wildcard)', () => {
|
|
16
|
+
test.each([
|
|
17
|
+
['https://learncard.app', 'https://learncard.app'],
|
|
18
|
+
['https://vetpass.app', 'https://vetpass.app'],
|
|
19
|
+
['http://localhost:3000', 'http://localhost:3000'],
|
|
20
|
+
['capacitor://localhost', 'capacitor://localhost'],
|
|
21
|
+
])('%s matches itself', (origin, pattern) => {
|
|
22
|
+
expect(matches(origin, pattern)).toBe(true);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
test.each([
|
|
26
|
+
// Different protocol.
|
|
27
|
+
['http://learncard.app', 'https://learncard.app'],
|
|
28
|
+
// Different port.
|
|
29
|
+
['https://learncard.app:8443', 'https://learncard.app'],
|
|
30
|
+
// Different host entirely.
|
|
31
|
+
['https://other.com', 'https://learncard.app'],
|
|
32
|
+
// Trailing path is part of the input — only the origin should match.
|
|
33
|
+
['https://learncard.app/foo', 'https://learncard.app'],
|
|
34
|
+
])('%s does not match %s', (candidate, pattern) => {
|
|
35
|
+
expect(matches(candidate, pattern)).toBe(false);
|
|
36
|
+
});
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
describe('leading-label wildcards', () => {
|
|
40
|
+
const PATTERN = 'https://*.learncard.app';
|
|
41
|
+
|
|
42
|
+
test.each([
|
|
43
|
+
'https://staging.learncard.app',
|
|
44
|
+
'https://alpha.learncard.app',
|
|
45
|
+
'https://pr-123.learncard.app',
|
|
46
|
+
// Multi-label prefix is allowed.
|
|
47
|
+
'https://pr-123.preview.learncard.app',
|
|
48
|
+
'https://a.b.c.learncard.app',
|
|
49
|
+
])('matches %s', candidate => {
|
|
50
|
+
expect(matches(candidate, PATTERN)).toBe(true);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test.each([
|
|
54
|
+
// Apex of the trusted domain — caller must add the bare origin
|
|
55
|
+
// explicitly if they want to trust it.
|
|
56
|
+
'https://learncard.app',
|
|
57
|
+
// Protocol mismatch.
|
|
58
|
+
'http://staging.learncard.app',
|
|
59
|
+
// Port mismatch.
|
|
60
|
+
'https://staging.learncard.app:8443',
|
|
61
|
+
// Suffix confusion: the trusted suffix is somewhere in the host
|
|
62
|
+
// string, but the actual eTLD+1 is `attacker.com`.
|
|
63
|
+
'https://learncard.app.attacker.com',
|
|
64
|
+
'https://staging.learncard.app.attacker.com',
|
|
65
|
+
// Trailing dot / double dot edge cases.
|
|
66
|
+
'https://.learncard.app',
|
|
67
|
+
// Host with leading `.` after a label is rejected by URL parser
|
|
68
|
+
// already, but also covered.
|
|
69
|
+
'https://..learncard.app',
|
|
70
|
+
// Different domain entirely.
|
|
71
|
+
'https://staging.vetpass.app',
|
|
72
|
+
])('rejects %s', candidate => {
|
|
73
|
+
expect(matches(candidate, PATTERN)).toBe(false);
|
|
74
|
+
});
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
describe('disallowed wildcard shapes', () => {
|
|
78
|
+
// Wildcards may only be a leading label. The matcher must reject any
|
|
79
|
+
// exotic placement so the rule stays predictable and auditable.
|
|
80
|
+
test.each([
|
|
81
|
+
// Wildcard in the middle.
|
|
82
|
+
['https://staging.learncard.app', 'https://staging.*.app'],
|
|
83
|
+
// Wildcard as a partial label.
|
|
84
|
+
['https://api-staging.learncard.app', 'https://api-*.learncard.app'],
|
|
85
|
+
// Wildcard in the TLD position.
|
|
86
|
+
['https://learncard.app', 'https://learncard.*'],
|
|
87
|
+
// Wildcard with no following label.
|
|
88
|
+
['https://staging.learncard.app', 'https://*'],
|
|
89
|
+
// Multiple disjoint wildcards.
|
|
90
|
+
['https://a.b.learncard.app', 'https://*.*.learncard.app'],
|
|
91
|
+
])('rejects %s against %s', (candidate, pattern) => {
|
|
92
|
+
expect(matches(candidate, pattern)).toBe(false);
|
|
93
|
+
});
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
describe('port handling', () => {
|
|
97
|
+
test('matches when both pattern and candidate share the same port', () => {
|
|
98
|
+
expect(
|
|
99
|
+
matches('https://staging.learncard.app:8443', 'https://*.learncard.app:8443')
|
|
100
|
+
).toBe(true);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test('rejects when only the candidate specifies a port', () => {
|
|
104
|
+
expect(
|
|
105
|
+
matches('https://staging.learncard.app:8443', 'https://*.learncard.app')
|
|
106
|
+
).toBe(false);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
test('rejects when only the pattern specifies a port', () => {
|
|
110
|
+
expect(
|
|
111
|
+
matches('https://staging.learncard.app', 'https://*.learncard.app:8443')
|
|
112
|
+
).toBe(false);
|
|
113
|
+
});
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
describe('malformed input', () => {
|
|
117
|
+
test.each([
|
|
118
|
+
['', 'https://learncard.app'],
|
|
119
|
+
['not a url', 'https://learncard.app'],
|
|
120
|
+
['https://staging.learncard.app', ''],
|
|
121
|
+
['https://staging.learncard.app', 'not a url'],
|
|
122
|
+
['https://staging.learncard.app', 'https://*'],
|
|
123
|
+
])('rejects (%s, %s)', (candidate, pattern) => {
|
|
124
|
+
expect(matches(candidate, pattern)).toBe(false);
|
|
125
|
+
});
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
describe('cross-protocol wildcards', () => {
|
|
129
|
+
test('http wildcard pattern only matches http origins', () => {
|
|
130
|
+
expect(matches('http://staging.dev.local', 'http://*.dev.local')).toBe(true);
|
|
131
|
+
expect(matches('https://staging.dev.local', 'http://*.dev.local')).toBe(false);
|
|
132
|
+
});
|
|
133
|
+
});
|
|
134
|
+
});
|