@statorjs/stator 1.2.0 → 1.2.2
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 +2 -2
- package/src/client/use.ts +4 -1
- package/src/compiler/virtual-code.ts +64 -22
- package/src/engine/actor.ts +13 -3
- package/src/server/api-route.ts +21 -3
- package/src/server/csrf.ts +31 -0
- package/src/server/http.ts +27 -4
- package/src/server/recompute.ts +11 -1
- package/src/server/session-lock.ts +42 -1
- 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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@statorjs/stator",
|
|
3
|
-
"version": "1.2.
|
|
3
|
+
"version": "1.2.2",
|
|
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.
|
|
91
|
+
"@statorjs/stator": "1.2.2"
|
|
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]
|
|
@@ -18,6 +18,7 @@
|
|
|
18
18
|
* load-bearing part.
|
|
19
19
|
*/
|
|
20
20
|
|
|
21
|
+
import ts from 'typescript'
|
|
21
22
|
import { analyzeScriptClasses } from './client-script.ts'
|
|
22
23
|
import { componentPropsType, extractFrontmatterTypes } from './dts.ts'
|
|
23
24
|
import { type ScannedRegions, scanRegions } from './split.ts'
|
|
@@ -131,24 +132,29 @@ export function toVirtualCode(source: string): VirtualCodeResult {
|
|
|
131
132
|
}
|
|
132
133
|
}
|
|
133
134
|
|
|
134
|
-
/** Server component:
|
|
135
|
-
*
|
|
135
|
+
/** Server component: import/type/interface declarations hoist to module scope;
|
|
136
|
+
* the executable frontmatter body + the template (a JSX fragment) live inside
|
|
137
|
+
* the render function, so template expressions see the frontmatter's bindings.
|
|
138
|
+
*
|
|
139
|
+
* The body is deliberately NOT at module scope: it runs as a synchronous
|
|
140
|
+
* function body at runtime (compile.ts wraps it the same way), so modelling it
|
|
141
|
+
* as one here is what makes top-level `await` / `return` the TS errors they
|
|
142
|
+
* should be. Emitting the body at module scope (the previous shape) silently
|
|
143
|
+
* made them legal in-editor while diverging from runtime semantics. */
|
|
136
144
|
function buildServerTsx(regions: ScannedRegions): VirtualFile {
|
|
137
145
|
const mappings: VirtualMapping[] = []
|
|
138
|
-
const
|
|
146
|
+
const fm = regions.frontmatter?.content ?? ''
|
|
147
|
+
const fmOffset = regions.frontmatter?.contentOffset ?? 0
|
|
148
|
+
const userCode = fm + regions.template.content
|
|
139
149
|
let code =
|
|
140
150
|
injectImports(TEMPLATE_GLOBALS, '@statorjs/stator/template', userCode) +
|
|
141
151
|
AMBIENT_TYPE_IMPORTS +
|
|
142
152
|
STATOR_AMBIENT
|
|
143
153
|
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
code.length,
|
|
149
|
-
regions.frontmatter.content.length,
|
|
150
|
-
)
|
|
151
|
-
code += `${regions.frontmatter.content}\n`
|
|
154
|
+
const { hoisted, body } = splitFrontmatter(fm, fmOffset)
|
|
155
|
+
for (const seg of hoisted) {
|
|
156
|
+
push(mappings, seg.sourceOffset, code.length, seg.text.length)
|
|
157
|
+
code += `${seg.text}\n`
|
|
152
158
|
}
|
|
153
159
|
|
|
154
160
|
// A leading <!doctype> is static HTML, not JSX — drop it from the shell (no
|
|
@@ -162,17 +168,18 @@ function buildServerTsx(regions: ScannedRegions): VirtualFile {
|
|
|
162
168
|
}
|
|
163
169
|
|
|
164
170
|
// `export default function` gives importers a default export (`import X from
|
|
165
|
-
// './x.stator'`) and
|
|
166
|
-
//
|
|
167
|
-
//
|
|
168
|
-
//
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
const
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
171
|
+
// './x.stator'`) and a scope that closes over the frontmatter body. The param
|
|
172
|
+
// is typed from `Stator.props<P>()` (same extraction as the .d.ts generator),
|
|
173
|
+
// so `<Component bad={...}/>` in OTHER .stator files is checked in-editor.
|
|
174
|
+
// Named prop types resolve because their type/interface decls hoisted above.
|
|
175
|
+
const propsT = componentPropsType(extractFrontmatterTypes(fm).propsType, regions.template.content)
|
|
176
|
+
code += `export default function (_props: ${propsT}) {\n`
|
|
177
|
+
for (const seg of body) {
|
|
178
|
+
code += ' '
|
|
179
|
+
push(mappings, seg.sourceOffset, code.length, seg.text.length)
|
|
180
|
+
code += `${seg.text}\n`
|
|
181
|
+
}
|
|
182
|
+
code += ' return (<>'
|
|
176
183
|
push(mappings, tplOffset, code.length, tpl.length)
|
|
177
184
|
code += tpl
|
|
178
185
|
code += '</>);\n}\n'
|
|
@@ -180,6 +187,41 @@ function buildServerTsx(regions: ScannedRegions): VirtualFile {
|
|
|
180
187
|
return { lang: 'tsx', code, mappings }
|
|
181
188
|
}
|
|
182
189
|
|
|
190
|
+
interface FmSegment {
|
|
191
|
+
sourceOffset: number
|
|
192
|
+
text: string
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Split frontmatter into hoisted declarations (import/type/interface — must be
|
|
197
|
+
* module scope) and body statements (everything else — the render function).
|
|
198
|
+
* Mirrors the runtime classification in `compile.ts` `processFrontmatter`, so
|
|
199
|
+
* the editor models the same scoping. Each segment keeps its exact source range
|
|
200
|
+
* (`getStart`..`getText`) so the mapping stays a verbatim 1:1 run.
|
|
201
|
+
*/
|
|
202
|
+
function splitFrontmatter(
|
|
203
|
+
fm: string,
|
|
204
|
+
fmOffset: number,
|
|
205
|
+
): { hoisted: FmSegment[]; body: FmSegment[] } {
|
|
206
|
+
const hoisted: FmSegment[] = []
|
|
207
|
+
const body: FmSegment[] = []
|
|
208
|
+
if (!fm.trim()) return { hoisted, body }
|
|
209
|
+
const sf = ts.createSourceFile('fm.ts', fm, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS)
|
|
210
|
+
for (const stmt of sf.statements) {
|
|
211
|
+
const seg: FmSegment = { sourceOffset: fmOffset + stmt.getStart(sf), text: stmt.getText(sf) }
|
|
212
|
+
if (
|
|
213
|
+
ts.isImportDeclaration(stmt) ||
|
|
214
|
+
ts.isTypeAliasDeclaration(stmt) ||
|
|
215
|
+
ts.isInterfaceDeclaration(stmt)
|
|
216
|
+
) {
|
|
217
|
+
hoisted.push(seg)
|
|
218
|
+
} else {
|
|
219
|
+
body.push(seg)
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
return { hoisted, body }
|
|
223
|
+
}
|
|
224
|
+
|
|
183
225
|
/** Client component: the `<script>` is the module. Emit it as TS so the class,
|
|
184
226
|
* `use()`/`machine()`, and imports get full intelligence. (Template-member
|
|
185
227
|
* resolution — `bind:text={theme.label}` → the class field — is a later phase.) */
|
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,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
|
-
|
|
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
|
-
|
|
154
|
-
|
|
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
|
+
}
|
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
|
+
}
|
|
@@ -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,
|
|
@@ -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
|
+
}
|