@erickxavier/no-js 1.10.0 → 1.11.0
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 +68 -6
- package/dist/cjs/no.js +7 -7
- package/dist/cjs/no.js.map +4 -4
- package/dist/esm/no.js +7 -7
- package/dist/esm/no.js.map +4 -4
- package/dist/iife/no.js +7 -7
- package/dist/iife/no.js.map +4 -4
- package/package.json +1 -1
- package/src/animations.js +11 -7
- package/src/context.js +6 -1
- package/src/devtools.js +36 -6
- package/src/directives/binding.js +43 -8
- package/src/directives/dnd.js +11 -2
- package/src/directives/events.js +1 -0
- package/src/directives/head.js +142 -0
- package/src/directives/http.js +12 -24
- package/src/directives/i18n.js +4 -3
- package/src/directives/loops.js +4 -2
- package/src/directives/refs.js +9 -0
- package/src/directives/state.js +26 -6
- package/src/directives/styling.js +1 -1
- package/src/directives/validation.js +34 -13
- package/src/dom.js +68 -2
- package/src/evaluate.js +149 -4
- package/src/fetch.js +153 -9
- package/src/filters.js +6 -1
- package/src/globals.js +28 -1
- package/src/index.js +284 -31
- package/src/registry.js +12 -1
- package/src/router.js +39 -8
|
@@ -417,26 +417,33 @@ registerDirective("validate", {
|
|
|
417
417
|
|
|
418
418
|
if (triggers.includes("input")) {
|
|
419
419
|
field.addEventListener("input", handler);
|
|
420
|
+
_onDispose(() => field.removeEventListener("input", handler));
|
|
420
421
|
} else {
|
|
421
422
|
// Always track dirty and re-validate on input for data accuracy
|
|
422
423
|
// (validate-on only affects visual feedback like error-class/templates)
|
|
423
|
-
|
|
424
|
+
const silentInputHandler = () => {
|
|
424
425
|
dirtyFields.add(field.name);
|
|
425
426
|
formCtx.dirty = true;
|
|
426
427
|
checkValidity();
|
|
427
|
-
}
|
|
428
|
+
};
|
|
429
|
+
field.addEventListener("input", silentInputHandler);
|
|
430
|
+
_onDispose(() => field.removeEventListener("input", silentInputHandler));
|
|
428
431
|
}
|
|
429
432
|
if (triggers.includes("blur") || triggers.includes("focusout")) {
|
|
430
|
-
|
|
433
|
+
const blurFocusoutHandler = (e) => {
|
|
431
434
|
touchHandler();
|
|
432
435
|
if (triggers.includes("blur")) handler();
|
|
433
|
-
}
|
|
436
|
+
};
|
|
437
|
+
field.addEventListener("focusout", blurFocusoutHandler);
|
|
438
|
+
_onDispose(() => field.removeEventListener("focusout", blurFocusoutHandler));
|
|
434
439
|
} else {
|
|
435
440
|
// Always track touched on focusout
|
|
436
441
|
field.addEventListener("focusout", touchHandler);
|
|
442
|
+
_onDispose(() => field.removeEventListener("focusout", touchHandler));
|
|
437
443
|
}
|
|
438
444
|
if (triggers.includes("submit")) {
|
|
439
445
|
field.addEventListener("focusout", touchHandler);
|
|
446
|
+
_onDispose(() => field.removeEventListener("focusout", touchHandler));
|
|
440
447
|
}
|
|
441
448
|
}
|
|
442
449
|
|
|
@@ -455,14 +462,18 @@ registerDirective("validate", {
|
|
|
455
462
|
checkValidity();
|
|
456
463
|
};
|
|
457
464
|
el.addEventListener("input", inputHandler);
|
|
465
|
+
_onDispose(() => el.removeEventListener("input", inputHandler));
|
|
458
466
|
el.addEventListener("change", inputHandler);
|
|
459
|
-
el.
|
|
467
|
+
_onDispose(() => el.removeEventListener("change", inputHandler));
|
|
468
|
+
const focusoutHandler = (e) => {
|
|
460
469
|
if (e.target && e.target.name) {
|
|
461
470
|
touchedFields.add(e.target.name);
|
|
462
471
|
}
|
|
463
472
|
formCtx.touched = true;
|
|
464
473
|
checkValidity();
|
|
465
|
-
}
|
|
474
|
+
};
|
|
475
|
+
el.addEventListener("focusout", focusoutHandler);
|
|
476
|
+
_onDispose(() => el.removeEventListener("focusout", focusoutHandler));
|
|
466
477
|
} else {
|
|
467
478
|
// Per-field event binding with validate-on
|
|
468
479
|
for (const field of getFields()) {
|
|
@@ -470,7 +481,7 @@ registerDirective("validate", {
|
|
|
470
481
|
}
|
|
471
482
|
}
|
|
472
483
|
|
|
473
|
-
|
|
484
|
+
const submitHandler = (e) => {
|
|
474
485
|
// If validate-on="submit", run validation now
|
|
475
486
|
formCtx.submitting = true;
|
|
476
487
|
// Mark all fields as touched on submit
|
|
@@ -484,7 +495,9 @@ registerDirective("validate", {
|
|
|
484
495
|
formCtx.submitting = false;
|
|
485
496
|
ctx.$set("$form", { ...formCtx });
|
|
486
497
|
});
|
|
487
|
-
}
|
|
498
|
+
};
|
|
499
|
+
el.addEventListener("submit", submitHandler);
|
|
500
|
+
_onDispose(() => el.removeEventListener("submit", submitHandler));
|
|
488
501
|
|
|
489
502
|
// Initial check
|
|
490
503
|
requestAnimationFrame(checkValidity);
|
|
@@ -501,7 +514,7 @@ registerDirective("validate", {
|
|
|
501
514
|
el.tagName === "SELECT")
|
|
502
515
|
) {
|
|
503
516
|
const errorTpl = el.getAttribute("error");
|
|
504
|
-
|
|
517
|
+
const fieldInputHandler = () => {
|
|
505
518
|
const err = _validateField(el.value, rules, {});
|
|
506
519
|
if (err && errorTpl) {
|
|
507
520
|
let errorEl = el.nextElementSibling?.__validationError
|
|
@@ -516,6 +529,7 @@ registerDirective("validate", {
|
|
|
516
529
|
const clone = _cloneTemplate(errorTpl);
|
|
517
530
|
if (clone) {
|
|
518
531
|
const childCtx = createContext({ err: { message: err } }, ctx);
|
|
532
|
+
_disposeChildren(errorEl);
|
|
519
533
|
errorEl.innerHTML = "";
|
|
520
534
|
errorEl.__ctx = childCtx;
|
|
521
535
|
errorEl.appendChild(clone);
|
|
@@ -525,9 +539,14 @@ registerDirective("validate", {
|
|
|
525
539
|
const errorEl = el.nextElementSibling?.__validationError
|
|
526
540
|
? el.nextElementSibling
|
|
527
541
|
: null;
|
|
528
|
-
if (errorEl)
|
|
542
|
+
if (errorEl) {
|
|
543
|
+
_disposeChildren(errorEl);
|
|
544
|
+
errorEl.innerHTML = "";
|
|
545
|
+
}
|
|
529
546
|
}
|
|
530
|
-
}
|
|
547
|
+
};
|
|
548
|
+
el.addEventListener("input", fieldInputHandler);
|
|
549
|
+
_onDispose(() => el.removeEventListener("input", fieldInputHandler));
|
|
531
550
|
}
|
|
532
551
|
},
|
|
533
552
|
});
|
|
@@ -556,9 +575,11 @@ registerDirective("error-boundary", {
|
|
|
556
575
|
}
|
|
557
576
|
|
|
558
577
|
// Listen for NoJS expression errors dispatched from event handlers
|
|
559
|
-
|
|
578
|
+
const nojsErrorHandler = (e) => {
|
|
560
579
|
showFallback(e.detail?.message || "An error occurred");
|
|
561
|
-
}
|
|
580
|
+
};
|
|
581
|
+
el.addEventListener("nojs:error", nojsErrorHandler);
|
|
582
|
+
_onDispose(() => el.removeEventListener("nojs:error", nojsErrorHandler));
|
|
562
583
|
|
|
563
584
|
// Listen for window-level errors (resource load failures, etc.)
|
|
564
585
|
const errorHandler = (e) => {
|
package/src/dom.js
CHANGED
|
@@ -33,6 +33,51 @@ export function _cloneTemplate(id) {
|
|
|
33
33
|
return tpl.content ? tpl.content.cloneNode(true) : null;
|
|
34
34
|
}
|
|
35
35
|
|
|
36
|
+
// ─── SVG data URI deep-sanitization ──────────────────────────────────────────
|
|
37
|
+
// Strip JS vectors from raw SVG markup using DOMParser for robust sanitization.
|
|
38
|
+
// Regex-based approaches are bypassable via entity encoding and nested contexts.
|
|
39
|
+
function _sanitizeSvgContent(svg) {
|
|
40
|
+
const doc = new DOMParser().parseFromString(svg, "image/svg+xml");
|
|
41
|
+
const root = doc.documentElement;
|
|
42
|
+
if (root.querySelector("parsererror") ||
|
|
43
|
+
root.nodeName !== "svg" ||
|
|
44
|
+
root.getElementsByTagNameNS("http://www.mozilla.org/newlayout/xml/parsererror.xml", "parsererror").length) {
|
|
45
|
+
return "<svg></svg>";
|
|
46
|
+
}
|
|
47
|
+
function cleanAttrs(node) {
|
|
48
|
+
for (const attr of [...node.attributes]) {
|
|
49
|
+
const name = attr.name.toLowerCase();
|
|
50
|
+
if (name.startsWith("on")) { node.removeAttribute(attr.name); continue; }
|
|
51
|
+
if ((name === "href" || name === "xlink:href") &&
|
|
52
|
+
attr.value.trim().toLowerCase().startsWith("javascript:")) {
|
|
53
|
+
node.removeAttribute(attr.name);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
for (const s of [...root.querySelectorAll("script")]) s.remove();
|
|
58
|
+
cleanAttrs(root);
|
|
59
|
+
for (const node of root.querySelectorAll("*")) cleanAttrs(node);
|
|
60
|
+
return new XMLSerializer().serializeToString(root);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Sanitize a data:image/svg+xml URI — handles both base64 and URL-encoded forms.
|
|
64
|
+
function _sanitizeSvgDataUri(str) {
|
|
65
|
+
try {
|
|
66
|
+
const b64 = str.match(/^data:image\/svg\+xml;base64,(.+)$/i);
|
|
67
|
+
if (b64) {
|
|
68
|
+
const clean = _sanitizeSvgContent(atob(b64[1]));
|
|
69
|
+
return "data:image/svg+xml;base64," + btoa(clean);
|
|
70
|
+
}
|
|
71
|
+
const comma = str.indexOf(",");
|
|
72
|
+
if (comma === -1) return "#";
|
|
73
|
+
const header = str.slice(0, comma + 1);
|
|
74
|
+
const clean = _sanitizeSvgContent(decodeURIComponent(str.slice(comma + 1)));
|
|
75
|
+
return header + encodeURIComponent(clean);
|
|
76
|
+
} catch (_e) {
|
|
77
|
+
return "#";
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
36
81
|
// Structural HTML sanitizer — uses DOMParser to parse the markup before cleaning.
|
|
37
82
|
// Regex-based sanitizers are bypassable via SVG/MathML event handlers, nested
|
|
38
83
|
// srcdoc attributes, and HTML entity encoding (e.g. javascript:).
|
|
@@ -47,7 +92,10 @@ const _BLOCKED_TAGS = new Set([
|
|
|
47
92
|
]);
|
|
48
93
|
|
|
49
94
|
export function _sanitizeHtml(html) {
|
|
50
|
-
if (!_config.sanitize)
|
|
95
|
+
if (_config.dangerouslyDisableSanitize || !_config.sanitize) {
|
|
96
|
+
_warn('HTML sanitization is DISABLED. This exposes your app to XSS attacks. Only disable for trusted content.');
|
|
97
|
+
return html;
|
|
98
|
+
}
|
|
51
99
|
if (typeof _config.sanitizeHtml === 'function') return _config.sanitizeHtml(html);
|
|
52
100
|
|
|
53
101
|
const doc = new DOMParser().parseFromString(html, 'text/html');
|
|
@@ -62,11 +110,15 @@ export function _sanitizeHtml(html) {
|
|
|
62
110
|
for (const attr of [...child.attributes]) {
|
|
63
111
|
const n = attr.name.toLowerCase();
|
|
64
112
|
const v = attr.value.toLowerCase().trimStart();
|
|
65
|
-
const isUrlAttr = n === 'href' || n === 'src' || n === 'action' || n === 'xlink:href'
|
|
113
|
+
const isUrlAttr = n === 'href' || n === 'src' || n === 'action' || n === 'xlink:href'
|
|
114
|
+
|| n === 'formaction' || n === 'poster' || n === 'data';
|
|
66
115
|
const isDangerousScheme = v.startsWith('javascript:') || v.startsWith('vbscript:');
|
|
67
116
|
const isDangerousData = isUrlAttr && v.startsWith('data:') && !/^data:image\//.test(v);
|
|
68
117
|
if (n.startsWith('on') || isDangerousScheme || isDangerousData) {
|
|
69
118
|
child.removeAttribute(attr.name);
|
|
119
|
+
} else if (isUrlAttr && v.startsWith('data:image/svg+xml')) {
|
|
120
|
+
// Deep-sanitize SVG data URIs to strip embedded <script> and on* handlers
|
|
121
|
+
child.setAttribute(attr.name, _sanitizeSvgDataUri(attr.value));
|
|
70
122
|
}
|
|
71
123
|
}
|
|
72
124
|
_clean(child);
|
|
@@ -98,6 +150,18 @@ function _resolveTemplateSrc(src, tpl) {
|
|
|
98
150
|
return resolveUrl(src, tpl);
|
|
99
151
|
}
|
|
100
152
|
|
|
153
|
+
// Warn when a template URL uses plain HTTP from an HTTPS page (mixed content / MITM risk).
|
|
154
|
+
// The optional pageProtocol parameter enables testing without mutating jsdom's
|
|
155
|
+
// non-configurable window.location.protocol property.
|
|
156
|
+
export function _warnIfInsecureTemplateUrl(resolvedUrl, src, pageProtocol) {
|
|
157
|
+
const proto = pageProtocol !== undefined
|
|
158
|
+
? pageProtocol
|
|
159
|
+
: (typeof window !== 'undefined' && window.location ? window.location.protocol : '');
|
|
160
|
+
if (resolvedUrl.startsWith('http://') && proto === 'https:') {
|
|
161
|
+
_warn('Template "' + src + '" is loaded over insecure HTTP from an HTTPS page. Use HTTPS to prevent tampering.');
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
101
165
|
export async function _loadRemoteTemplates(root) {
|
|
102
166
|
const scope = root || document;
|
|
103
167
|
const templates = scope.querySelectorAll("template[src]");
|
|
@@ -108,6 +172,7 @@ export async function _loadRemoteTemplates(root) {
|
|
|
108
172
|
tpl.__srcLoaded = true;
|
|
109
173
|
const src = tpl.getAttribute("src");
|
|
110
174
|
const resolvedUrl = _resolveTemplateSrc(src, tpl);
|
|
175
|
+
_warnIfInsecureTemplateUrl(resolvedUrl, src);
|
|
111
176
|
// Track the folder of this template so children can use "./" paths
|
|
112
177
|
const baseFolder = resolvedUrl.substring(0, resolvedUrl.lastIndexOf("/") + 1);
|
|
113
178
|
try {
|
|
@@ -163,6 +228,7 @@ export async function _loadTemplateElement(tpl) {
|
|
|
163
228
|
_log("[LTE] START fetch:", src, "| route:", tpl.hasAttribute("route"), "| inDOM:", document.contains(tpl), "| loading:", tpl.getAttribute("loading"));
|
|
164
229
|
tpl.__srcLoaded = true;
|
|
165
230
|
const resolvedUrl = _resolveTemplateSrc(src, tpl);
|
|
231
|
+
_warnIfInsecureTemplateUrl(resolvedUrl, src);
|
|
166
232
|
const baseFolder = resolvedUrl.substring(0, resolvedUrl.lastIndexOf("/") + 1);
|
|
167
233
|
|
|
168
234
|
// Synchronously insert loading placeholder before the fetch begins
|
package/src/evaluate.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// EXPRESSION EVALUATOR
|
|
3
3
|
// ═══════════════════════════════════════════════════════════════════════
|
|
4
4
|
|
|
5
|
-
import { _config, _stores, _routerInstance, _filters, _warn, _notifyStoreWatchers } from "./globals.js";
|
|
5
|
+
import { _config, _stores, _routerInstance, _filters, _warn, _notifyStoreWatchers, _globals } from "./globals.js";
|
|
6
6
|
import { _i18n } from "./i18n.js";
|
|
7
7
|
import { _collectKeys } from "./context.js";
|
|
8
8
|
|
|
@@ -736,7 +736,130 @@ const _SAFE_GLOBALS = {
|
|
|
736
736
|
// network and storage APIs — fetch, XMLHttpRequest, localStorage, sessionStorage,
|
|
737
737
|
// WebSocket, indexedDB — are unreachable from template code by default, closing
|
|
738
738
|
// the surface where interpolated external data could trigger unintended requests.
|
|
739
|
-
// window
|
|
739
|
+
// window, document, and location are further wrapped in Proxy objects below
|
|
740
|
+
// to block sensitive sub-properties (fetch, cookie, navigation, etc.).
|
|
741
|
+
|
|
742
|
+
// ── Security proxies for window, document, and location ─────────────────
|
|
743
|
+
// Even though window/document are on the allow-list, we wrap them in Proxy
|
|
744
|
+
// objects that block access to sensitive sub-properties (network, storage,
|
|
745
|
+
// cookie, eval, etc.) while still allowing safe DOM / measurement APIs.
|
|
746
|
+
|
|
747
|
+
const _BLOCKED_WINDOW_PROPS = new Set([
|
|
748
|
+
'fetch', 'XMLHttpRequest', 'localStorage', 'sessionStorage',
|
|
749
|
+
'WebSocket', 'indexedDB', 'eval', 'Function', 'importScripts',
|
|
750
|
+
'open', 'postMessage',
|
|
751
|
+
]);
|
|
752
|
+
// Props on window that must return safe proxies instead of raw objects
|
|
753
|
+
const _WINDOW_PROXY_OVERRIDES = {}; // populated after proxy creation below
|
|
754
|
+
|
|
755
|
+
const _BLOCKED_DOCUMENT_PROPS = new Set([
|
|
756
|
+
'cookie', 'domain', 'write', 'writeln', 'execCommand',
|
|
757
|
+
]);
|
|
758
|
+
|
|
759
|
+
const _safeWindow = typeof globalThis !== 'undefined' && typeof globalThis.window !== 'undefined'
|
|
760
|
+
? new Proxy(globalThis.window, {
|
|
761
|
+
get(target, prop, receiver) {
|
|
762
|
+
if (typeof prop === 'string' && _BLOCKED_WINDOW_PROPS.has(prop)) return undefined;
|
|
763
|
+
if (typeof prop === 'string' && prop in _WINDOW_PROXY_OVERRIDES) return _WINDOW_PROXY_OVERRIDES[prop];
|
|
764
|
+
return Reflect.get(target, prop, receiver);
|
|
765
|
+
},
|
|
766
|
+
set(target, prop, value) {
|
|
767
|
+
// Block writes to dangerous window properties from expressions;
|
|
768
|
+
// allow writing user-defined properties (e.g. window.__myHelper)
|
|
769
|
+
if (typeof prop === 'string' && _BLOCKED_WINDOW_PROPS.has(prop)) return true;
|
|
770
|
+
if (prop === 'name' || prop === 'status') return true; // anti-exfiltration
|
|
771
|
+
target[prop] = value;
|
|
772
|
+
return true;
|
|
773
|
+
},
|
|
774
|
+
})
|
|
775
|
+
: undefined;
|
|
776
|
+
|
|
777
|
+
const _safeDocument = typeof globalThis !== 'undefined' && typeof globalThis.document !== 'undefined'
|
|
778
|
+
? new Proxy(globalThis.document, {
|
|
779
|
+
get(target, prop, receiver) {
|
|
780
|
+
if (typeof prop === 'string' && _BLOCKED_DOCUMENT_PROPS.has(prop)) return undefined;
|
|
781
|
+
if (prop === 'defaultView') return _safeWindow;
|
|
782
|
+
return Reflect.get(target, prop, receiver);
|
|
783
|
+
},
|
|
784
|
+
set(target, prop, value) {
|
|
785
|
+
if (typeof prop === 'string' && _BLOCKED_DOCUMENT_PROPS.has(prop)) return true;
|
|
786
|
+
target[prop] = value;
|
|
787
|
+
return true;
|
|
788
|
+
},
|
|
789
|
+
})
|
|
790
|
+
: undefined;
|
|
791
|
+
|
|
792
|
+
// Read-only location wrapper — exposes common getters via a plain object with
|
|
793
|
+
// property descriptors that read from the real location. Navigation methods are
|
|
794
|
+
// replaced with no-ops. Using a plain object avoids Proxy invariant violations
|
|
795
|
+
// on non-configurable properties (like location.assign).
|
|
796
|
+
const _LOCATION_READ_PROPS = [
|
|
797
|
+
'href', 'pathname', 'search', 'hash', 'origin',
|
|
798
|
+
'hostname', 'port', 'protocol', 'host',
|
|
799
|
+
];
|
|
800
|
+
const _locationNoop = () => {};
|
|
801
|
+
|
|
802
|
+
const _safeLocation = typeof globalThis !== 'undefined' && typeof globalThis.location !== 'undefined'
|
|
803
|
+
? (() => {
|
|
804
|
+
const loc = {};
|
|
805
|
+
for (const prop of _LOCATION_READ_PROPS) {
|
|
806
|
+
Object.defineProperty(loc, prop, {
|
|
807
|
+
get() { return globalThis.location[prop]; },
|
|
808
|
+
set() { /* silently ignore writes */ },
|
|
809
|
+
enumerable: true, configurable: false,
|
|
810
|
+
});
|
|
811
|
+
}
|
|
812
|
+
loc.assign = _locationNoop;
|
|
813
|
+
loc.replace = _locationNoop;
|
|
814
|
+
loc.reload = _locationNoop;
|
|
815
|
+
loc.toString = () => globalThis.location.href;
|
|
816
|
+
return Object.freeze(loc);
|
|
817
|
+
})()
|
|
818
|
+
: undefined;
|
|
819
|
+
|
|
820
|
+
// Read-only history wrapper — exposes state and length as read-only getters,
|
|
821
|
+
// replaces navigation methods with no-ops. Prevents expressions from
|
|
822
|
+
// manipulating browser history (pushState, back, forward, etc.).
|
|
823
|
+
const _HISTORY_READ_PROPS = ['length', 'state', 'scrollRestoration'];
|
|
824
|
+
|
|
825
|
+
const _safeHistory = typeof globalThis !== 'undefined' && typeof globalThis.history !== 'undefined'
|
|
826
|
+
? (() => {
|
|
827
|
+
const h = {};
|
|
828
|
+
for (const prop of _HISTORY_READ_PROPS) {
|
|
829
|
+
Object.defineProperty(h, prop, {
|
|
830
|
+
get() { return globalThis.history[prop]; },
|
|
831
|
+
enumerable: true, configurable: false,
|
|
832
|
+
});
|
|
833
|
+
}
|
|
834
|
+
h.pushState = _locationNoop;
|
|
835
|
+
h.replaceState = _locationNoop;
|
|
836
|
+
h.back = _locationNoop;
|
|
837
|
+
h.forward = _locationNoop;
|
|
838
|
+
h.go = _locationNoop;
|
|
839
|
+
return Object.freeze(h);
|
|
840
|
+
})()
|
|
841
|
+
: undefined;
|
|
842
|
+
|
|
843
|
+
// Navigator proxy — blocks sendBeacon (data exfiltration) and credentials
|
|
844
|
+
const _BLOCKED_NAVIGATOR_PROPS = new Set(['sendBeacon', 'credentials']);
|
|
845
|
+
const _safeNavigator = typeof globalThis !== 'undefined' && typeof globalThis.navigator !== 'undefined'
|
|
846
|
+
? new Proxy(globalThis.navigator, {
|
|
847
|
+
get(target, prop, receiver) {
|
|
848
|
+
if (typeof prop === 'string' && _BLOCKED_NAVIGATOR_PROPS.has(prop)) return undefined;
|
|
849
|
+
return Reflect.get(target, prop, receiver);
|
|
850
|
+
},
|
|
851
|
+
set() { return true; }, // navigator props are browser-enforced read-only
|
|
852
|
+
})
|
|
853
|
+
: undefined;
|
|
854
|
+
|
|
855
|
+
// Wire window.location → _safeLocation, window.document → _safeDocument,
|
|
856
|
+
// window.history → _safeHistory, window.navigator → _safeNavigator
|
|
857
|
+
// so accessing via the window proxy returns safe versions
|
|
858
|
+
if (_safeLocation) _WINDOW_PROXY_OVERRIDES.location = _safeLocation;
|
|
859
|
+
if (_safeDocument) _WINDOW_PROXY_OVERRIDES.document = _safeDocument;
|
|
860
|
+
if (_safeHistory) _WINDOW_PROXY_OVERRIDES.history = _safeHistory;
|
|
861
|
+
if (_safeNavigator) _WINDOW_PROXY_OVERRIDES.navigator = _safeNavigator;
|
|
862
|
+
|
|
740
863
|
const _BROWSER_GLOBALS = new Set([
|
|
741
864
|
'window', 'document', 'console', 'location', 'history',
|
|
742
865
|
'navigator', 'screen', 'performance', 'crypto',
|
|
@@ -764,7 +887,14 @@ function _evalNode(node, scope) {
|
|
|
764
887
|
case 'Identifier':
|
|
765
888
|
if (node.name in scope) return scope[node.name];
|
|
766
889
|
if (node.name in _SAFE_GLOBALS) return _SAFE_GLOBALS[node.name];
|
|
767
|
-
if (_BROWSER_GLOBALS.has(node.name) && typeof globalThis !== 'undefined')
|
|
890
|
+
if (_BROWSER_GLOBALS.has(node.name) && typeof globalThis !== 'undefined') {
|
|
891
|
+
if (node.name === 'window') return _safeWindow;
|
|
892
|
+
if (node.name === 'document') return _safeDocument;
|
|
893
|
+
if (node.name === 'location') return _safeLocation;
|
|
894
|
+
if (node.name === 'history') return _safeHistory;
|
|
895
|
+
if (node.name === 'navigator') return _safeNavigator;
|
|
896
|
+
return globalThis[node.name];
|
|
897
|
+
}
|
|
768
898
|
return undefined;
|
|
769
899
|
|
|
770
900
|
case 'Forbidden':
|
|
@@ -915,7 +1045,12 @@ function _evalNode(node, scope) {
|
|
|
915
1045
|
for (let i = 0; i < node.properties.length; i++) {
|
|
916
1046
|
const prop = node.properties[i];
|
|
917
1047
|
if (prop.spread) {
|
|
918
|
-
|
|
1048
|
+
const src = _evalNode(prop.value, scope);
|
|
1049
|
+
if (src && typeof src === 'object') {
|
|
1050
|
+
for (const k of Object.keys(src)) {
|
|
1051
|
+
if (!_FORBIDDEN_PROPS[k]) obj[k] = src[k];
|
|
1052
|
+
}
|
|
1053
|
+
}
|
|
919
1054
|
} else {
|
|
920
1055
|
const key = prop.computed ? _evalNode(prop.key, scope) : prop.key;
|
|
921
1056
|
if (_FORBIDDEN_PROPS[key]) continue;
|
|
@@ -1176,6 +1311,11 @@ export function evaluate(expr, ctx) {
|
|
|
1176
1311
|
if (!("$i18n" in scope)) scope.$i18n = _i18n;
|
|
1177
1312
|
if (!("$refs" in scope)) scope.$refs = ctx.$refs;
|
|
1178
1313
|
if (!("$form" in scope)) scope.$form = ctx.$form || null;
|
|
1314
|
+
// Inject plugin globals (cannot shadow local or core $ variables)
|
|
1315
|
+
for (const gk in _globals) {
|
|
1316
|
+
const key = "$" + gk;
|
|
1317
|
+
if (!(key in scope)) scope[key] = _globals[gk];
|
|
1318
|
+
}
|
|
1179
1319
|
|
|
1180
1320
|
// Parse expression into AST (cached)
|
|
1181
1321
|
let ast = _exprCache.get(mainExpr);
|
|
@@ -1212,6 +1352,11 @@ export function _execStatement(expr, ctx, extraVars = {}) {
|
|
|
1212
1352
|
if (!("$router" in scope)) scope.$router = _routerInstance;
|
|
1213
1353
|
if (!("$i18n" in scope)) scope.$i18n = _i18n;
|
|
1214
1354
|
if (!("$refs" in scope)) scope.$refs = ctx.$refs;
|
|
1355
|
+
// Inject plugin globals (before extraVars so $event etc. take priority)
|
|
1356
|
+
for (const gk in _globals) {
|
|
1357
|
+
const key = "$" + gk;
|
|
1358
|
+
if (!(key in scope)) scope[key] = _globals[gk];
|
|
1359
|
+
}
|
|
1215
1360
|
Object.assign(scope, extraVars);
|
|
1216
1361
|
|
|
1217
1362
|
// Snapshot context chain values for write-back comparison
|
package/src/fetch.js
CHANGED
|
@@ -2,7 +2,61 @@
|
|
|
2
2
|
// FETCH HELPER, URL RESOLUTION & CACHE
|
|
3
3
|
// ═══════════════════════════════════════════════════════════════════════
|
|
4
4
|
|
|
5
|
-
import { _config, _interceptors, _cache } from "./globals.js";
|
|
5
|
+
import { _config, _interceptors, _cache, _plugins, _SENSITIVE_HEADERS, _SENSITIVE_RESPONSE_HEADERS, _log, _warn, _CANCEL, _RESPOND, _REPLACE } from "./globals.js";
|
|
6
|
+
|
|
7
|
+
const _MAX_CACHE = 200;
|
|
8
|
+
const _INTERCEPTOR_TIMEOUT = 5000;
|
|
9
|
+
let _interceptorDepth = 0;
|
|
10
|
+
const _MAX_INTERCEPTOR_DEPTH = 1;
|
|
11
|
+
const _responseOriginals = new WeakMap();
|
|
12
|
+
|
|
13
|
+
function _withTimeout(promise, ms, label) {
|
|
14
|
+
let id;
|
|
15
|
+
return Promise.race([
|
|
16
|
+
promise.finally(() => clearTimeout(id)),
|
|
17
|
+
new Promise((_, reject) => {
|
|
18
|
+
id = setTimeout(() => reject(new Error(label)), ms);
|
|
19
|
+
}),
|
|
20
|
+
]);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function _isSensitiveHeader(name) {
|
|
24
|
+
return _SENSITIVE_HEADERS.has(name.toLowerCase()) || /^x-(auth|api)-/i.test(name);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// URL param redaction helper
|
|
28
|
+
function _redactUrlParams(url) {
|
|
29
|
+
try {
|
|
30
|
+
const u = new URL(url, "http://localhost");
|
|
31
|
+
for (const key of [...u.searchParams.keys()]) {
|
|
32
|
+
if (/token|key|secret|auth|password|credential/i.test(key)) {
|
|
33
|
+
u.searchParams.set(key, "[REDACTED]");
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
// Return just the path+search if it was a relative URL
|
|
37
|
+
return url.startsWith("http") ? u.href : u.pathname + u.search;
|
|
38
|
+
} catch {
|
|
39
|
+
return url;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Response redaction helper for untrusted interceptors
|
|
44
|
+
function _redactResponse(response) {
|
|
45
|
+
const redactedHeaders = new Headers(response.headers);
|
|
46
|
+
for (const h of _SENSITIVE_RESPONSE_HEADERS) {
|
|
47
|
+
redactedHeaders.delete(h);
|
|
48
|
+
}
|
|
49
|
+
const redactedUrl = _redactUrlParams(response.url);
|
|
50
|
+
const redacted = Object.freeze({
|
|
51
|
+
status: response.status,
|
|
52
|
+
ok: response.ok,
|
|
53
|
+
statusText: response.statusText,
|
|
54
|
+
headers: redactedHeaders,
|
|
55
|
+
url: redactedUrl,
|
|
56
|
+
});
|
|
57
|
+
_responseOriginals.set(redacted, response);
|
|
58
|
+
return redacted;
|
|
59
|
+
}
|
|
6
60
|
|
|
7
61
|
export function resolveUrl(url, el) {
|
|
8
62
|
if (
|
|
@@ -31,6 +85,8 @@ export async function _doFetch(
|
|
|
31
85
|
extraHeaders = {},
|
|
32
86
|
el = null,
|
|
33
87
|
externalSignal = null,
|
|
88
|
+
retries = undefined,
|
|
89
|
+
retryDelay = undefined,
|
|
34
90
|
) {
|
|
35
91
|
const fullUrl = resolveUrl(url, el);
|
|
36
92
|
let opts = {
|
|
@@ -62,13 +118,67 @@ export async function _doFetch(
|
|
|
62
118
|
_config.csrf.token || "";
|
|
63
119
|
}
|
|
64
120
|
|
|
65
|
-
// Request interceptors
|
|
66
|
-
|
|
67
|
-
|
|
121
|
+
// ── Request interceptors ──
|
|
122
|
+
// Strip sensitive headers before passing to interceptors
|
|
123
|
+
const sensitiveHeaders = {};
|
|
124
|
+
for (const key of Object.keys(opts.headers)) {
|
|
125
|
+
if (_isSensitiveHeader(key)) {
|
|
126
|
+
sensitiveHeaders[key] = opts.headers[key];
|
|
127
|
+
delete opts.headers[key];
|
|
128
|
+
}
|
|
68
129
|
}
|
|
69
130
|
|
|
131
|
+
_interceptorDepth++;
|
|
132
|
+
try {
|
|
133
|
+
if (_interceptorDepth <= _MAX_INTERCEPTOR_DEPTH) {
|
|
134
|
+
for (let i = 0; i < _interceptors.request.length; i++) {
|
|
135
|
+
const entry = _interceptors.request[i];
|
|
136
|
+
const fn = entry.fn ?? entry;
|
|
137
|
+
const isTrusted = entry.pluginName && _plugins.get(entry.pluginName)?.options?.trusted === true;
|
|
138
|
+
|
|
139
|
+
const interceptorOpts = isTrusted
|
|
140
|
+
? { ...opts, headers: { ...opts.headers, ...sensitiveHeaders } }
|
|
141
|
+
: { ...opts, headers: { ...opts.headers } };
|
|
142
|
+
|
|
143
|
+
const result = await _withTimeout(
|
|
144
|
+
Promise.resolve(fn(fullUrl, interceptorOpts)),
|
|
145
|
+
_INTERCEPTOR_TIMEOUT,
|
|
146
|
+
"Interceptor timeout",
|
|
147
|
+
).catch(e => {
|
|
148
|
+
_warn(`Request interceptor [${i}] error:`, e.message);
|
|
149
|
+
return undefined;
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
if (result && result[_CANCEL]) {
|
|
153
|
+
_log("Request cancelled by interceptor", i);
|
|
154
|
+
throw new DOMException("Request cancelled by interceptor", "AbortError");
|
|
155
|
+
}
|
|
156
|
+
if (result && result[_RESPOND] !== undefined) {
|
|
157
|
+
_log("Request short-circuited by interceptor", i);
|
|
158
|
+
return result[_RESPOND];
|
|
159
|
+
}
|
|
160
|
+
if (result && typeof result === "object" && !result[_CANCEL] && result[_RESPOND] === undefined) {
|
|
161
|
+
if (result.headers && typeof result.headers === "object") {
|
|
162
|
+
const safeHeaders = { ...opts.headers };
|
|
163
|
+
for (const [key, value] of Object.entries(result.headers)) {
|
|
164
|
+
if (!_isSensitiveHeader(key)) {
|
|
165
|
+
safeHeaders[key] = value;
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
opts.headers = safeHeaders;
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
} finally {
|
|
174
|
+
_interceptorDepth--;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// Re-apply sensitive headers after interceptor chain
|
|
178
|
+
Object.assign(opts.headers, sensitiveHeaders);
|
|
179
|
+
|
|
70
180
|
// Retry logic
|
|
71
|
-
const maxRetries = _config.retries || 0;
|
|
181
|
+
const maxRetries = retries !== undefined ? retries : (_config.retries || 0);
|
|
72
182
|
let lastError;
|
|
73
183
|
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
|
74
184
|
try {
|
|
@@ -93,8 +203,33 @@ export async function _doFetch(
|
|
|
93
203
|
clearTimeout(timeout);
|
|
94
204
|
|
|
95
205
|
// Response interceptors
|
|
96
|
-
for (
|
|
97
|
-
|
|
206
|
+
for (let i = 0; i < _interceptors.response.length; i++) {
|
|
207
|
+
const entry = _interceptors.response[i];
|
|
208
|
+
const fn = entry.fn ?? entry;
|
|
209
|
+
const isTrusted = entry.pluginName && _plugins.get(entry.pluginName)?.options?.trusted === true;
|
|
210
|
+
|
|
211
|
+
const interceptorResponse = isTrusted ? response : _redactResponse(response);
|
|
212
|
+
const interceptorUrl = isTrusted ? fullUrl : _redactUrlParams(fullUrl);
|
|
213
|
+
|
|
214
|
+
const result = await _withTimeout(
|
|
215
|
+
Promise.resolve(fn(interceptorResponse, interceptorUrl)),
|
|
216
|
+
_INTERCEPTOR_TIMEOUT,
|
|
217
|
+
"Response interceptor timeout",
|
|
218
|
+
).catch(e => {
|
|
219
|
+
_warn(`Response interceptor [${i}] error:`, e.message);
|
|
220
|
+
return undefined;
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
if (result && result[_REPLACE] !== undefined) {
|
|
224
|
+
_log("Response replaced by interceptor", i);
|
|
225
|
+
return result[_REPLACE];
|
|
226
|
+
}
|
|
227
|
+
if (result) response = result;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// Unwrap redacted shell back to real Response for data extraction
|
|
231
|
+
if (_responseOriginals.has(response)) {
|
|
232
|
+
response = _responseOriginals.get(response);
|
|
98
233
|
}
|
|
99
234
|
|
|
100
235
|
if (!response.ok) {
|
|
@@ -115,7 +250,7 @@ export async function _doFetch(
|
|
|
115
250
|
if (e.name === "AbortError") throw e; // Don't retry aborted requests
|
|
116
251
|
lastError = e;
|
|
117
252
|
if (attempt < maxRetries) {
|
|
118
|
-
await new Promise((r) => setTimeout(r, _config.retryDelay || 1000));
|
|
253
|
+
await new Promise((r) => setTimeout(r, retryDelay !== undefined ? retryDelay : (_config.retryDelay || 1000)));
|
|
119
254
|
}
|
|
120
255
|
}
|
|
121
256
|
}
|
|
@@ -126,8 +261,12 @@ export function _cacheGet(key, strategy) {
|
|
|
126
261
|
if (strategy === "none") return null;
|
|
127
262
|
if (strategy === "memory") {
|
|
128
263
|
const entry = _cache.get(key);
|
|
129
|
-
if (entry && Date.now() - entry.time < (_config.cache.ttl || 300000))
|
|
264
|
+
if (entry && Date.now() - entry.time < (_config.cache.ttl || 300000)) {
|
|
265
|
+
// Move to end (most-recently-used) for LRU eviction
|
|
266
|
+
_cache.delete(key);
|
|
267
|
+
_cache.set(key, entry);
|
|
130
268
|
return entry.data;
|
|
269
|
+
}
|
|
131
270
|
return null;
|
|
132
271
|
}
|
|
133
272
|
const store =
|
|
@@ -154,6 +293,11 @@ export function _cacheSet(key, data, strategy) {
|
|
|
154
293
|
if (strategy === "none") return;
|
|
155
294
|
const entry = { data, time: Date.now() };
|
|
156
295
|
if (strategy === "memory") {
|
|
296
|
+
if (_cache.has(key)) {
|
|
297
|
+
_cache.delete(key); // refresh position before re-inserting
|
|
298
|
+
} else if (_cache.size >= _MAX_CACHE) {
|
|
299
|
+
_cache.delete(_cache.keys().next().value); // evict LRU (insertion-order first)
|
|
300
|
+
}
|
|
157
301
|
_cache.set(key, entry);
|
|
158
302
|
return;
|
|
159
303
|
}
|
package/src/filters.js
CHANGED
|
@@ -20,7 +20,12 @@ _filters.slugify = (v) =>
|
|
|
20
20
|
.toLowerCase()
|
|
21
21
|
.replace(/[^a-z0-9]+/g, "-")
|
|
22
22
|
.replace(/^-|-$/g, "");
|
|
23
|
-
_filters.nl2br = (v) =>
|
|
23
|
+
_filters.nl2br = (v) =>
|
|
24
|
+
String(v ?? "")
|
|
25
|
+
.replace(/&/g, "&")
|
|
26
|
+
.replace(/</g, "<")
|
|
27
|
+
.replace(/>/g, ">")
|
|
28
|
+
.replace(/\n/g, "<br>");
|
|
24
29
|
_filters.encodeUri = (v) => encodeURIComponent(String(v ?? ""));
|
|
25
30
|
|
|
26
31
|
// Numbers
|