@memberjunction/react-runtime 6.2.0-edge.6 → 6.2.0-edge.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@memberjunction/react-runtime",
3
- "version": "6.2.0-edge.6",
3
+ "version": "6.2.0-edge.8",
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.6",
24
- "@memberjunction/core": "6.2.0-edge.6",
25
- "@memberjunction/core-entities": "6.2.0-edge.6",
26
- "@memberjunction/global": "6.2.0-edge.6",
27
- "@memberjunction/graphql-dataprovider": "6.2.0-edge.6",
28
- "@memberjunction/interactive-component-types": "6.2.0-edge.6",
23
+ "@memberjunction/ai-vectors-memory": "6.2.0-edge.8",
24
+ "@memberjunction/core": "6.2.0-edge.8",
25
+ "@memberjunction/core-entities": "6.2.0-edge.8",
26
+ "@memberjunction/global": "6.2.0-edge.8",
27
+ "@memberjunction/graphql-dataprovider": "6.2.0-edge.8",
28
+ "@memberjunction/interactive-component-types": "6.2.0-edge.8",
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 { LibraryLoader } from '../utilities/library-loader';
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
- // Check if stylesheet already exists
647
- const existingLink = document.querySelector(`link[href="${url}"]`);
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
- // Check if script already exists
677
- const existingScript = document.querySelector(`script[src="${url}"]`);
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 (document.querySelector(`link[href="${lib.cdnUrl}"]`) ||
87
- document.querySelector(`script[src="${lib.cdnUrl}"]`)) {
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 = document.querySelector(`script[src="${url}"]`);
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
- this.loadedResources.set(url, {
474
- element: document.querySelector(`script[src="${url}"]`)!,
475
- promise
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 = document.querySelector(`link[href="${url}"]`);
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) {