@statorjs/stator 1.2.0 → 1.2.1

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@statorjs/stator",
3
- "version": "1.2.0",
3
+ "version": "1.2.1",
4
4
  "description": "Server-canonical web framework: business logic in composable state machines, UI as a thin renderer binding machine outputs to DOM positions. Ships TypeScript source (Vite/tsx-native by design).",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -88,7 +88,7 @@
88
88
  "pino-pretty": "^13.1.3",
89
89
  "vite": "^6.0.0",
90
90
  "vitest": "^2.1.0",
91
- "@statorjs/stator": "1.2.0"
91
+ "@statorjs/stator": "1.2.1"
92
92
  },
93
93
  "scripts": {
94
94
  "typecheck": "tsc --noEmit",
package/src/client/use.ts CHANGED
@@ -85,7 +85,10 @@ export function use<D extends MachineDef>(
85
85
  context: { ...(def.context as object), ...eager },
86
86
  }
87
87
  : undefined
88
- const actor = createActor(def as AnyMachineDef, { snapshot })
88
+ // Client islands honor framework-internal events (`@set` for two-way
89
+ // `bind:value`); the actor lives in the browser and only its own compiled
90
+ // bind code sends them. Server actors deliberately do NOT (see createActor).
91
+ const actor = createActor(def as AnyMachineDef, { snapshot, internalEvents: true })
89
92
 
90
93
  // Register with the element under construction so its lifecycle owns the actor.
91
94
  const bucket = collectors[collectors.length - 1]
@@ -57,6 +57,12 @@ export interface CreateActorOptions<C> {
57
57
  * effect locally on a microtask and sends its completion event to itself —
58
58
  * the client-plane (and unit-test) behavior. */
59
59
  onEffect?: (invocation: EffectInvocation) => void
60
+ /** Honor framework-internal events (currently `@set`, which powers two-way
61
+ * `bind:value`). Enabled ONLY for client-island actors, whose `@set` events
62
+ * originate from their own compiled bind code. Server actors leave this off:
63
+ * they take events straight from the untrusted wire (`/__events`), where a
64
+ * `@set` would be an arbitrary-context-write that bypasses every guard. */
65
+ internalEvents?: boolean
60
66
  }
61
67
 
62
68
  /** Unique per-invocation effect id — usable as an idempotency key, so it must
@@ -125,9 +131,13 @@ export function createActor<C extends object, E extends EventObject, S extends s
125
131
  },
126
132
 
127
133
  send(event: E) {
128
- // Built-in `@set`: assign one context key. Powers two-way `bind:value`
129
- // (DOM → state) without a per-field transition. Available in every state.
130
- if ((event as { type: string }).type === '@set') {
134
+ // Framework-internal `@set`: assign one context key. Powers two-way
135
+ // `bind:value` (DOM → state) on client islands without a per-field
136
+ // transition. Honored ONLY when the host opted in (`internalEvents`)
137
+ // server actors never do, so a wire-delivered `@set` falls through to
138
+ // ordinary (unhandled) resolution and mutates nothing. Without this gate
139
+ // `@set` is a guard-bypassing arbitrary-context write over `/__events`.
140
+ if (opts.internalEvents && (event as { type: string }).type === '@set') {
131
141
  const e = event as unknown as { key: string; value: unknown }
132
142
  context = { ...context, [e.key]: e.value }
133
143
  commits += 1
@@ -1,6 +1,7 @@
1
1
  import { randomUUID } from 'node:crypto'
2
2
  import type { Context } from 'hono'
3
3
  import { setCookie } from 'hono/cookie'
4
+ import { safeNavigationUrl } from '../wire/safe-url.ts'
4
5
  import { scheduleSessionEffects } from './effects.ts'
5
6
  import { scopedLogger } from './logger.ts'
6
7
  import type { MachineStore } from './machine-store.ts'
@@ -124,6 +125,21 @@ export async function runApiRoute(
124
125
  })
125
126
  }
126
127
 
128
+ /** The Referer header is attacker-controllable, so redirecting back to it is an
129
+ * open-redirect vector. Return a same-origin relative path (pathname+search)
130
+ * when the referer matches this request's origin, else '/'. */
131
+ function sameOriginReferer(request: RouteRequest): string {
132
+ const ref = request.headers.get('referer')
133
+ if (!ref) return '/'
134
+ try {
135
+ const refUrl = new URL(ref)
136
+ if (refUrl.origin !== new URL(request.url).origin) return '/'
137
+ return `${refUrl.pathname}${refUrl.search}`
138
+ } catch {
139
+ return '/'
140
+ }
141
+ }
142
+
127
143
  /**
128
144
  * Content-negotiated response synthesis from a directives envelope.
129
145
  *
@@ -146,12 +162,14 @@ function synthesizeResponse(
146
162
  (d): d is Extract<Directive, { type: 'navigate' }> => d.type === 'navigate',
147
163
  )
148
164
  if (navigate) {
149
- return c.redirect(navigate.to, 303)
165
+ // Never emit a javascript:/vbscript:/data: Location — coerce to '/'.
166
+ return c.redirect(safeNavigationUrl(navigate.to), 303)
150
167
  }
151
168
  const reload = envelope.directives?.find((d) => d.type === 'reload')
152
169
  if (reload) {
153
- const ref = request.headers.get('referer') ?? '/'
154
- return c.redirect(ref, 303)
170
+ // The Referer is attacker-controllable; only bounce back to it when it's
171
+ // same-origin, else fall back to '/' (no open redirect).
172
+ return c.redirect(sameOriginReferer(request), 303)
155
173
  }
156
174
  // No actionable directive for a no-JS client. Send a minimal 204.
157
175
  return new Response(null, { status: 204 })
@@ -0,0 +1,31 @@
1
+ import type { Context } from 'hono'
2
+
3
+ /**
4
+ * CSRF signal check for state-changing requests. The session cookie
5
+ * (`stator_sid`) is `SameSite=Lax`, which already withholds itself from most
6
+ * cross-site POSTs — this is defense-in-depth using the browser-supplied
7
+ * request metadata (`Sec-Fetch-Site`, falling back to `Origin`).
8
+ *
9
+ * Only browser-originated cross-origin writes are rejected. Requests with no
10
+ * such signal (server-to-server API/webhook callers, the test harness) pass —
11
+ * they carry no ambient cookie authority a forgery could abuse, and the header
12
+ * is browser-only, so a real browser can never suppress it.
13
+ *
14
+ * Note the primary vector blocked is `cross-site`. `same-site` (sibling
15
+ * subdomain) is allowed so legitimate multi-subdomain deployments keep working;
16
+ * harden with `SameSite=Strict` / an app-level check if subdomains are
17
+ * untrusted.
18
+ */
19
+ export function isBlockedCrossSite(c: Context): boolean {
20
+ const site = c.req.header('sec-fetch-site')
21
+ if (site) return site === 'cross-site'
22
+ const origin = c.req.header('origin')
23
+ if (origin) {
24
+ try {
25
+ return new URL(origin).host !== new URL(c.req.url).host
26
+ } catch {
27
+ return true
28
+ }
29
+ }
30
+ return false
31
+ }
@@ -1,11 +1,12 @@
1
1
  import { readFile } from 'node:fs/promises'
2
- import { dirname, extname, resolve } from 'node:path'
2
+ import { dirname, extname, resolve, sep } from 'node:path'
3
3
  import { fileURLToPath } from 'node:url'
4
4
  import { build } from 'esbuild'
5
5
  import { type Context, Hono } from 'hono'
6
6
  import { streamSSE } from 'hono/streaming'
7
7
  import { z } from 'zod'
8
8
  import { applyRenderedEffects, runApiRoute } from './api-route.ts'
9
+ import { isBlockedCrossSite } from './csrf.ts'
9
10
  import { scheduleSessionEffects } from './effects.ts'
10
11
  import { scopedLogger } from './logger.ts'
11
12
  import type { MachineStore } from './machine-store.ts'
@@ -161,12 +162,20 @@ export async function buildHonoApp(config: HttpConfig): Promise<Hono> {
161
162
  }
162
163
 
163
164
  if (config.staticDir) {
164
- const staticDir = config.staticDir
165
+ const staticDir = resolve(config.staticDir)
165
166
  app.get('/static/*', async (c) => {
166
167
  const rel = c.req.path.replace(/^\/static\//, '')
167
- if (rel.includes('..')) return c.text('forbidden', 403)
168
+ // Containment check: resolve, then require the result to stay under
169
+ // staticDir. This defeats `..` traversal AND absolute-path escapes —
170
+ // `/static//etc/passwd` yields rel `/etc/passwd`, which `resolve` would
171
+ // otherwise honor verbatim (discarding staticDir) and serve. A lexical
172
+ // `..` check alone misses the absolute-path case.
173
+ const full = resolve(staticDir, rel)
174
+ if (full !== staticDir && !full.startsWith(staticDir + sep)) {
175
+ return c.text('forbidden', 403)
176
+ }
168
177
  try {
169
- const buf = await readFile(resolve(staticDir, rel))
178
+ const buf = await readFile(full)
170
179
  c.header('Content-Type', contentTypeFor(rel))
171
180
  return c.body(buf)
172
181
  } catch {
@@ -260,6 +269,9 @@ export async function buildHonoApp(config: HttpConfig): Promise<Hono> {
260
269
  })
261
270
 
262
271
  app.post('/__events', async (c) => {
272
+ if (isBlockedCrossSite(c)) {
273
+ return c.json({ error: 'cross-site request blocked' }, 403)
274
+ }
263
275
  const { sessionId } = getOrCreateSessionId(c)
264
276
  const routeKey = c.req.header('X-Stator-Route')
265
277
  if (!routeKey) {
@@ -287,6 +299,16 @@ export async function buildHonoApp(config: HttpConfig): Promise<Hono> {
287
299
  return c.json({ error: 'invalid event payload', detail: String(e) }, 400)
288
300
  }
289
301
 
302
+ // Reserved `@`-prefixed events (e.g. the engine's built-in `@set`) are
303
+ // framework-internal and only ever originate in-browser for client-island
304
+ // binds. They must never reach a server machine from the wire, where `@set`
305
+ // would be a guard-bypassing arbitrary-context write. Server actors also
306
+ // ignore them (createActor without `internalEvents`); this is the clean
307
+ // 400 at the boundary rather than a silent no-op.
308
+ if (body.event.type.startsWith('@')) {
309
+ return c.json({ error: `event type "${body.event.type}" is reserved` }, 400)
310
+ }
311
+
290
312
  const originDef = config.store.getDef(body.machine)
291
313
  if (!originDef) {
292
314
  return c.json({ error: `unknown machine "${body.machine}"` }, 404)
@@ -339,6 +361,7 @@ export async function buildHonoApp(config: HttpConfig): Promise<Hono> {
339
361
  const matched = matchPath(matchers, c.req.path)
340
362
  const apiRoute = matched?.route[method]
341
363
  if (!matched || !apiRoute) return next()
364
+ if (isBlockedCrossSite(c)) return c.text('cross-site request blocked', 403)
342
365
  return runApiRoute(c, matched.route, apiRoute, config.store, matched.params)
343
366
  })
344
367
  }
@@ -1,6 +1,7 @@
1
1
  import { renderBranchBody } from '../template/conditional.ts'
2
2
  import { coerceKeys, renderKeyedItem, renderListBody } from '../template/each.ts'
3
3
  import type { Patch } from '../wire/index.ts'
4
+ import { isUrlAttribute, safeAttrUrl } from '../wire/safe-url.ts'
4
5
  import {
5
6
  keyedScopePrefix,
6
7
  keyToken,
@@ -68,7 +69,7 @@ export function recompute(
68
69
  target: { kind: 'element', id: binding.parentId },
69
70
  op: 'attr',
70
71
  name: binding.attrName,
71
- value: attrWireValue(newValue),
72
+ value: sanitizeAttrWire(binding.attrName, attrWireValue(newValue)),
72
73
  },
73
74
  sourceSlot: slotId,
74
75
  })
@@ -285,3 +286,12 @@ function attrWireValue(v: unknown): string | null {
285
286
  if (v === true) return ''
286
287
  return stringify(v)
287
288
  }
289
+
290
+ /** Mirror of html.ts `sanitizeAttrValue` for the live-update path: strip a
291
+ * javascript:/vbscript: value from a url-bearing attribute patch so a binding
292
+ * that was safe at first render can't turn dangerous via a diff. Null
293
+ * (attribute-absent) passes through. */
294
+ function sanitizeAttrWire(attrName: string, value: string | null): string | null {
295
+ if (value === null) return null
296
+ return isUrlAttribute(attrName) ? safeAttrUrl(value) : value
297
+ }
@@ -11,11 +11,52 @@
11
11
  *
12
12
  * GETs are read-only and do not acquire the lock.
13
13
  */
14
+ import { scopedLogger } from './logger.ts'
15
+
16
+ const lockLog = scopedLogger('session-lock')
14
17
  const sessionLocks = new Map<string, Promise<unknown>>()
15
18
 
19
+ /** A single mutation that neither resolves nor rejects (a hung store I/O, a
20
+ * wedged effect) would otherwise pin the session's whole promise chain
21
+ * forever — every later mutation queues behind it with no recovery. This cap
22
+ * converts a hang into a rejection so the chain drains. The abandoned work may
23
+ * still complete in the background, so the timeout is generous (a real
24
+ * mutation is milliseconds); it's a wedge backstop, not a deadline. */
25
+ const LOCK_TIMEOUT_MS = 30_000
26
+
27
+ function withTimeout<T>(sid: string, fn: () => Promise<T>): Promise<T> {
28
+ return new Promise<T>((resolve, reject) => {
29
+ let done = false
30
+ const timer = setTimeout(() => {
31
+ if (done) return
32
+ done = true
33
+ lockLog.error({ sid, ms: LOCK_TIMEOUT_MS }, 'session lock timed out — mutation abandoned')
34
+ reject(new Error(`stator: session "${sid}" lock held > ${LOCK_TIMEOUT_MS}ms — abandoned`))
35
+ }, LOCK_TIMEOUT_MS)
36
+ ;(timer as unknown as { unref?: () => void }).unref?.()
37
+ Promise.resolve()
38
+ .then(fn)
39
+ .then(
40
+ (v) => {
41
+ if (done) return
42
+ done = true
43
+ clearTimeout(timer)
44
+ resolve(v)
45
+ },
46
+ (e) => {
47
+ if (done) return
48
+ done = true
49
+ clearTimeout(timer)
50
+ reject(e)
51
+ },
52
+ )
53
+ })
54
+ }
55
+
16
56
  export function withSessionLock<T>(sid: string, fn: () => Promise<T>): Promise<T> {
17
57
  const prev = sessionLocks.get(sid) ?? Promise.resolve()
18
- const next = prev.then(fn, fn)
58
+ const run = () => withTimeout(sid, fn)
59
+ const next = prev.then(run, run)
19
60
  const settled = next.then(
20
61
  () => undefined,
21
62
  () => undefined,
@@ -119,11 +119,24 @@ function walkStyle(spec: StyleListSpec, out: string[]): void {
119
119
  for (const [prop, raw] of Object.entries(spec)) {
120
120
  const value = evalRead(raw)
121
121
  if (value == null || value === '') continue
122
- out.push(`${prop}: ${value}`)
122
+ // A single property's value must not carry `;` — a `read()`-sourced value
123
+ // like `red; position: fixed; …` would otherwise inject extra
124
+ // declarations (overlay / `url()` exfil). Cut at the first `;`.
125
+ const safe = cssValue(String(value))
126
+ if (safe === '') continue
127
+ out.push(`${prop}: ${safe}`)
123
128
  }
124
129
  }
125
130
  }
126
131
 
132
+ /** One declaration's value: everything up to the first `;` (the declaration
133
+ * separator), trimmed. Blocks reactive CSS-value injection while leaving legit
134
+ * values — including `url(...)` — intact. */
135
+ function cssValue(v: string): string {
136
+ const semi = v.indexOf(';')
137
+ return (semi === -1 ? v : v.slice(0, semi)).trim()
138
+ }
139
+
127
140
  /**
128
141
  * Register one attr-binding per unique machine the spec depends on. Each
129
142
  * binding's selector ignores its arg and recomputes the entire attribute
@@ -3,6 +3,7 @@ import {
3
3
  registerBinding,
4
4
  requireCurrentRenderState,
5
5
  } from '../server/render-context.ts'
6
+ import { isUrlAttribute, safeAttrUrl } from '../wire/safe-url.ts'
6
7
  import { escapeAttribute, escapeText, HtmlBuilder, type ValuePosition } from './parser.ts'
7
8
  import { createHtmlFragment, type HtmlFragment, isHtmlFragment } from './types.ts'
8
9
 
@@ -43,6 +44,14 @@ export function html(strings: TemplateStringsArray, ...values: unknown[]): HtmlF
43
44
  return createHtmlFragment(builder.toString())
44
45
  }
45
46
 
47
+ /** URL-scheme guard for attribute interpolation: on url-bearing attributes
48
+ * (href/src/…), strip a javascript:/vbscript: value; other attributes pass
49
+ * through unchanged. Mirrored on the live-update path (server/recompute.ts) so
50
+ * a value that's safe at first render can't turn dangerous via a patch. */
51
+ function sanitizeAttrValue(attrName: string, value: string): string {
52
+ return isUrlAttribute(attrName) ? safeAttrUrl(value) : value
53
+ }
54
+
46
55
  function processValue(builder: HtmlBuilder, state: RenderState, value: unknown): void {
47
56
  const pos = builder.positionForValue()
48
57
  if (pos.kind === 'invalid') {
@@ -101,7 +110,7 @@ function processValue(builder: HtmlBuilder, state: RenderState, value: unknown):
101
110
  return
102
111
  }
103
112
  if (pos.kind === 'attr-value') {
104
- builder.pushRaw(escapeAttribute(stringifyValue(value)))
113
+ builder.pushRaw(escapeAttribute(sanitizeAttrValue(pos.attrName, stringifyValue(value))))
105
114
  return
106
115
  }
107
116
  throw new Error(`stator: cannot interpolate a plain value at ${pos.kind} position`)
@@ -164,7 +173,7 @@ function handleRead(
164
173
  if (r.value === false || r.value === null || r.value === undefined) {
165
174
  builder.omitCurrentAttribute()
166
175
  } else if (r.value !== true) {
167
- builder.pushRaw(escapeAttribute(stringifyValue(r.value)))
176
+ builder.pushRaw(escapeAttribute(sanitizeAttrValue(pos.attrName, stringifyValue(r.value))))
168
177
  }
169
178
  return
170
179
  }
@@ -336,6 +336,7 @@ export function escapeAttribute(s: string): string {
336
336
  return s
337
337
  .replace(/&/g, '&amp;')
338
338
  .replace(/"/g, '&quot;')
339
+ .replace(/'/g, '&#39;')
339
340
  .replace(/</g, '&lt;')
340
341
  .replace(/>/g, '&gt;')
341
342
  }
package/src/wire/apply.ts CHANGED
@@ -9,6 +9,7 @@
9
9
  * path applied it.
10
10
  */
11
11
  import type { Directive, Patch } from './index.ts'
12
+ import { isSafeNavigationUrl } from './safe-url.ts'
12
13
 
13
14
  function emit(name: string, detail: unknown): void {
14
15
  window.dispatchEvent(new CustomEvent(name, { detail }))
@@ -66,15 +67,29 @@ export function applyDirectives(directives: Directive[]): void {
66
67
  emit('stator:directive-applied', { directive, timestamp: Date.now() })
67
68
  switch (directive.type) {
68
69
  case 'navigate':
70
+ // Reject javascript:/vbscript:/data: targets — a navigation directive
71
+ // must not be an in-page script sink or off-document jump.
72
+ if (!isSafeNavigationUrl(directive.to)) {
73
+ console.error('stator: refusing unsafe navigate target', directive.to)
74
+ return
75
+ }
69
76
  location.href = directive.to
70
77
  return // stop processing further directives; we're leaving
71
78
  case 'reload':
72
79
  location.reload()
73
80
  return
74
81
  case 'push-url':
82
+ if (!isSafeNavigationUrl(directive.to)) {
83
+ console.error('stator: refusing unsafe push-url target', directive.to)
84
+ break
85
+ }
75
86
  history.pushState({}, '', directive.to)
76
87
  break
77
88
  case 'replace-url':
89
+ if (!isSafeNavigationUrl(directive.to)) {
90
+ console.error('stator: refusing unsafe replace-url target', directive.to)
91
+ break
92
+ }
78
93
  history.replaceState({}, '', directive.to)
79
94
  break
80
95
  case 'focus': {
@@ -0,0 +1,67 @@
1
+ /**
2
+ * URL-scheme safety — shared by the client directive applier (`wire/apply.ts`)
3
+ * and the server render/redirect paths (`template/html.ts`,
4
+ * `server/recompute.ts`, `server/api-route.ts`). One implementation so the two
5
+ * planes can't drift on what counts as a dangerous URL.
6
+ *
7
+ * The framework does no URL-scheme validation otherwise, so a user-controlled
8
+ * `href`/`src` or navigation target can carry `javascript:` (script execution)
9
+ * or an off-site/`data:` document. These helpers reject the never-legitimate
10
+ * schemes while leaving ordinary relative and `http(s)`/`mailto:`/`tel:` URLs —
11
+ * and, for attributes, `data:` images — untouched.
12
+ */
13
+
14
+ /** Normalize for the scheme check ONLY (we still render the original value).
15
+ * Browsers ignore leading control chars/whitespace and strip TAB/LF/CR from
16
+ * within a scheme, so `\x01java\tscript:` must be caught. The `^` anchor in
17
+ * the callers means removing interior whitespace can't create a false match on
18
+ * a legitimate `http(s)` URL. Strips ASCII space/controls (<= 0x20) and C1
19
+ * controls (0x7f–0x9f) without embedding a control-char regex literal. */
20
+ function stripForSchemeCheck(url: string): string {
21
+ let out = ''
22
+ for (let i = 0; i < url.length; i++) {
23
+ const code = url.charCodeAt(i)
24
+ if (code > 0x20 && !(code >= 0x7f && code <= 0x9f)) out += url[i]
25
+ }
26
+ return out
27
+ }
28
+
29
+ /** Script-executing schemes — never legitimate in any attribute or navigation. */
30
+ const SCRIPT_SCHEME = /^(?:javascript|vbscript):/i
31
+ /** Adds `data:` — dangerous as a navigation target (renders as a document),
32
+ * but allowed in resource attributes like `img src` (data-URI images). */
33
+ const NAV_SCHEME = /^(?:javascript|vbscript|data):/i
34
+
35
+ /** Safe as a navigation target (`location.href`, an HTTP redirect)? */
36
+ export function isSafeNavigationUrl(url: string): boolean {
37
+ return !NAV_SCHEME.test(stripForSchemeCheck(url))
38
+ }
39
+
40
+ /** Coerce a navigation target: dangerous-scheme URLs collapse to `fallback`. */
41
+ export function safeNavigationUrl(url: string, fallback = '/'): string {
42
+ return isSafeNavigationUrl(url) ? url : fallback
43
+ }
44
+
45
+ /** Attribute names whose values are fetched/navigated as URLs. */
46
+ const URL_ATTRS = new Set([
47
+ 'href',
48
+ 'src',
49
+ 'action',
50
+ 'formaction',
51
+ 'xlink:href',
52
+ 'poster',
53
+ 'background',
54
+ 'cite',
55
+ 'ping',
56
+ ])
57
+
58
+ export function isUrlAttribute(name: string): boolean {
59
+ return URL_ATTRS.has(name.toLowerCase())
60
+ }
61
+
62
+ /** Sanitize a URL attribute value: strip an ever-illegitimate script scheme
63
+ * (`javascript:`/`vbscript:`) to an empty value; leave everything else
64
+ * (relative, http(s), mailto, tel, and `data:` images) intact. */
65
+ export function safeAttrUrl(value: string): string {
66
+ return SCRIPT_SCHEME.test(stripForSchemeCheck(value)) ? '' : value
67
+ }