@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.
@@ -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
- field.addEventListener("input", () => {
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
- field.addEventListener("focusout", (e) => {
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.addEventListener("focusout", (e) => {
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
- el.addEventListener("submit", (e) => {
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
- el.addEventListener("input", () => {
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) errorEl.innerHTML = "";
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
- el.addEventListener("nojs:error", (e) => {
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. &#x6A;avascript:).
@@ -47,7 +92,10 @@ const _BLOCKED_TAGS = new Set([
47
92
  ]);
48
93
 
49
94
  export function _sanitizeHtml(html) {
50
- if (!_config.sanitize) return html;
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.fetch / window.localStorage remain accessible via the window object.
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') return globalThis[node.name];
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
- Object.assign(obj, _evalNode(prop.value, scope));
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
- for (const fn of _interceptors.request) {
67
- opts = fn(fullUrl, opts) || opts;
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 (const fn of _interceptors.response) {
97
- response = fn(response, fullUrl) || response;
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) => String(v ?? "").replace(/\n/g, "<br>");
23
+ _filters.nl2br = (v) =>
24
+ String(v ?? "")
25
+ .replace(/&/g, "&amp;")
26
+ .replace(/</g, "&lt;")
27
+ .replace(/>/g, "&gt;")
28
+ .replace(/\n/g, "<br>");
24
29
  _filters.encodeUri = (v) => encodeURIComponent(String(v ?? ""));
25
30
 
26
31
  // Numbers