@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 +1 -1
- package/package.json +2 -2
- package/src/client/use.ts +4 -1
- package/src/engine/actor.ts +13 -3
- package/src/server/api-route.ts +54 -5
- package/src/server/app-dispatch.ts +5 -1
- package/src/server/cached-store.ts +14 -0
- package/src/server/csrf.ts +31 -0
- package/src/server/http.ts +27 -4
- package/src/server/recompute.ts +11 -1
- package/src/server/redis-store.ts +8 -0
- package/src/server/routing.ts +12 -1
- package/src/server/session-lock.ts +42 -1
- package/src/server/session.ts +12 -6
- package/src/server/store.ts +16 -0
- package/src/template/directives/list-attr.ts +14 -1
- package/src/template/html.ts +11 -2
- package/src/template/parser.ts +1 -0
- package/src/wire/apply.ts +15 -0
- package/src/wire/safe-url.ts +67 -0
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 `
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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]
|
package/src/engine/actor.ts
CHANGED
|
@@ -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
|
-
//
|
|
129
|
-
// (DOM → state) without a per-field
|
|
130
|
-
|
|
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
|
package/src/server/api-route.ts
CHANGED
|
@@ -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,
|
|
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
|
-
|
|
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
|
-
|
|
123
|
-
|
|
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<
|
|
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
|
+
}
|
package/src/server/http.ts
CHANGED
|
@@ -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
|
-
|
|
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(
|
|
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
|
}
|
package/src/server/recompute.ts
CHANGED
|
@@ -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()
|
package/src/server/routing.ts
CHANGED
|
@@ -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>(
|
|
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
|
|
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,
|
package/src/server/session.ts
CHANGED
|
@@ -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
|
-
|
|
23
|
-
httpOnly: true,
|
|
24
|
-
sameSite: 'Lax',
|
|
25
|
-
path: '/',
|
|
26
|
-
secure: shouldUseSecureCookie(),
|
|
27
|
-
})
|
|
33
|
+
setSessionCookie(c, sessionId)
|
|
28
34
|
return { sessionId, isNew: true }
|
|
29
35
|
}
|
package/src/server/store.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
package/src/template/html.ts
CHANGED
|
@@ -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
|
}
|
package/src/template/parser.ts
CHANGED
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
|
+
}
|