@statorjs/stator 1.1.1 → 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/README.md CHANGED
@@ -96,7 +96,7 @@ const [counter] = Stator.reads([Counter])
96
96
  </html>
97
97
  ```
98
98
 
99
- Run with `tsx server.ts`. See the repo's `apps/example` for the full wiring
99
+ Run with `tsx server.ts`. See the repo's `examples/desksmith` for the full wiring
100
100
  (type sync, production build, deploy).
101
101
 
102
102
  ## License
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@statorjs/stator",
3
- "version": "1.1.1",
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.1.1"
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,5 +1,7 @@
1
+ import { randomUUID } from 'node:crypto'
1
2
  import type { Context } from 'hono'
2
3
  import { setCookie } from 'hono/cookie'
4
+ import { safeNavigationUrl } from '../wire/safe-url.ts'
3
5
  import { scheduleSessionEffects } from './effects.ts'
4
6
  import { scopedLogger } from './logger.ts'
5
7
  import type { MachineStore } from './machine-store.ts'
@@ -13,7 +15,7 @@ import type {
13
15
  Directive,
14
16
  RouteRequest,
15
17
  } from './routing.ts'
16
- import { getOrCreateSessionId } from './session.ts'
18
+ import { getOrCreateSessionId, setSessionCookie } from './session.ts'
17
19
  import { withSessionLock } from './session-lock.ts'
18
20
  import { SessionRuntime } from './session-runtime.ts'
19
21
  import { fanOut } from './sse.ts'
@@ -42,6 +44,7 @@ export async function runApiRoute(
42
44
  params?: Record<string, string>,
43
45
  ): Promise<Response> {
44
46
  const { sessionId } = getOrCreateSessionId(c)
47
+ let rotation: { clear: boolean } | null = null
45
48
  const request = params
46
49
  ? { ...buildRouteRequest(c, discovered.paramNames), params }
47
50
  : buildRouteRequest(c, discovered.paramNames)
@@ -63,6 +66,12 @@ export async function runApiRoute(
63
66
  // The target machine is addressed by its def; read the name off it.
64
67
  const dispatchedTouched = runtime.processEvent(machine.name, event)
65
68
  for (const name of dispatchedTouched) touched.add(name)
69
+ // Same honesty as client dispatch: a guard-dropped event commits
70
+ // nothing, and handlers (login flows especially) need to know.
71
+ return { committed: dispatchedTouched.size > 0 }
72
+ },
73
+ rotateSession: (opts) => {
74
+ rotation = { clear: opts?.clear === true }
66
75
  },
67
76
  }
68
77
 
@@ -78,9 +87,32 @@ export async function runApiRoute(
78
87
  await runtime.persistTouched(touched)
79
88
  await fanOut(touched, { sessionId })
80
89
  }
90
+
91
+ // Session rotation (fixation defense). Order matters: the runtime has
92
+ // already persisted under the OLD id and fan-out has reached the old
93
+ // id's connections (about to be navigated away) — now the whole
94
+ // session moves (or, for logout, dies) and the response carries the
95
+ // new cookie. Effect completions must chase the NEW id.
96
+ let effectsSessionId = sessionId
97
+ if (rotation !== null) {
98
+ const newSessionId = randomUUID()
99
+ if ((rotation as { clear: boolean }).clear) {
100
+ await store.persistence.deleteSession(sessionId)
101
+ } else {
102
+ if (!store.persistence.renameSession) {
103
+ throw new Error(
104
+ 'stator: rotateSession requires a store with renameSession — ' +
105
+ 'the configured custom store does not implement it.',
106
+ )
107
+ }
108
+ await store.persistence.renameSession(sessionId, newSessionId)
109
+ }
110
+ setSessionCookie(c, newSessionId)
111
+ effectsSessionId = newSessionId
112
+ }
81
113
  // Effects queued by dispatched events run after this callback returns
82
114
  // (never under the session lock); see server/effects.ts.
83
- scheduleSessionEffects(runtime, store, sessionId)
115
+ scheduleSessionEffects(runtime, store, effectsSessionId)
84
116
 
85
117
  // Escape hatch: handler returned a real Response. Pass through.
86
118
  if (result instanceof Response) return result
@@ -93,6 +125,21 @@ export async function runApiRoute(
93
125
  })
94
126
  }
95
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
+
96
143
  /**
97
144
  * Content-negotiated response synthesis from a directives envelope.
98
145
  *
@@ -115,12 +162,14 @@ function synthesizeResponse(
115
162
  (d): d is Extract<Directive, { type: 'navigate' }> => d.type === 'navigate',
116
163
  )
117
164
  if (navigate) {
118
- return c.redirect(navigate.to, 303)
165
+ // Never emit a javascript:/vbscript:/data: Location — coerce to '/'.
166
+ return c.redirect(safeNavigationUrl(navigate.to), 303)
119
167
  }
120
168
  const reload = envelope.directives?.find((d) => d.type === 'reload')
121
169
  if (reload) {
122
- const ref = request.headers.get('referer') ?? '/'
123
- 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)
124
173
  }
125
174
  // No actionable directive for a no-JS client. Send a minimal 204.
126
175
  return new Response(null, { status: 204 })
@@ -18,7 +18,7 @@ export async function dispatchToApp<D extends AnyMachineDef>(
18
18
  store: MachineStore,
19
19
  machine: D,
20
20
  event: EventOf<D>,
21
- ): Promise<void> {
21
+ ): Promise<{ committed: boolean }> {
22
22
  const name = machine.name
23
23
  const handle = store.appInstance(name)
24
24
  if (!handle) {
@@ -46,6 +46,10 @@ export async function dispatchToApp<D extends AnyMachineDef>(
46
46
  await store.persistAppMachine(touchedName)
47
47
  }
48
48
  await fanOut(touched)
49
+ // Same contract as client and API-route dispatch: did the TARGET machine
50
+ // actually commit a transition? Webhook receivers use this to tell a
51
+ // processed event from a guard-dropped duplicate.
52
+ return { committed: touched.has(name) }
49
53
  } finally {
50
54
  runtime.dispose()
51
55
  }
@@ -99,6 +99,20 @@ export class CachedStore implements Store {
99
99
  await this.backing.deleteSession(sid)
100
100
  }
101
101
 
102
+ async renameSession(oldSid: string, newSid: string): Promise<void> {
103
+ // Drop (don't move) cache entries — the next read repopulates from the
104
+ // backing under the new id. Simpler than key surgery, and rotation is
105
+ // rare (login/logout).
106
+ const prefix = `${oldSid}:`
107
+ for (const k of [...this.cache.keys()]) {
108
+ if (k.startsWith(prefix)) this.cache.delete(k)
109
+ }
110
+ if (!this.backing.renameSession) {
111
+ throw new Error('stator: CachedStore backing store does not support renameSession')
112
+ }
113
+ await this.backing.renameSession(oldSid, newSid)
114
+ }
115
+
102
116
  /** Insert / refresh an entry at LRU-end and evict oldest if over capacity. */
103
117
  private populate(key: string, snapshot: unknown, ttlMs: number): void {
104
118
  this.cache.delete(key)
@@ -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
+ }
@@ -69,6 +69,14 @@ export class RedisStore implements Store {
69
69
  await this.client.del(this.key(sid))
70
70
  }
71
71
 
72
+ async renameSession(oldSid: string, newSid: string): Promise<void> {
73
+ // RENAME is atomic and carries the hash's TTL; it throws when the source
74
+ // is missing (an empty session has nothing to move — fine).
75
+ if ((await this.client.exists(this.key(oldSid))) === 1) {
76
+ await this.client.rename(this.key(oldSid), this.key(newSid))
77
+ }
78
+ }
79
+
72
80
  /** Close the connection. Call on graceful shutdown. */
73
81
  async close(): Promise<void> {
74
82
  await this.client.quit()
@@ -136,7 +136,18 @@ export interface ApiRouteHelpers {
136
136
  * Processes the event under the dispatch context, persists touched machines,
137
137
  * fires cross-machine subscriptions. The machine must be in the route's
138
138
  * loaded graph (its `reads`, transitively). */
139
- dispatch: <D extends AnyMachineDef>(machine: D, event: EventOf<D>) => Promise<void>
139
+ dispatch: <D extends AnyMachineDef>(
140
+ machine: D,
141
+ event: EventOf<D>,
142
+ ) => Promise<{ committed: boolean }>
143
+ /** Rotate the session id — the fixation defense for privilege changes.
144
+ * Call on login (state moves to a fresh id; the old id becomes worthless
145
+ * to anyone who captured it) and on logout with `{ clear: true }` (the
146
+ * old session's state is DELETED and the browser starts anonymous).
147
+ * Applied after the handler returns: state persists under the new id and
148
+ * the response carries the new cookie. Requires a store with
149
+ * `renameSession` (all built-in stores have it). */
150
+ rotateSession: (opts?: { clear?: boolean }) => void
140
151
  }
141
152
 
142
153
  export interface ApiRouteDefinition {
@@ -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,
@@ -12,6 +12,17 @@ function shouldUseSecureCookie(): boolean {
12
12
  return process.env.NODE_ENV === 'production'
13
13
  }
14
14
 
15
+ /** Write the session cookie — shared by session creation and rotation so
16
+ * the flags can never drift apart. */
17
+ export function setSessionCookie(c: Context, sessionId: string): void {
18
+ setCookie(c, SESSION_COOKIE, sessionId, {
19
+ httpOnly: true,
20
+ sameSite: 'Lax',
21
+ path: '/',
22
+ secure: shouldUseSecureCookie(),
23
+ })
24
+ }
25
+
15
26
  export function getOrCreateSessionId(c: Context): {
16
27
  sessionId: string
17
28
  isNew: boolean
@@ -19,11 +30,6 @@ export function getOrCreateSessionId(c: Context): {
19
30
  const existing = getCookie(c, SESSION_COOKIE)
20
31
  if (existing) return { sessionId: existing, isNew: false }
21
32
  const sessionId = randomUUID()
22
- setCookie(c, SESSION_COOKIE, sessionId, {
23
- httpOnly: true,
24
- sameSite: 'Lax',
25
- path: '/',
26
- secure: shouldUseSecureCookie(),
27
- })
33
+ setSessionCookie(c, sessionId)
28
34
  return { sessionId, isNew: true }
29
35
  }
@@ -29,6 +29,10 @@ export interface Store {
29
29
  ): Promise<void>
30
30
  has(sessionId: string, machineName: string): Promise<boolean>
31
31
  deleteSession(sessionId: string): Promise<void>
32
+ /** Move EVERY machine snapshot from one session id to another (used by
33
+ * session rotation on privilege change). Optional for custom adapters;
34
+ * rotation fails loudly when the configured store lacks it. */
35
+ renameSession?(oldSessionId: string, newSessionId: string): Promise<void>
32
36
  }
33
37
 
34
38
  /**
@@ -92,4 +96,16 @@ export class InMemoryStore implements Store {
92
96
  async deleteSession(sid: string): Promise<void> {
93
97
  this.dropSession(sid)
94
98
  }
99
+
100
+ async renameSession(oldSid: string, newSid: string): Promise<void> {
101
+ if (this.isExpired(oldSid)) {
102
+ this.dropSession(oldSid)
103
+ return
104
+ }
105
+ const session = this.data.get(oldSid)
106
+ const expiry = this.expiryAt.get(oldSid)
107
+ this.dropSession(oldSid)
108
+ if (session) this.data.set(newSid, session)
109
+ if (expiry !== undefined) this.expiryAt.set(newSid, expiry)
110
+ }
95
111
  }
@@ -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
+ }