micra.js 1.0.0 → 2.0.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/src/utils/expr.ts CHANGED
@@ -5,9 +5,20 @@
5
5
  * - Compile expression strings into cached functions
6
6
  * - Evaluate them against a state object
7
7
  * - Fast-path for simple property lookups
8
+ * - Shadow non-state identifiers so directive expressions cannot reach
9
+ * globals like `window`, `fetch`, `constructor`, etc. A small whitelist
10
+ * of utility globals (Math, JSON, Date, ...) remains accessible.
8
11
  *
9
12
  * LLM NOTE: This module is PURE. It does not touch the DOM or mutate state.
10
- * All side effects are isolated to console.warn on invalid expressions.
13
+ *
14
+ * Security model:
15
+ * Directive expressions are JavaScript — they are compiled via `new Function`
16
+ * and run with full JS capability except that bare identifiers must resolve
17
+ * to either a state key, a component instance method, or one of
18
+ * ALLOWED_GLOBALS. This blocks the `constructor.constructor("...")()` chain
19
+ * and accidental access to `window` / `document` / `fetch`. It does NOT
20
+ * sandbox method calls — if a component method itself touches `window`,
21
+ * that still works. Treat directive templates as trusted code regardless.
11
22
  */
12
23
 
13
24
  import type { StateRecord } from '../types'
@@ -18,12 +29,105 @@ import type { StateRecord } from '../types'
18
29
 
19
30
  // LLM NOTE: exprCache is module-level (shared across all components).
20
31
  // This is intentional — most apps reuse the same expressions.
21
- const exprCache = new Map<string, (state: StateRecord) => unknown>()
32
+ type Compiled = (state: object, safe: object) => unknown
33
+ const exprCache = new Map<string, Compiled>()
34
+ // Expressions whose runtime error we have already warned about. Prevents log spam
35
+ // when the same `data-text="item.naame"` typo fires every render.
36
+ const warnedRuntime = new Set<string>()
22
37
 
23
38
  // Simple identifier or dot-path: "count", "user.name", "item.email"
24
39
  // Matches: letter/$/_ followed by word chars, optionally with .property chains
25
40
  const SIMPLE_PATH = /^[a-zA-Z_$][a-zA-Z0-9_$]*(\.[a-zA-Z_$][a-zA-Z0-9_$]*)*$/
26
41
 
42
+ // ── Safe scope ────────────────────────────────────────────────────────────────
43
+
44
+ /**
45
+ * Globals reachable from directive expressions. Anything else (window, fetch,
46
+ * constructor, eval, ...) is shadowed by SAFE_OUTER and resolves to undefined.
47
+ */
48
+ const ALLOWED_GLOBALS = new Set<string>([
49
+ 'Math', 'JSON', 'Date', 'String', 'Number', 'Boolean', 'Array', 'Object',
50
+ 'parseInt', 'parseFloat', 'isNaN', 'isFinite', 'NaN', 'Infinity', 'undefined',
51
+ ])
52
+
53
+ /**
54
+ * Outer `with()` scope. Its `has` trap claims every non-whitelisted identifier
55
+ * is "in scope" so the JS engine resolves the read on this Proxy (which returns
56
+ * undefined) instead of walking up to the global object. Whitelisted names fall
57
+ * through to globalThis.
58
+ */
59
+ // Sentinel parameter names used by the compiled function. SAFE_OUTER must NOT
60
+ // shadow them, or `with($s)` would resolve to `undefined` via SAFE_OUTER.
61
+ const PARAM_S = '$s'
62
+ const PARAM_SAFE = '$safe'
63
+
64
+ const SAFE_OUTER: object = new Proxy(Object.create(null) as object, {
65
+ has(_target, key): boolean {
66
+ if (typeof key !== 'string') return false
67
+ if (key === PARAM_S || key === PARAM_SAFE) return false
68
+ return !ALLOWED_GLOBALS.has(key)
69
+ },
70
+ get(): undefined {
71
+ return undefined
72
+ },
73
+ })
74
+
75
+ /**
76
+ * @internal Per-state safe wrappers — one per source state object. WeakMap so
77
+ * short-lived itemStates get GC'd with their wrappers.
78
+ */
79
+ const safeWrapCache = new WeakMap<object, object>()
80
+
81
+ /**
82
+ * @internal Pre-computed names that live on `Object.prototype`
83
+ * (constructor, toString, hasOwnProperty, ...). Used by safeStateHas to detect
84
+ * built-in keys without re-walking the chain on every call.
85
+ */
86
+ const OBJ_PROTO_KEYS = new Set<string>(Object.getOwnPropertyNames(Object.prototype))
87
+
88
+ /**
89
+ * Wrap a state object so its `has` trap reports only "real" keys — own
90
+ * properties or keys reachable up to (but not including) `Object.prototype`.
91
+ * This blocks `'constructor' in state` from leaking the prototype.
92
+ */
93
+ function safeStateWrap(state: object): object {
94
+ const cached = safeWrapCache.get(state)
95
+ if (cached) return cached
96
+ const wrapped = new Proxy(state, {
97
+ has(target, key) {
98
+ return safeStateHas(target, key)
99
+ },
100
+ get(target, key) {
101
+ return Reflect.get(target, key)
102
+ },
103
+ })
104
+ safeWrapCache.set(state, wrapped)
105
+ return wrapped
106
+ }
107
+
108
+ /**
109
+ * Return true iff `key` is reachable on `state` without walking into
110
+ * `Object.prototype`. Works for plain objects, prototype-chained objects, and
111
+ * Proxies with their own `has` trap.
112
+ */
113
+ function safeStateHas(state: object, key: PropertyKey): boolean {
114
+ if (typeof key !== 'string') return false
115
+ if (!Reflect.has(state, key)) return false
116
+ // Identifiers that are NOT on Object.prototype are always safe — accept them
117
+ // immediately without walking the chain.
118
+ if (!OBJ_PROTO_KEYS.has(key)) return true
119
+ // Built-in Object.prototype names (constructor, toString, hasOwnProperty, ...)
120
+ // are only accepted when they have been explicitly placed on the state chain.
121
+ let obj: object | null = state
122
+ while (obj && obj !== Object.prototype) {
123
+ if (Object.prototype.hasOwnProperty.call(obj, key)) return true
124
+ obj = Object.getPrototypeOf(obj) as object | null
125
+ }
126
+ return false
127
+ }
128
+
129
+ // ── evalExpr ──────────────────────────────────────────────────────────────────
130
+
27
131
  /**
28
132
  * Evaluate a JS expression string against a state object.
29
133
  *
@@ -37,9 +141,12 @@ const SIMPLE_PATH = /^[a-zA-Z_$][a-zA-Z0-9_$]*(\.[a-zA-Z_$][a-zA-Z0-9_$]*)*$/
37
141
  * evalExpr('price * qty', { price: 9.99, qty: 3 }) // → 29.97
38
142
  */
