@memberjunction/react-runtime 6.2.0-edge.6 → 6.2.0-edge.7
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/.turbo/turbo-build.log +14 -14
- package/CHANGELOG.md +61 -0
- package/dist/compiler/component-compiler.d.ts.map +1 -1
- package/dist/compiler/component-compiler.js +11 -3
- package/dist/compiler/component-compiler.js.map +1 -1
- package/dist/runtime.umd.js +160 -149
- package/dist/utilities/library-loader.d.ts +2 -0
- package/dist/utilities/library-loader.d.ts.map +1 -1
- package/dist/utilities/library-loader.js +51 -9
- package/dist/utilities/library-loader.js.map +1 -1
- package/dist/utilities/runtime-utilities.d.ts.map +1 -1
- package/dist/utilities/runtime-utilities.js +2 -1
- package/dist/utilities/runtime-utilities.js.map +1 -1
- package/package.json +7 -7
- package/src/__tests__/subresource-url-selector-security.test.ts +121 -0
- package/src/compiler/component-compiler.ts +22 -5
- package/src/utilities/library-loader.ts +71 -10
- package/src/utilities/runtime-utilities.ts +2 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@memberjunction/react-runtime",
|
|
3
|
-
"version": "6.2.0-edge.
|
|
3
|
+
"version": "6.2.0-edge.7",
|
|
4
4
|
"description": "Platform-agnostic React component runtime for MemberJunction. Provides core compilation, registry, and execution capabilities for React components in any JavaScript environment.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.js",
|
|
@@ -20,12 +20,12 @@
|
|
|
20
20
|
"homepage": "https://github.com/MemberJunction/MJ#readme",
|
|
21
21
|
"dependencies": {
|
|
22
22
|
"@babel/standalone": "^7.29.1",
|
|
23
|
-
"@memberjunction/ai-vectors-memory": "6.2.0-edge.
|
|
24
|
-
"@memberjunction/core": "6.2.0-edge.
|
|
25
|
-
"@memberjunction/core-entities": "6.2.0-edge.
|
|
26
|
-
"@memberjunction/global": "6.2.0-edge.
|
|
27
|
-
"@memberjunction/graphql-dataprovider": "6.2.0-edge.
|
|
28
|
-
"@memberjunction/interactive-component-types": "6.2.0-edge.
|
|
23
|
+
"@memberjunction/ai-vectors-memory": "6.2.0-edge.7",
|
|
24
|
+
"@memberjunction/core": "6.2.0-edge.7",
|
|
25
|
+
"@memberjunction/core-entities": "6.2.0-edge.7",
|
|
26
|
+
"@memberjunction/global": "6.2.0-edge.7",
|
|
27
|
+
"@memberjunction/graphql-dataprovider": "6.2.0-edge.7",
|
|
28
|
+
"@memberjunction/interactive-component-types": "6.2.0-edge.7",
|
|
29
29
|
"rxjs": "^7.8.2"
|
|
30
30
|
},
|
|
31
31
|
"devDependencies": {
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
2
|
+
|
|
3
|
+
vi.mock('@memberjunction/core-entities', () => ({
|
|
4
|
+
MJComponentLibraryEntity: class {},
|
|
5
|
+
}));
|
|
6
|
+
|
|
7
|
+
import {
|
|
8
|
+
FindDomSubresourceByUrl,
|
|
9
|
+
IsAllowedSubresourceUrl,
|
|
10
|
+
} from '../utilities/library-loader';
|
|
11
|
+
import { ComponentCompiler } from '../compiler/component-compiler';
|
|
12
|
+
|
|
13
|
+
interface FakeElement {
|
|
14
|
+
tagName: string;
|
|
15
|
+
rel?: string;
|
|
16
|
+
href?: string;
|
|
17
|
+
src?: string;
|
|
18
|
+
async?: boolean;
|
|
19
|
+
getAttribute(attr: string): string | null;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function makeFakeElement(tagName: string, attrs: { src?: string; href?: string } = {}): FakeElement {
|
|
23
|
+
return {
|
|
24
|
+
tagName,
|
|
25
|
+
src: attrs.src,
|
|
26
|
+
href: attrs.href,
|
|
27
|
+
getAttribute(attr: string): string | null {
|
|
28
|
+
if (attr === 'src') return this.src ?? null;
|
|
29
|
+
if (attr === 'href') return this.href ?? null;
|
|
30
|
+
return null;
|
|
31
|
+
},
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
describe('React runtime subresource URL screening & selector-injection prevention (PR #6034)', () => {
|
|
36
|
+
const scripts: FakeElement[] = [];
|
|
37
|
+
const links: FakeElement[] = [];
|
|
38
|
+
const appendedToHead: FakeElement[] = [];
|
|
39
|
+
const querySelectorSpy = vi.fn(() => {
|
|
40
|
+
throw new Error('querySelector should never be used for untrusted URL lookups');
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
beforeEach(() => {
|
|
44
|
+
scripts.length = 0;
|
|
45
|
+
links.length = 0;
|
|
46
|
+
appendedToHead.length = 0;
|
|
47
|
+
querySelectorSpy.mockClear();
|
|
48
|
+
|
|
49
|
+
vi.stubGlobal('window', { location: { origin: 'https://explorer.example.com' } });
|
|
50
|
+
vi.stubGlobal('document', {
|
|
51
|
+
querySelector: querySelectorSpy,
|
|
52
|
+
getElementsByTagName: vi.fn((tag: string) => {
|
|
53
|
+
if (tag === 'script') return scripts;
|
|
54
|
+
if (tag === 'link') return links;
|
|
55
|
+
return [];
|
|
56
|
+
}),
|
|
57
|
+
createElement: vi.fn((tag: string) => makeFakeElement(tag)),
|
|
58
|
+
head: {
|
|
59
|
+
appendChild: vi.fn((el: FakeElement) => {
|
|
60
|
+
appendedToHead.push(el);
|
|
61
|
+
}),
|
|
62
|
+
},
|
|
63
|
+
});
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
afterEach(() => {
|
|
67
|
+
vi.unstubAllGlobals();
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it('IsAllowedSubresourceUrl allows http(s) and same-origin paths while blocking dangerous schemes', () => {
|
|
71
|
+
expect(IsAllowedSubresourceUrl('https://cdn.jsdelivr.net/npm/react@18/umd/react.production.min.js')).toBe(true);
|
|
72
|
+
expect(IsAllowedSubresourceUrl('http://localhost:4200/vendor/lib.js')).toBe(true);
|
|
73
|
+
expect(IsAllowedSubresourceUrl('/assets/theme.css')).toBe(true);
|
|
74
|
+
|
|
75
|
+
expect(IsAllowedSubresourceUrl('javascript:alert(1)')).toBe(false);
|
|
76
|
+
expect(IsAllowedSubresourceUrl('data:text/javascript,alert(1)')).toBe(false);
|
|
77
|
+
expect(IsAllowedSubresourceUrl('blob:https://explorer.example.com/id')).toBe(false);
|
|
78
|
+
expect(IsAllowedSubresourceUrl('//evil.example.com/lib.js')).toBe(false);
|
|
79
|
+
expect(IsAllowedSubresourceUrl('')).toBe(false);
|
|
80
|
+
expect(IsAllowedSubresourceUrl(null)).toBe(false);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it('FindDomSubresourceByUrl safely matches URLs containing quotes and CSS selector metacharacters without calling querySelector', () => {
|
|
84
|
+
const trickyUrl = 'https://cdn.example.com/style.css?v=1"] , body[data-x="1';
|
|
85
|
+
const existing = makeFakeElement('link', { href: trickyUrl });
|
|
86
|
+
links.push(existing);
|
|
87
|
+
|
|
88
|
+
const found = FindDomSubresourceByUrl('link', 'href', trickyUrl);
|
|
89
|
+
expect(found).toBe(existing);
|
|
90
|
+
expect(FindDomSubresourceByUrl('link', 'href', 'https://cdn.example.com/other.css')).toBeNull();
|
|
91
|
+
expect(querySelectorSpy).not.toHaveBeenCalled();
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it('ComponentCompiler.loadStyles and loadScript block non-http(s) URLs and avoid querySelector on quoted URLs', async () => {
|
|
95
|
+
const compiler = new ComponentCompiler();
|
|
96
|
+
const loadStyles = Reflect.get(compiler, 'loadStyles') as (urls: string[]) => Promise<void>;
|
|
97
|
+
const loadScript = Reflect.get(compiler, 'loadScript') as (url: string, globalName: string) => Promise<void>;
|
|
98
|
+
|
|
99
|
+
const quotedCssUrl = 'https://cdn.example.com/app.css?tok="]';
|
|
100
|
+
await loadStyles.call(compiler, [
|
|
101
|
+
'javascript:alert(1)',
|
|
102
|
+
'data:text/css,body{display:none}',
|
|
103
|
+
'//evil.example.com/inject.css',
|
|
104
|
+
quotedCssUrl,
|
|
105
|
+
]);
|
|
106
|
+
|
|
107
|
+
// Only the valid https stylesheet was appended; querySelector was never invoked
|
|
108
|
+
expect(appendedToHead).toHaveLength(1);
|
|
109
|
+
expect(appendedToHead[0].href).toBe(quotedCssUrl);
|
|
110
|
+
expect(querySelectorSpy).not.toHaveBeenCalled();
|
|
111
|
+
|
|
112
|
+
// Dangerous script schemes reject immediately without creating a <script> tag
|
|
113
|
+
await expect(loadScript.call(compiler, 'javascript:alert(1)', 'EvilGlobal')).rejects.toThrow(
|
|
114
|
+
/Refused to load script from non-http\(s\) URL/,
|
|
115
|
+
);
|
|
116
|
+
await expect(loadScript.call(compiler, '//evil.example.com/x.js', 'EvilGlobal')).rejects.toThrow(
|
|
117
|
+
/Refused to load script from non-http\(s\) URL/,
|
|
118
|
+
);
|
|
119
|
+
expect(appendedToHead).toHaveLength(1);
|
|
120
|
+
});
|
|
121
|
+
});
|
|
@@ -15,7 +15,11 @@ import {
|
|
|
15
15
|
} from '../types';
|
|
16
16
|
import { ComponentStyles, ComponentObject } from '@memberjunction/interactive-component-types';
|
|
17
17
|
import { LibraryRegistry } from '../utilities/library-registry';
|
|
18
|
-
import {
|
|
18
|
+
import {
|
|
19
|
+
FindDomSubresourceByUrl,
|
|
20
|
+
IsAllowedSubresourceUrl,
|
|
21
|
+
LibraryLoader,
|
|
22
|
+
} from '../utilities/library-loader';
|
|
19
23
|
import { UnwrapLibraryComponent, UnwrapLibraryComponents, UnwrapAllLibraryComponents } from '../utilities/component-unwrapper';
|
|
20
24
|
import { MJComponentLibraryEntity } from '@memberjunction/core-entities';
|
|
21
25
|
|
|
@@ -643,8 +647,14 @@ export class ComponentCompiler {
|
|
|
643
647
|
private async loadStyles(urls: string[]): Promise<void> {
|
|
644
648
|
const loadPromises = urls.map(url => {
|
|
645
649
|
return new Promise<void>((resolve) => {
|
|
646
|
-
|
|
647
|
-
|
|
650
|
+
if (!IsAllowedSubresourceUrl(url)) {
|
|
651
|
+
resolve();
|
|
652
|
+
return;
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
// Check if stylesheet already exists via direct href attribute comparison instead of
|
|
656
|
+
// interpolating the URL into a CSS selector.
|
|
657
|
+
const existingLink = FindDomSubresourceByUrl('link', 'href', url);
|
|
648
658
|
if (existingLink) {
|
|
649
659
|
resolve();
|
|
650
660
|
return;
|
|
@@ -673,8 +683,15 @@ export class ComponentCompiler {
|
|
|
673
683
|
*/
|
|
674
684
|
private loadScript(url: string, globalName: string): Promise<void> {
|
|
675
685
|
return new Promise((resolve, reject) => {
|
|
676
|
-
|
|
677
|
-
|
|
686
|
+
if (!IsAllowedSubresourceUrl(url)) {
|
|
687
|
+
reject(new Error(`Refused to load script from non-http(s) URL: ${url}`));
|
|
688
|
+
return;
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
// Check if script already exists. Compare src attributes directly instead of interpolating
|
|
692
|
+
// the URL into a CSS selector, where quotes/brackets in a URL would break (or redirect) the
|
|
693
|
+
// selector match.
|
|
694
|
+
const existingScript = FindDomSubresourceByUrl('script', 'src', url);
|
|
678
695
|
if (existingScript) {
|
|
679
696
|
// Wait for it to finish loading with exponential backoff
|
|
680
697
|
let attempts = 0;
|
|
@@ -19,6 +19,54 @@ import { LibraryRegistry } from './library-registry';
|
|
|
19
19
|
// Unique component ID for resource tracking
|
|
20
20
|
const LIBRARY_LOADER_COMPONENT_ID = 'mj-react-runtime-library-loader-singleton';
|
|
21
21
|
|
|
22
|
+
/**
|
|
23
|
+
* Validates that a script or stylesheet subresource URL resolves to `http:` or `https:` and is not
|
|
24
|
+
* a scheme-relative (`//...`) reference. Prevents dangerous schemes (`javascript:`, `data:`,
|
|
25
|
+
* `blob:`, `vbscript:`) from reaching `<script src>` or `<link href>` DOM sinks.
|
|
26
|
+
*/
|
|
27
|
+
export function IsAllowedSubresourceUrl(url: string | null | undefined): boolean {
|
|
28
|
+
if (typeof url !== 'string') {
|
|
29
|
+
return false;
|
|
30
|
+
}
|
|
31
|
+
const trimmed = url.trim();
|
|
32
|
+
if (!trimmed || trimmed.startsWith('//')) {
|
|
33
|
+
return false;
|
|
34
|
+
}
|
|
35
|
+
try {
|
|
36
|
+
const baseOrigin =
|
|
37
|
+
typeof window !== 'undefined' && window.location?.origin
|
|
38
|
+
? window.location.origin
|
|
39
|
+
: 'https://localhost';
|
|
40
|
+
const parsed = new URL(trimmed, baseOrigin);
|
|
41
|
+
return parsed.protocol === 'http:' || parsed.protocol === 'https:';
|
|
42
|
+
} catch {
|
|
43
|
+
return false;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Finds an existing `<script>` or `<link>` element by exact attribute comparison rather than
|
|
49
|
+
* interpolating an untrusted URL into `document.querySelector('tag[attr="..."]')`, where quotes or
|
|
50
|
+
* brackets in a URL can break or redirect CSS selector matching.
|
|
51
|
+
*/
|
|
52
|
+
export function FindDomSubresourceByUrl(
|
|
53
|
+
tagName: 'script' | 'link',
|
|
54
|
+
attrName: 'src' | 'href',
|
|
55
|
+
url: string,
|
|
56
|
+
): HTMLScriptElement | HTMLLinkElement | null {
|
|
57
|
+
if (typeof document === 'undefined' || typeof document.getElementsByTagName !== 'function') {
|
|
58
|
+
return null;
|
|
59
|
+
}
|
|
60
|
+
const collection = document.getElementsByTagName(tagName);
|
|
61
|
+
for (let i = 0; i < collection.length; i++) {
|
|
62
|
+
const el = collection[i];
|
|
63
|
+
if (typeof el.getAttribute === 'function' && el.getAttribute(attrName) === url) {
|
|
64
|
+
return el;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
return null;
|
|
68
|
+
}
|
|
69
|
+
|
|
22
70
|
/**
|
|
23
71
|
* Represents a loaded script or CSS resource
|
|
24
72
|
*/
|
|
@@ -83,8 +131,11 @@ export class LibraryLoader {
|
|
|
83
131
|
const coreLibraries = GetCoreRuntimeLibraries(debug);
|
|
84
132
|
for (const lib of coreLibraries) {
|
|
85
133
|
// Skip if already preloaded or loaded
|
|
86
|
-
if (
|
|
87
|
-
|
|
134
|
+
if (
|
|
135
|
+
!IsAllowedSubresourceUrl(lib.cdnUrl) ||
|
|
136
|
+
FindDomSubresourceByUrl('link', 'href', lib.cdnUrl) ||
|
|
137
|
+
FindDomSubresourceByUrl('script', 'src', lib.cdnUrl)
|
|
138
|
+
) {
|
|
88
139
|
continue;
|
|
89
140
|
}
|
|
90
141
|
const link = document.createElement('link');
|
|
@@ -381,7 +432,13 @@ export class LibraryLoader {
|
|
|
381
432
|
return existing.promise;
|
|
382
433
|
}
|
|
383
434
|
|
|
435
|
+
let createdScript: HTMLScriptElement | null = null;
|
|
384
436
|
const promise = new Promise((resolve, reject) => {
|
|
437
|
+
if (!IsAllowedSubresourceUrl(url)) {
|
|
438
|
+
reject(new Error(`Refused to load script from non-http(s) URL: ${url}`));
|
|
439
|
+
return;
|
|
440
|
+
}
|
|
441
|
+
|
|
385
442
|
// Check if global already exists
|
|
386
443
|
const existingGlobal = (window as any)[globalName];
|
|
387
444
|
if (existingGlobal) {
|
|
@@ -389,8 +446,8 @@ export class LibraryLoader {
|
|
|
389
446
|
return;
|
|
390
447
|
}
|
|
391
448
|
|
|
392
|
-
// Check if script tag exists
|
|
393
|
-
const existingScript =
|
|
449
|
+
// Check if script tag exists via direct attribute comparison (no CSS selector interpolation)
|
|
450
|
+
const existingScript = FindDomSubresourceByUrl('script', 'src', url);
|
|
394
451
|
if (existingScript) {
|
|
395
452
|
this.waitForScriptLoad(existingScript as HTMLScriptElement, globalName, resolve, reject);
|
|
396
453
|
return;
|
|
@@ -400,6 +457,7 @@ export class LibraryLoader {
|
|
|
400
457
|
// "no-cors" mode, which avoids CORS failures when CDN headers are missing
|
|
401
458
|
// or browser tracking prevention interferes with the response.
|
|
402
459
|
const script = document.createElement('script');
|
|
460
|
+
createdScript = script;
|
|
403
461
|
script.src = url;
|
|
404
462
|
script.async = true;
|
|
405
463
|
|
|
@@ -470,10 +528,13 @@ export class LibraryLoader {
|
|
|
470
528
|
resourceManager.registerDOMElement(LIBRARY_LOADER_COMPONENT_ID, script);
|
|
471
529
|
});
|
|
472
530
|
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
531
|
+
const trackedElement = createdScript ?? FindDomSubresourceByUrl('script', 'src', url);
|
|
532
|
+
if (trackedElement) {
|
|
533
|
+
this.loadedResources.set(url, {
|
|
534
|
+
element: trackedElement,
|
|
535
|
+
promise
|
|
536
|
+
});
|
|
537
|
+
}
|
|
477
538
|
|
|
478
539
|
return promise;
|
|
479
540
|
}
|
|
@@ -576,11 +637,11 @@ export class LibraryLoader {
|
|
|
576
637
|
* Load CSS from URL
|
|
577
638
|
*/
|
|
578
639
|
private static loadCSS(url: string): void {
|
|
579
|
-
if (this.loadedResources.has(url)) {
|
|
640
|
+
if (!IsAllowedSubresourceUrl(url) || this.loadedResources.has(url)) {
|
|
580
641
|
return;
|
|
581
642
|
}
|
|
582
643
|
|
|
583
|
-
const existingLink =
|
|
644
|
+
const existingLink = FindDomSubresourceByUrl('link', 'href', url);
|
|
584
645
|
if (existingLink) {
|
|
585
646
|
return;
|
|
586
647
|
}
|
|
@@ -169,7 +169,8 @@ export class RuntimeUtilities {
|
|
|
169
169
|
// Use the AI client from GraphQLDataProvider to generate embeddings
|
|
170
170
|
const result = await graphQLProvider.AI.EmbedText({
|
|
171
171
|
textToEmbed: params.textToEmbed,
|
|
172
|
-
modelSize: params.modelSize
|
|
172
|
+
modelSize: params.modelSize,
|
|
173
|
+
inputType: params.inputType
|
|
173
174
|
});
|
|
174
175
|
|
|
175
176
|
if (result.error) {
|