39
143
  export function evalExpr(expr: string, state: StateRecord): unknown {
40
- // Fast-path: simple property access — no Function() needed
144
+ // Fast-path: simple property access — no Function() needed.
145
+ // Still guarded so bare access to Object.prototype names returns undefined.
41
146
  if (SIMPLE_PATH.test(expr)) {
42
- return expr.split('.').reduce<unknown>((obj, key) =>
147
+ const parts = expr.split('.')
148
+ if (!safeStateHas(state, parts[0]!)) return undefined
149
+ return parts.reduce<unknown>((obj, key) =>
43
150
  obj != null ? (obj as StateRecord)[key] : undefined,
44
151
  state,
45
152
  )
@@ -47,9 +154,10 @@ export function evalExpr(expr: string, state: StateRecord): unknown {
47
154
 
48
155
  if (!exprCache.has(expr)) {
49
156
  try {
157
+ // Two with() statements: $s wins for state keys; $safe shadows globals.
50
158
  exprCache.set(
51
159
  expr,
52
- new Function('$s', `with($s){return (${expr})}`) as (s: StateRecord) => unknown,
160
+ new Function('$s', '$safe', `with($safe){with($s){return (${expr})}}`) as Compiled,
53
161
  )
54
162
  } catch {
55
163
  warn(`invalid expression "${expr}"`)
@@ -58,8 +166,12 @@ export function evalExpr(expr: string, state: StateRecord): unknown {
58
166
  }
59
167
 
60
168
  try {
61
- return exprCache.get(expr)!(state)
62
- } catch {
169
+ return exprCache.get(expr)!(safeStateWrap(state), SAFE_OUTER)
170
+ } catch (e) {
171
+ if (!warnedRuntime.has(expr)) {
172
+ warnedRuntime.add(expr)
173
+ warn(`runtime error in "${expr}": ${(e as Error).message}`)
174
+ }
63
175
  return undefined
64
176
  }
65
177
  }
@@ -81,9 +81,9 @@ export async function micraFetch(url: string, options: FetchOptions = {}): Promi
81
81
  }
82
82
  if (Object.keys(params).length)
83
83
  finalUrl += (url.includes('?') ? '&' : '?') + new URLSearchParams(params)
84
- } else {
84
+ } else if (options.body !== undefined) {
85
85
  headers['Content-Type'] = 'application/json'
86
- body = JSON.stringify(options.body !== undefined ? options.body : options)
86
+ body = JSON.stringify(options.body)
87
87
  }
88
88
 
89
89
  const res = await fetch(finalUrl, {