svelte-realtime 0.5.0-next.21 → 0.5.0-next.22

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
@@ -1815,6 +1815,8 @@ For custom lock implementations, the option is forwarded as the third argument:
1815
1815
 
1816
1816
  `live.push({ userId }, event, data, options?)` sends a server-initiated request to a connected user and awaits the reply. Routes through a per-instance userId -> WebSocket registry maintained by a small pair of hooks.
1817
1817
 
1818
+ > **Trust model:** `live.push` and `live.notify` are **server-trust primitives**. The `userId` you pass is whatever you pass; the framework does NOT check that the calling context is allowed to address that user. If your call site interpolates a wire-supplied value, read [Trust model: target.userId is whatever the caller passes](#trust-model-targetuserid-is-whatever-the-caller-passes) below before shipping.
1819
+
1818
1820
  ```js
1819
1821
  // hooks.ws.js - wire the registry once
1820
1822
  import { pushHooks } from 'svelte-realtime/server';
@@ -1888,6 +1890,60 @@ Multi-device users see most-recent-connection-wins routing within each instance,
1888
1890
 
1889
1891
  `onPush(event, handler)` multiplexes multiple events over the adapter's single `onRequest` channel. Returning a value sends it as the reply; throwing rejects the server-side promise. Returns an unsubscribe function.
1890
1892
 
1893
+ ### Trust model: target.userId is whatever the caller passes
1894
+
1895
+ `live.push` and `live.notify` are **server-trust primitives**. Calling `live.push({ userId: someId }, event, data)` delivers `event` to whichever connection is registered under `someId`, full stop. The framework does NOT check that the server context invoking the push is allowed to address that user. It cannot - your authorization model lives outside the framework.
1896
+
1897
+ This matters most when a message handler interpolates a wire-supplied target:
1898
+
1899
+ ```js
1900
+ // BUG: `msg.to` is client-controlled. Any authenticated user can push
1901
+ // arbitrary events to any other user - including admins, including
1902
+ // users in other tenants.
1903
+ export const dm = live(async (ctx, msg) => {
1904
+ await live.notify({ userId: msg.to }, 'message', {
1905
+ from: ctx.user.id,
1906
+ text: msg.text
1907
+ });
1908
+ });
1909
+ ```
1910
+
1911
+ The exploit shape is one line of client code: `dm.invoke({ to: 'admin-1', text: 'fake admin notification' })`. The fix is a tiny ownership check at the handler boundary - keep it generic and reuse it everywhere the handler routes to a `userId` from the wire:
1912
+
1913
+ ```js
1914
+ import { live, RpcError } from 'svelte-realtime/server';
1915
+
1916
+ function mustOwnUser(ctx, targetUserId) {
1917
+ if (ctx.user?.id === targetUserId) return; // self-targeted (allowed)
1918
+ if (ctx.user?.role === 'admin') return; // admins can address anyone
1919
+ if (sameTenant(ctx.user, targetUserId)) return; // tenant peers
1920
+ throw new RpcError('FORBIDDEN', 'You cannot push to that user');
1921
+ }
1922
+
1923
+ export const dm = live(async (ctx, msg) => {
1924
+ mustOwnUser(ctx, msg.to); // <-- decision lives here, not in live.push
1925
+ await live.notify({ userId: msg.to }, 'message', {
1926
+ from: ctx.user.id,
1927
+ text: msg.text
1928
+ });
1929
+ });
1930
+ ```
1931
+
1932
+ The rule of thumb: every `userId` you hand to `live.push` / `live.notify` must come from a value the **server** trusts. Safe sources:
1933
+
1934
+ - `ctx.user.id` - you put it there in `upgrade()`; the framework guarantees provenance.
1935
+ - A database row your server just looked up (the DB is server-trusted).
1936
+ - A cron job's iteration target (cron payloads are server-authored).
1937
+ - A webhook payload **after** you've verified the source and confirmed it's allowed to address that userId. The userId field of an unverified third-party webhook is just another wire value.
1938
+
1939
+ Unsafe sources without an ownership check:
1940
+
1941
+ - `msg.targetUserId`, `payload.to`, `args.recipient` - any field that arrived on the WebSocket wire.
1942
+ - `searchParams.get('user')` on a webhook endpoint - same shape, different transport.
1943
+ - A row id chosen by a client even if you fetched the row server-side - the row is trusted, but the choice of WHICH row to fetch was not.
1944
+
1945
+ The same contract applies to any future push-target shape (`{ group, role, tenant }`, etc.): the framework treats the target as an instruction, not as an authorization claim. Authorization is your handler's job; the framework is the delivery primitive.
1946
+
1891
1947
  ### Fire-and-forget: `live.notify`
1892
1948
 
1893
1949
  For server-initiated events where you don't need a reply - progress notifications, "upload complete" pings, "new message available" hints, cron-driven price ticks fanned out to many users - use `live.notify(target, event, data)` instead of `live.push`:
package/cli.js CHANGED
@@ -1,13 +1,72 @@
1
1
  #!/usr/bin/env node
2
2
  // @ts-check
3
- import { execSync } from 'child_process';
3
+ import { execFileSync } from 'child_process';
4
4
  import { writeFileSync, existsSync, mkdirSync } from 'fs';
5
5
  import { resolve, join } from 'path';
6
6
  import * as p from '@clack/prompts';
7
7
  import { detectAgent, parseArgs, VALID_NAME_RE } from './cli-utils.js';
8
8
 
9
+ const IS_WIN = process.platform === 'win32';
10
+
11
+ /**
12
+ * Resolve a binary name to the OS-specific form. npm / pnpm / yarn / bun / npx
13
+ * ship as `.cmd` shims on Windows; Node's execFileSync (no shell) refuses to
14
+ * launch them without the extension. Native binaries (git) work as-is on
15
+ * every platform because Node resolves PATHEXT for them.
16
+ * @param {string} name
17
+ */
18
+ function bin(name) {
19
+ return IS_WIN && name !== 'git' ? `${name}.cmd` : name;
20
+ }
21
+
9
22
  const DEMO_REPO = 'https://github.com/lanteanio/svelte-realtime-demo.git';
10
23
 
24
+ // The scaffolded upgrade() hook is intentionally permissive so the first
25
+ // `npm run dev` works without any identity wiring: every connection gets a
26
+ // fresh UUID and accepts. The hook also fires a console.warn on every
27
+ // accepted connection until the developer deletes the SCAFFOLD_PLACEHOLDER
28
+ // line. Deletion is the explicit "I have replaced this with real auth, stop
29
+ // nagging me" action. Without that, the warning floods stderr/log
30
+ // aggregators - unmissable in dev, impossible to ignore in prod.
31
+ const UPGRADE_HOOK_SCAFFOLD = `// SECURITY: this scaffolded upgrade() hook accepts every WebSocket
32
+ // connection and gives it a random UUID identity. It exists so the
33
+ // scaffold works out of the box. Before deploying anything that
34
+ // touches a real session store, real users, or anything else worth
35
+ // protecting, replace this hook with one of:
36
+ //
37
+ // 1. Cookie session - parse req.getHeader('cookie'), look up the
38
+ // session id in your store, return { id: session.userId } or
39
+ // false if missing/expired.
40
+ //
41
+ // 2. Bearer token - parse req.getHeader('authorization'), validate
42
+ // the JWT or opaque token, return { id: claims.sub } or false.
43
+ //
44
+ // 3. Signed query token - parse the upgrade URL's query string,
45
+ // verify a server-issued HMAC, return { id: claims.userId } or
46
+ // false. Useful when the client cannot send custom headers.
47
+ //
48
+ // Returning false from upgrade() rejects the connection. The object
49
+ // you return becomes ctx.user on every message and live() call.
50
+ //
51
+ // Delete the SCAFFOLD_PLACEHOLDER line below to silence the runtime
52
+ // warning once your auth is in place.
53
+ import { message } from 'svelte-realtime/server';
54
+ export { message };
55
+
56
+ const SCAFFOLD_PLACEHOLDER = true;
57
+
58
+ export function upgrade() {
59
+ \tif (SCAFFOLD_PLACEHOLDER) {
60
+ \t\tconsole.warn(
61
+ \t\t\t'[svelte-realtime] upgrade() is the scaffold default. ' +
62
+ \t\t\t\t'Every connection gets a random UUID identity. ' +
63
+ \t\t\t\t'Edit src/hooks.ws.ts before deploying - see the SECURITY block at the top of the file.'
64
+ \t\t);
65
+ \t}
66
+ \treturn { id: crypto.randomUUID() };
67
+ }
68
+ `;
69
+
11
70
  const parsed = parseArgs(process.argv.slice(2), {
12
71
  dirExists: (name) => existsSync(resolve(process.cwd(), name))
13
72
  });
@@ -84,23 +143,23 @@ const agent = detectAgent(process.env.npm_config_user_agent);
84
143
 
85
144
  if (template === 'demo') {
86
145
  p.log.step('Cloning demo repository');
87
- run(`git clone ${DEMO_REPO} "${name}"`);
146
+ run('git', ['clone', DEMO_REPO, name]);
88
147
 
89
148
  p.log.step('Installing dependencies');
90
- run(`${agent} install`, dest);
149
+ run(bin(agent), ['install'], dest);
91
150
 
92
151
  p.outro(`Done. cd ${name} && ${agent} run dev`);
93
152
  process.exit(0);
94
153
  }
95
154
 
96
155
  p.log.step('Creating SvelteKit project');
97
- run(`npx -y sv create "${name}" --template minimal --types ts --no-add-ons --no-install`);
156
+ run(bin('npx'), ['-y', 'sv', 'create', name, '--template', 'minimal', '--types', 'ts', '--no-add-ons', '--no-install']);
98
157
 
99
158
  p.log.step('Installing dependencies');
100
159
  const add = agent === 'npm' ? 'install' : 'add';
101
- run(`${agent} ${add} svelte-adapter-uws svelte-realtime`, dest);
102
- run(`${agent} ${add} uNetworking/uWebSockets.js#v20.60.0`, dest);
103
- run(`${agent} ${add} -D ws`, dest);
160
+ run(bin(agent), [add, 'svelte-adapter-uws', 'svelte-realtime'], dest);
161
+ run(bin(agent), [add, 'uNetworking/uWebSockets.js#v20.60.0'], dest);
162
+ run(bin(agent), [add, '-D', 'ws'], dest);
104
163
 
105
164
  p.log.step('Configuring svelte-realtime');
106
165
 
@@ -132,23 +191,7 @@ export default defineConfig({
132
191
  `
133
192
  );
134
193
 
135
- writeFileSync(
136
- join(dest, 'src', 'hooks.ws.ts'),
137
- `// SECURITY: this scaffolded upgrade() hook authenticates every connection
138
- // with a random UUID - nobody is rejected and nobody is identified. It exists
139
- // to make the scaffold work out of the box. Before deploying anything that
140
- // touches a real session store, real users, or anything else worth protecting,
141
- // replace this with a real authentication step that validates a session
142
- // cookie / bearer token / signed handshake against your identity provider.
143
- // Returning false from upgrade() rejects the connection.
144
- import { message } from 'svelte-realtime/server';
145
- export { message };
146
-
147
- export function upgrade() {
148
- \treturn { id: crypto.randomUUID() };
149
- }
150
- `
151
- );
194
+ writeFileSync(join(dest, 'src', 'hooks.ws.ts'), UPGRADE_HOOK_SCAFFOLD);
152
195
 
153
196
  if (template === 'example') {
154
197
  mkdirSync(join(dest, 'src', 'live'), { recursive: true });
@@ -196,16 +239,46 @@ p.outro(`Done. cd ${name} && ${agent} run dev`);
196
239
 
197
240
  // ---------------------------------------------------------------------------
198
241
 
199
- /** @param {string} cmd @param {string} [cwd] */
200
- function run(cmd, cwd) {
242
+ /**
243
+ * Run an external binary. Args are passed as an explicit array so they reach
244
+ * the OS exec call without shell tokenization - no template-string injection
245
+ * possible from this entrypoint regardless of how the caller assembles args.
246
+ *
247
+ * On Windows, package-manager shims (npm.cmd / pnpm.cmd / yarn.cmd / bun.cmd
248
+ * / npx.cmd) must run under a shell because Node >=22 EINVALs `.cmd` files
249
+ * under `shell: false`. The `shell: IS_WIN` toggle is scoped per-call to that
250
+ * platform-specific need; on Linux / Mac the no-shell path is the default.
251
+ * Even on Windows, the shell:true escaping caveat (DEP0190) is bounded here
252
+ * because every arg fed to run() in this file is either a hardcoded literal,
253
+ * the cli-utils VALID_NAME_RE-validated project name, or the hardcoded demo
254
+ * repo URL - none of which carry shell metacharacters. Adding an arg from an
255
+ * unvalidated source reintroduces the shell-injection class this refactor
256
+ * was designed to remove; new args must keep that invariant.
257
+ *
258
+ * @param {string} file
259
+ * @param {string[]} args
260
+ * @param {string} [cwd]
261
+ */
262
+ function run(file, args, cwd) {
263
+ // Strip NODE_ENV so the scaffold runs npm/pnpm install as if from a clean
264
+ // shell - if the user invoked `npx svelte-realtime` inside an env where
265
+ // NODE_ENV=production was set, the package manager would skip devDeps and
266
+ // the resulting project would be broken. Use `delete` rather than spread +
267
+ // undefined: both have the same effect on Node 22+ (Node drops undefined
268
+ // env entries) but `delete` is the unambiguous form that does not depend
269
+ // on Node-internal handling of undefined env values.
270
+ const env = { ...process.env };
271
+ delete env.NODE_ENV;
201
272
  try {
202
- execSync(cmd, {
273
+ execFileSync(file, args, {
203
274
  cwd,
204
275
  stdio: 'inherit',
205
- env: { ...process.env, NODE_ENV: undefined }
276
+ env,
277
+ shell: IS_WIN
206
278
  });
207
279
  } catch (e) {
208
- p.cancel(`Command failed: ${cmd}\n${/** @type {any} */ (e).stderr || /** @type {any} */ (e).message}`);
280
+ const line = `${file} ${args.join(' ')}`;
281
+ p.cancel(`Command failed: ${line}\n${/** @type {any} */ (e).stderr || /** @type {any} */ (e).message}`);
209
282
  process.exit(1);
210
283
  }
211
284
  }
package/client.js CHANGED
@@ -4,6 +4,7 @@ import { writable, readable } from 'svelte/store';
4
4
  import { assert } from './shared/assert.js';
5
5
  export { assert, getAssertionCounters, _resetAssertCounters } from './shared/assert.js';
6
6
  import { mergeKeyField, rebuildIndex } from './shared/merge.js';
7
+ import { sanitizeRowData } from './shared/safe-assign.js';
7
8
  // Namespace import lets .rune() access fromStore (Svelte 5 only) without
8
9
  // breaking the module under Svelte 4 - missing exports become undefined,
9
10
  // not module-load errors.
@@ -83,7 +84,10 @@ export class RpcError extends Error {
83
84
  }
84
85
  }
85
86
 
86
- /** Incrementing counter for short correlation IDs, prefixed to avoid cross-tab collision */
87
+ // Incrementing counter for short correlation IDs, prefixed to avoid cross-tab
88
+ // collision. Math.random is the right primitive: this prefix is response-routing
89
+ // bookkeeping, not a session token or any value that crosses a trust boundary.
90
+ // Not security-relevant; collision-avoidance only.
87
91
  const _idPrefix = Math.random().toString(36).slice(2, 6);
88
92
  let idCounter = 0;
89
93
 
@@ -1938,7 +1942,17 @@ function _createStream(path, options, dynamicArgs, initialSchemaVersion) {
1938
1942
  * true in all other paths.
1939
1943
  */
1940
1944
  function _applyMergeFn(value, index, envelope, optimisticKeys) {
1941
- const { event, data } = envelope;
1945
+ const { event } = envelope;
1946
+ // Defense-in-depth: strip prototype-pollution keys (`__proto__`,
1947
+ // `constructor`, `prototype`) from envelope data at ingress for
1948
+ // keyed merge strategies. The framework does not currently spread
1949
+ // or `Object.assign` stored items, so the live exploit surface is
1950
+ // host-app code that later iterates the array. The sanitizer is a
1951
+ // no-op (zero allocation) when the danger keys are absent, which
1952
+ // is every legitimate envelope.
1953
+ const data = (merge === 'crud' || merge === 'presence' || merge === 'cursor')
1954
+ ? sanitizeRowData(envelope.data)
1955
+ : envelope.data;
1942
1956
 
1943
1957
  if (event === 'refreshed') {
1944
1958
  value = data;
@@ -2606,6 +2620,10 @@ function _createStream(path, options, dynamicArgs, initialSchemaVersion) {
2606
2620
  _statusStore.set('reconnecting');
2607
2621
  if (_reconnectTimer) clearTimeout(_reconnectTimer);
2608
2622
  let delay;
2623
+ // Reconnect jitter: spread a fleet's reconnect attempts across the
2624
+ // window so a server restart does not get a thundering-herd retry
2625
+ // spike. Math.random is the right primitive here - jitter does not
2626
+ // need crypto-quality entropy. Not security-relevant.
2609
2627
  if (_reconnectAttempts < 2) {
2610
2628
  delay = 20 + Math.floor(Math.random() * 80);
2611
2629
  } else {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "svelte-realtime",
3
- "version": "0.5.0-next.21",
3
+ "version": "0.5.0-next.22",
4
4
  "publishConfig": {
5
5
  "tag": "next"
6
6
  },
package/server.d.ts CHANGED
@@ -455,6 +455,21 @@ export interface HandleRpcOptions {
455
455
  * Use for error reporting (Sentry, logging, etc.).
456
456
  */
457
457
  onError?(path: string, error: unknown, ctx: LiveContext<any>): void;
458
+
459
+ /**
460
+ * Maximum nesting depth allowed in an inbound RPC envelope. Envelopes
461
+ * whose parsed JSON has any descendant deeper than this are dropped at
462
+ * ingress (the same path as malformed JSON). Defense in depth against
463
+ * downstream handlers / instrumentation that recursively walk the
464
+ * parsed object and would stack-overflow on pathological depth.
465
+ *
466
+ * The adapter's `maxPayloadLength` (default 1 MB) is the primary cap
467
+ * - it bounds the bytes `JSON.parse` ever sees. This is a second
468
+ * line of defense for the post-parse shape.
469
+ *
470
+ * @default 64
471
+ */
472
+ maxEnvelopeDepth?: number;
458
473
  }
459
474
 
460
475
  /**
package/server.js CHANGED
@@ -1,11 +1,54 @@
1
1
  // @ts-check
2
2
  import { assert, wireAssertionMetrics } from './shared/assert.js';
3
+ import { safeAssign as _safeAssignSnapshot } from './shared/safe-assign.js';
3
4
  export { assert, getAssertionCounters, _resetAssertCounters } from './shared/assert.js';
4
5
 
5
6
  const textDecoder = new TextDecoder();
6
7
  const _validPathRe = /^[a-zA-Z0-9_-]+(?:\/[a-zA-Z0-9_-]+)+$/;
7
8
  const _validSegmentRe = /^[a-zA-Z0-9_]+$/;
8
9
 
10
+ /**
11
+ * Max accepted length for a userId that flows into a topic name via
12
+ * `__signal:${userId}` / `__push:${userId}` and similar server-built
13
+ * system topics. 256 chars is generous for any realistic identifier
14
+ * (UUIDs, opaque session tokens, prefixed-by-tenant ids) without
15
+ * bloating log lines or stressing the adapter's wire-topic budget.
16
+ */
17
+ const _MAX_USER_ID_LENGTH = 256;
18
+
19
+ /**
20
+ * Validate that a userId is safe to interpolate into a system topic
21
+ * name. Returns `null` if valid, otherwise a short error reason string
22
+ * suitable for embedding in a thrown LiveError / Error message.
23
+ *
24
+ * Server-side helpers that build `__signal:${userId}` / `__push:${userId}`
25
+ * topic names from caller-supplied identifiers go through this gate so
26
+ * malformed identifiers (control bytes, CR/LF, NUL, quotes, backslash,
27
+ * empty, non-string, oversized) cannot poison the topic namespace,
28
+ * corrupt log lines, or escape the system-topic prefix into the
29
+ * user-topic space. Non-ASCII bytes are allowed for parity with the
30
+ * adapter's `allowNonAsciiTopics` opt-in; the server-side builder
31
+ * trusts identifier shapes set by upgrade hooks.
32
+ *
33
+ * @param {unknown} userId
34
+ * @returns {string | null}
35
+ */
36
+ function _validUserIdReason(userId) {
37
+ if (typeof userId !== 'string') return 'userId must be a string (got ' + (typeof userId) + ')';
38
+ if (userId.length === 0) return 'userId must be non-empty';
39
+ if (userId.length > _MAX_USER_ID_LENGTH) return 'userId exceeds maximum length ' + _MAX_USER_ID_LENGTH + ' (got ' + userId.length + ')';
40
+ for (let i = 0; i < userId.length; i++) {
41
+ const c = userId.charCodeAt(i);
42
+ // Reject ASCII C0 controls (0x00-0x1F), DEL (0x7F), and the two
43
+ // characters the adapter's wire-topic validator forbids:
44
+ // 0x22 (double-quote), 0x5C (backslash).
45
+ if (c < 0x20 || c === 0x7F || c === 0x22 || c === 0x5C) {
46
+ return 'userId contains invalid character at index ' + i + ' (charCode ' + c + ')';
47
+ }
48
+ }
49
+ return null;
50
+ }
51
+
9
52
  // - Bounded-by-default capacity caps (server side) -------------------------
10
53
  // Every per-process Map / Set with caller-driven growth is bounded. Numbers
11
54
  // are deliberately generous - far above any healthy single-instance workload
@@ -1182,7 +1225,11 @@ function _getCtxHelpers(platform) {
1182
1225
  publish,
1183
1226
  throttle: (topic, event, data, ms) => _throttlePublish(platform, topic, event, data, ms),
1184
1227
  debounce: (topic, event, data, ms) => _debouncePublish(platform, topic, event, data, ms),
1185
- signal: (userId, event, data) => platform.publish('__signal:' + userId, event, data),
1228
+ signal: (userId, event, data) => {
1229
+ const reason = _validUserIdReason(userId);
1230
+ if (reason !== null) throw new LiveError('INVALID_USER_ID', 'ctx.signal: ' + reason);
1231
+ return platform.publish('__signal:' + userId, event, data);
1232
+ },
1186
1233
  batch: (messages) => platform.batch ? platform.batch(messages) : messages.forEach((m) => publish(m.topic, m.event, m.data, m.options)),
1187
1234
  shed: (className) => _shouldShed(platform, className)
1188
1235
  };
@@ -1960,6 +2007,36 @@ function _consumeRateLimitBucket(bucketKey, points, windowMs) {
1960
2007
 
1961
2008
  /**
1962
2009
  * Declarative per-function rate limiting.
2010
+ * Marker wrapper that declares an RPC is intentionally public (no
2011
+ * `_guard` required). Returns the inner handler unchanged at runtime;
2012
+ * the vite codegen detects `live.public(...)` in source and
2013
+ * suppresses the build-time "no _guard" warning for that module.
2014
+ *
2015
+ * Use per-export to flag handlers that genuinely accept any
2016
+ * authenticated client (e.g. server-time, public health probes,
2017
+ * unauthenticated read-only endpoints). For module-wide public RPCs,
2018
+ * the `// realtime-allow-public` source comment is the lighter
2019
+ * alternative.
2020
+ *
2021
+ * @param {Function} fn - Handler function (ctx, ...args)
2022
+ * @returns {Function}
2023
+ *
2024
+ * @example
2025
+ * ```js
2026
+ * // src/lib/realtime/health.js
2027
+ * import { live } from 'svelte-realtime';
2028
+ *
2029
+ * export const serverTime = live.public(async () => ({ now: Date.now() }));
2030
+ * ```
2031
+ */
2032
+ live.public = function publicMarker(fn) {
2033
+ if (typeof fn !== 'function') {
2034
+ throw new Error('[svelte-realtime] live.public(fn) requires a handler function');
2035
+ }
2036
+ return fn;
2037
+ };
2038
+
2039
+ /**
1963
2040
  * Wraps a live() function with a sliding window rate limiter.
1964
2041
  *
1965
2042
  * @param {{ points: number, window: number, key?: (ctx: any) => string }} config
@@ -2817,8 +2894,9 @@ export const pushHooks = {
2817
2894
  }
2818
2895
  const userId = _getPushIdentify()(ws);
2819
2896
  if (userId == null || userId === '') return;
2820
- if (typeof userId !== 'string') {
2821
- throw new Error('[svelte-realtime] pushHooks.open: identify(ws) must return a string, null, or undefined (got ' + typeof userId + ')');
2897
+ const reason = _validUserIdReason(userId);
2898
+ if (reason !== null) {
2899
+ throw new Error('[svelte-realtime] pushHooks.open: ' + reason + '. identify(ws) must return a non-empty userId string that is safe to embed in a topic name (no control chars / CR / LF / NUL / quotes / backslash, max ' + _MAX_USER_ID_LENGTH + ' chars), or null / undefined for anonymous connections.');
2822
2900
  }
2823
2901
  if (!_pushRegistry.has(userId) && _pushRegistry.size >= _maxPushRegistry) {
2824
2902
  if (!_pushRegistryWarnFired) {
@@ -3735,22 +3813,6 @@ export const combineMerge = (...buckets) => {
3735
3813
  return merged;
3736
3814
  };
3737
3815
 
3738
- /**
3739
- * Copy own enumerable properties from `src` into `dst`, skipping
3740
- * `__proto__` and `constructor`. Used to hydrate aggregate state from
3741
- * an external snapshot (Redis cache, JSON payload, etc.) without giving
3742
- * a hostile snapshot a path to set Object.prototype properties via
3743
- * `Object.assign(target, JSON.parse('{"__proto__":{"polluted":1}}'))`.
3744
- *
3745
- * @param {object} dst
3746
- * @param {object} src
3747
- */
3748
- function _safeAssignSnapshot(dst, src) {
3749
- for (const k of Object.keys(src)) {
3750
- if (k === '__proto__' || k === 'constructor' || k === 'prototype') continue;
3751
- dst[k] = /** @type {any} */ (src)[k];
3752
- }
3753
- }
3754
3816
 
3755
3817
  /**
3756
3818
  * Create a real-time incremental aggregation over a source topic.
@@ -6042,13 +6104,54 @@ export class LiveError extends Error {
6042
6104
  }
6043
6105
  }
6044
6106
 
6107
+ /**
6108
+ * Default maximum nesting depth allowed in an inbound RPC envelope.
6109
+ * Anything deeper than this is rejected at ingress. 64 is well past any
6110
+ * realistic application shape (typical envelopes nest one or two levels
6111
+ * deep for `{args: [...]}` and an args payload) but well short of where
6112
+ * any host-app recursive walker would stack-overflow. Override per-call
6113
+ * via `handleRpc(ws, data, platform, { maxEnvelopeDepth })`.
6114
+ */
6115
+ const _DEFAULT_MAX_ENVELOPE_DEPTH = 64;
6116
+
6117
+ /**
6118
+ * Iterative depth walk over a parsed JSON value. Returns true when the
6119
+ * value (or any descendant) sits at a nesting depth greater than `max`.
6120
+ * Stack-based so a pathological depth cannot itself stack-overflow the
6121
+ * checker. Short-circuits on the first over-depth descendant found.
6122
+ *
6123
+ * @param {unknown} root
6124
+ * @param {number} max
6125
+ */
6126
+ function exceedsEnvelopeDepth(root, max) {
6127
+ if (root === null || typeof root !== 'object') return false;
6128
+ /** @type {Array<{ obj: any, depth: number }>} */
6129
+ const stack = [{ obj: root, depth: 1 }];
6130
+ while (stack.length > 0) {
6131
+ const { obj, depth } = /** @type {{ obj: any, depth: number }} */ (stack.pop());
6132
+ if (depth > max) return true;
6133
+ if (Array.isArray(obj)) {
6134
+ for (let i = 0; i < obj.length; i++) {
6135
+ const v = obj[i];
6136
+ if (v !== null && typeof v === 'object') stack.push({ obj: v, depth: depth + 1 });
6137
+ }
6138
+ } else {
6139
+ for (const k of Object.keys(obj)) {
6140
+ const v = obj[k];
6141
+ if (v !== null && typeof v === 'object') stack.push({ obj: v, depth: depth + 1 });
6142
+ }
6143
+ }
6144
+ }
6145
+ return false;
6146
+ }
6147
+
6045
6148
  /**
6046
6149
  * Check whether a raw WebSocket message is an RPC request and handle it.
6047
6150
  *
6048
6151
  * @param {any} ws
6049
6152
  * @param {ArrayBuffer} data - Raw message data from the adapter message hook
6050
6153
  * @param {import('svelte-adapter-uws').Platform} platform
6051
- * @param {{ beforeExecute?: (ws: any, rpcPath: string, args: any[]) => Promise<void> | void, onError?: (path: string, error: unknown, ctx: any) => void }} [options]
6154
+ * @param {{ beforeExecute?: (ws: any, rpcPath: string, args: any[]) => Promise<void> | void, onError?: (path: string, error: unknown, ctx: any) => void, maxEnvelopeDepth?: number }} [options]
6052
6155
  * @returns {boolean} true if the message was an RPC request
6053
6156
  */
6054
6157
  export function handleRpc(ws, data, platform, options) {
@@ -6107,6 +6210,16 @@ export function handleRpc(ws, data, platform, options) {
6107
6210
  return false;
6108
6211
  }
6109
6212
 
6213
+ // Post-parse depth cap. The adapter's `maxPayloadLength` (default 1 MB)
6214
+ // already bounds the bytes JSON.parse ever sees, so this is defense
6215
+ // in depth against downstream handlers / instrumentation that recursively
6216
+ // walk the parsed object and could stack-overflow on pathological depth.
6217
+ // Iterative stack so the check itself never overflows.
6218
+ const maxEnvelopeDepth = (options && options.maxEnvelopeDepth) || _DEFAULT_MAX_ENVELOPE_DEPTH;
6219
+ if (exceedsEnvelopeDepth(msg, maxEnvelopeDepth)) {
6220
+ return false;
6221
+ }
6222
+
6110
6223
  // Batch request: {"batch": [...]}
6111
6224
  if (Array.isArray(msg.batch)) {
6112
6225
  _executeBatch(ws, msg, platform, options);
@@ -6264,28 +6377,35 @@ async function _executeStreamRpc(ws, platform, fn, ctx, args, msg, subscribedRef
6264
6377
  }
6265
6378
  }
6266
6379
 
6267
- // Wire-level subscribe gate: ask the adapter's `subscribe` /
6268
- // `subscribeBatch` hook chain whether this (ws, topic) pair would be
6269
- // denied at the wire-level subscribe-batch frame. Without this gate,
6380
+ // Wire-level subscribe gate + atomic subscribe. `platform.subscribe`
6381
+ // runs the adapter's `subscribe` / `subscribeBatch` hook chain,
6382
+ // enforces `MAX_SUBSCRIPTIONS_PER_CONNECTION`, and updates the
6383
+ // adapter-side per-connection subscription state (the `subs` Set,
6384
+ // `totalSubscriptions` counter, and the close-hook's
6385
+ // `ctx.subscriptions` parameter). Without going through this path,
6270
6386
  // the loader would run, deliver initial data, and the room's
6271
6387
  // __onSubscribe would publish a 'join' before the adapter's hook
6272
6388
  // fires (which only fires on the client's follow-on subscribe-batch
6273
- // wire frame, AFTER the stream RPC returns). The optional-chain on
6274
- // `platform.checkSubscribe` keeps older adapters working: if the
6275
- // method isn't there, we fall through to the prior behavior and the
6276
- // in-realtime gates (`__streamFilter`, `live.room({ guard })`) remain
6277
- // the only stream-RPC access checks.
6278
- if (typeof platform.checkSubscribe === 'function') {
6279
- // Await the gate so async user hooks (the idiomatic pattern when
6280
- // the gate consults a session store or DB) deny correctly when
6281
- // they return `false`. A sync return is awaited transparently.
6282
- const denial = await platform.checkSubscribe(ws, topic);
6283
- if (denial) {
6284
- return { id, ok: false, code: denial, error: denial === 'UNAUTHENTICATED' ? 'Authentication required' : 'Access denied' };
6285
- }
6286
- }
6287
-
6288
- try { ws.subscribe(topic); } catch { return { id, ok: false, code: 'CONNECTION_CLOSED', error: 'WebSocket closed' }; }
6389
+ // wire frame, AFTER the stream RPC returns), AND the resulting
6390
+ // subscription would be invisible to the adapter's observability
6391
+ // surface (close-hook subscriptions set, per-conn cap). The
6392
+ // optional-chain on `platform.subscribe` keeps older adapters
6393
+ // working: if the method isn't there, we fall back to raw
6394
+ // `ws.subscribe` and only the in-realtime gates (`__streamFilter`,
6395
+ // `live.room({ guard })`) remain the stream-RPC access checks.
6396
+ let _subscribeDenial = null;
6397
+ try {
6398
+ if (typeof platform.subscribe === 'function') {
6399
+ _subscribeDenial = await platform.subscribe(ws, topic);
6400
+ } else {
6401
+ ws.subscribe(topic);
6402
+ }
6403
+ } catch {
6404
+ return { id, ok: false, code: 'CONNECTION_CLOSED', error: 'WebSocket closed' };
6405
+ }
6406
+ if (_subscribeDenial) {
6407
+ return { id, ok: false, code: _subscribeDenial, error: _subscribeDenial === 'UNAUTHENTICATED' ? 'Authentication required' : 'Access denied' };
6408
+ }
6289
6409
  _trackStreamSub(ws, topic, fn);
6290
6410
  subscribedRef.topic = topic;
6291
6411
 
@@ -7604,9 +7724,15 @@ export function enableSignals(ws, options) {
7604
7724
  const idField = options?.idField || 'id';
7605
7725
  const userData = ws.getUserData();
7606
7726
  const userId = userData?.[idField];
7607
- if (userId !== undefined && userId !== null) {
7608
- ws.subscribe('__signal:' + userId);
7609
- }
7727
+ // null / undefined = anonymous connection, silently skip (no signal
7728
+ // subscription wired). This preserves the documented pattern of
7729
+ // calling `enableSignals(ws)` unconditionally in the open hook.
7730
+ if (userId === undefined || userId === null) return;
7731
+ const reason = _validUserIdReason(userId);
7732
+ if (reason !== null) {
7733
+ throw new Error('[svelte-realtime] enableSignals: ' + reason + '. The userData field "' + idField + '" must be a non-empty string safe to embed in a topic name (no control chars / CR / LF / NUL / quotes / backslash, max ' + _MAX_USER_ID_LENGTH + ' chars), or null / undefined for anonymous connections.');
7734
+ }
7735
+ ws.subscribe('__signal:' + userId);
7610
7736
  }
7611
7737
 
7612
7738
  /**
@@ -0,0 +1,121 @@
1
+ // @ts-check
2
+
3
+ /**
4
+ * Defense-in-depth helpers for sanitizing user-supplied object data
5
+ * before it lands in framework-owned reactive state.
6
+ *
7
+ * Why: any property assignment `target.__proto__ = X` mutates the
8
+ * target's [[Prototype]], and `Object.assign(target, src)` with
9
+ * `src.__proto__` present moves the value onto `Object.prototype`,
10
+ * polluting every plain object in the realm. The wire format
11
+ * (`JSON.parse`) does NOT prototype-pollute on its own (it sets
12
+ * `__proto__` as an own property), but any downstream code that does
13
+ * `Object.assign({}, item)` or `for..in` over the items inherits the
14
+ * pollution path.
15
+ *
16
+ * The framework's CRUD / presence / cursor merge paths store
17
+ * user-supplied envelopes verbatim in reactive arrays. Today's code
18
+ * neither spreads nor `Object.assign`s those items - the stored shape
19
+ * goes back out on the wire and into user code. We strip danger keys
20
+ * at envelope ingress so a future refactor that introduces a spread
21
+ * or assign cannot accidentally pollute the host process.
22
+ *
23
+ * @module svelte-realtime/shared/safe-assign
24
+ */
25
+
26
+ /**
27
+ * Keys that mutate `Object.prototype` (or shadow built-ins) when
28
+ * assigned to a plain object. Frozen so a contributor cannot add a
29
+ * "safe" key here later without thinking about the implication.
30
+ */
31
+ export const PROTO_POLLUTION_KEYS = Object.freeze(['__proto__', 'constructor', 'prototype']);
32
+
33
+ /**
34
+ * Copy own enumerable properties from `src` into `dst`, skipping
35
+ * `__proto__`, `constructor`, and `prototype`. Used to hydrate
36
+ * snapshot state from an external source (Redis cache, JSON payload,
37
+ * etc.) without giving the snapshot a path to set Object.prototype
38
+ * properties via `Object.assign`. Returns `dst`.
39
+ *
40
+ * @template T
41
+ * @param {T} dst
42
+ * @param {Record<string, any>} src
43
+ * @returns {T}
44
+ */
45
+ export function safeAssign(dst, src) {
46
+ for (const k of Object.keys(src)) {
47
+ if (k === '__proto__' || k === 'constructor' || k === 'prototype') continue;
48
+ /** @type {any} */ (dst)[k] = /** @type {any} */ (src)[k];
49
+ }
50
+ return dst;
51
+ }
52
+
53
+ /**
54
+ * Return `data` with `__proto__` / `constructor` / `prototype` stripped
55
+ * as own properties, when present. When `data` has none of the danger
56
+ * keys, returns `data` unchanged (no allocation, reference identity
57
+ * preserved). When at least one is present, returns a fresh shallow
58
+ * clone with those keys removed. Date, Map, Set and other non-plain
59
+ * objects pass through unchanged.
60
+ *
61
+ * Arrays are processed element-by-element. The returned array is the
62
+ * same reference when every element is danger-key-free, or a fresh
63
+ * array containing sanitized clones for the elements that needed it.
64
+ *
65
+ * Performance contract: the no-danger-key path is `hasOwnProperty` x3
66
+ * (~30ns) for plain objects, one iteration for arrays. No allocation
67
+ * in the common case. Negligible against the surrounding merge cost.
68
+ *
69
+ * @template T
70
+ * @param {T} data
71
+ * @returns {T}
72
+ */
73
+ export function sanitizeRowData(data) {
74
+ if (!data || typeof data !== 'object') return data;
75
+ if (Array.isArray(data)) {
76
+ let cloned = null;
77
+ for (let i = 0; i < data.length; i++) {
78
+ const sanitized = sanitizeRowData(data[i]);
79
+ if (sanitized !== data[i]) {
80
+ if (cloned === null) cloned = data.slice();
81
+ cloned[i] = sanitized;
82
+ }
83
+ }
84
+ return /** @type {any} */ (cloned ?? data);
85
+ }
86
+ // Non-plain object (Date, Map, Set, etc.) - leave alone.
87
+ if (Object.getPrototypeOf(data) !== Object.prototype && Object.getPrototypeOf(data) !== null) {
88
+ return data;
89
+ }
90
+ const has = Object.prototype.hasOwnProperty;
91
+ if (
92
+ !has.call(data, '__proto__') &&
93
+ !has.call(data, 'constructor') &&
94
+ !has.call(data, 'prototype')
95
+ ) {
96
+ return data;
97
+ }
98
+ const clone = /** @type {any} */ ({});
99
+ for (const k of Object.keys(/** @type {any} */ (data))) {
100
+ if (k === '__proto__' || k === 'constructor' || k === 'prototype') continue;
101
+ clone[k] = /** @type {any} */ (data)[k];
102
+ }
103
+ return clone;
104
+ }
105
+
106
+ /**
107
+ * Throws when the supplied key value (the resolved `data[keyField]`
108
+ * used as a Map / index slot) equals one of the prototype-pollution
109
+ * strings. Map keys themselves are not exploitable - the Map stores
110
+ * `'__proto__'` as a string key without mutating any prototype - but
111
+ * a downstream `obj[mapKey] = value` assignment would, so callers that
112
+ * use this value in BOTH a Map AND a plain-object assignment should
113
+ * gate it through this assert.
114
+ *
115
+ * @param {unknown} keyValue
116
+ */
117
+ export function assertSafeMergeKey(keyValue) {
118
+ if (keyValue === '__proto__' || keyValue === 'constructor' || keyValue === 'prototype') {
119
+ throw new Error(`unsafe merge key "${String(keyValue)}" - prototype-pollution shapes are filtered at envelope ingress`);
120
+ }
121
+ }
package/test.js CHANGED
@@ -1,5 +1,6 @@
1
1
  // @ts-check
2
2
  import { __register, __registerGuard, __registerCron, __registerDerived, __registerEffect, __registerAggregate, __registerRoomActions, handleRpc, LiveError, _clearCron, _activateDerived, close, unsubscribe } from './server.js';
3
+ import { sanitizeRowData } from './shared/safe-assign.js';
3
4
 
4
5
  /**
5
6
  * Build a `ctx`-shaped object suitable for direct unit tests of guards,
@@ -104,10 +105,17 @@ const textEncoder = new TextEncoder();
104
105
  * @returns {any}
105
106
  */
106
107
  function _applyTestMerge(current, envelope, merge, key, opts) {
107
- const { event, data } = envelope;
108
+ const { event } = envelope;
108
109
  const prepend = opts?.prepend || false;
109
110
  const max = opts?.max || 50;
110
111
 
112
+ // Mirror the client's defense-in-depth: strip prototype-pollution
113
+ // keys at envelope ingress for keyed merges so test fixtures match
114
+ // the wire-shape clients actually observe.
115
+ const data = (merge === 'crud' || merge === 'presence' || merge === 'cursor')
116
+ ? sanitizeRowData(envelope.data)
117
+ : envelope.data;
118
+
111
119
  if (merge === 'set') return data;
112
120
 
113
121
  if (merge === 'latest') {
@@ -225,6 +233,10 @@ export function createTestEnv(options) {
225
233
 
226
234
  function _shouldChaosDropPublish() {
227
235
  if (!chaosConfig || chaosConfig.dropRate === 0) return false;
236
+ // Chaos drop check: simulated network loss for test scenarios. chaosRng
237
+ // is a seeded PRNG when the caller configured `chaos: { seed }`; without
238
+ // a seed we fall back to Math.random for the unseeded shape. Test-only
239
+ // code path. Not security-relevant.
228
240
  const r = chaosRng ? chaosRng() : Math.random();
229
241
  if (r < chaosConfig.dropRate) {
230
242
  chaosDropped++;
@@ -271,6 +283,18 @@ export function createTestEnv(options) {
271
283
  subscribers(topic) {
272
284
  return topicSubscribers.get(topic)?.size || 0;
273
285
  },
286
+ // platform.subscribe / platform.checkSubscribe mirrors of the real
287
+ // adapter shape. The test platform has no user subscribe hooks, so
288
+ // these just delegate to ws.subscribe and report null (allow). Tests
289
+ // that need to exercise gate-denial paths can replace this mock
290
+ // with a custom platform.
291
+ async subscribe(ws, topic) {
292
+ try { ws.subscribe(topic); } catch { return 'CONNECTION_CLOSED'; }
293
+ return null;
294
+ },
295
+ async checkSubscribe() {
296
+ return null;
297
+ },
274
298
  topic(t) {
275
299
  return {
276
300
  publish: (event, data) => platform.publish(t, event, data),
package/vite.js CHANGED
@@ -28,6 +28,18 @@ const AGGREGATE_EXPORT_RE = /export\s+const\s+(\w+)\s*=\s*live\.aggregate\s*\(/g
28
28
  // path. Without this, exports declared as `export const x = live.lock(...)`
29
29
  // would not be recognised and the client could not call them.
30
30
  const LOCK_EXPORT_RE = /export\s+const\s+(\w+)\s*=\s*live\.lock\s*\(/g;
31
+ // `live.public(...)` is a runtime no-op wrapper (returns the handler
32
+ // unchanged) whose only job is to declare intent: "this RPC is
33
+ // intentionally public; do not warn about a missing _guard." The
34
+ // codegen treats it identically to `live(...)` for emission and uses
35
+ // the presence of any `live.public` export as a module-level suppression
36
+ // for the "no _guard" build-time warning.
37
+ const PUBLIC_EXPORT_RE = /export\s+const\s+(\w+)\s*=\s*live\.public\s*\(/g;
38
+ // Module-level escape hatch: a `// realtime-allow-public` comment
39
+ // anywhere in the source suppresses the "no _guard" warning for the
40
+ // whole module. Use this when several or all live() exports in a
41
+ // module are intentionally public.
42
+ const PUBLIC_COMMENT_RE = /(?:\/\/|\/\*)\s*realtime-allow-public\b/;
31
43
  const IDEMPOTENT_EXPORT_RE = /export\s+const\s+(\w+)\s*=\s*live\.idempotent\s*\(/g;
32
44
 
33
45
  const _validSegmentReVite = /^[a-zA-Z0-9_]+$/;
@@ -1074,9 +1086,18 @@ function _generateClientStubs(filePath, modulePath, dir) {
1074
1086
 
1075
1087
  // Detect live() exports
1076
1088
  let match;
1089
+ // Track whether any live.public() export is present in the module - used
1090
+ // alongside the `// realtime-allow-public` comment to suppress the
1091
+ // "no _guard" build-time warning.
1092
+ let hasPublicExport = false;
1093
+ PUBLIC_EXPORT_RE.lastIndex = 0;
1094
+ if (PUBLIC_EXPORT_RE.exec(source) !== null) {
1095
+ hasPublicExport = true;
1096
+ }
1097
+ const hasPublicComment = PUBLIC_COMMENT_RE.test(source);
1077
1098
  // live() and the wrappers that pass through unchanged on the client
1078
- // (validated/lock/idempotent/rateLimit) all emit the same __rpc line.
1079
- for (const re of [LIVE_EXPORT_RE, VALIDATED_EXPORT_RE, LOCK_EXPORT_RE, IDEMPOTENT_EXPORT_RE, RATE_LIMIT_EXPORT_RE]) {
1099
+ // (validated/lock/idempotent/rateLimit/public) all emit the same __rpc line.
1100
+ for (const re of [LIVE_EXPORT_RE, VALIDATED_EXPORT_RE, LOCK_EXPORT_RE, IDEMPOTENT_EXPORT_RE, RATE_LIMIT_EXPORT_RE, PUBLIC_EXPORT_RE]) {
1080
1101
  re.lastIndex = 0;
1081
1102
  while ((match = re.exec(source)) !== null) {
1082
1103
  const name = match[1];
@@ -1279,6 +1300,30 @@ function _generateClientStubs(filePath, modulePath, dir) {
1279
1300
  }
1280
1301
  }
1281
1302
 
1303
+ // Build-time nudge: a module with live() exports but no `_guard`
1304
+ // is opting into the framework's default-allow posture (every
1305
+ // authenticated WS can invoke any registered handler). That is the
1306
+ // correct default for "Hello, world" but is a foot-gun for apps that
1307
+ // forget to add an auth gate.
1308
+ //
1309
+ // Suppression:
1310
+ // - export at least one `live.public(...)` (per-export intent, recommended)
1311
+ // - add `// realtime-allow-public` anywhere in the source (module-wide opt-out)
1312
+ // - export `_guard = guard(...)` (the framework auth gate)
1313
+ //
1314
+ // The warning is a soft nudge, not a hard error - runtime semantics
1315
+ // are unchanged.
1316
+ if (exportedNames.size > 0 && !hasGuard && !hasPublicExport && !hasPublicComment) {
1317
+ console.warn(
1318
+ `[svelte-realtime] ${dir}/${modulePath} has live() / live.stream() exports but no _guard. ` +
1319
+ `Every authenticated WS can invoke any handler in this module. ` +
1320
+ `Add 'export const _guard = guard(...)' to gate access, ` +
1321
+ `mark individual handlers as 'live.public(...)' to declare them intentionally open, ` +
1322
+ `or add '// realtime-allow-public' to the file to suppress this warning.\n` +
1323
+ ` See: https://svti.me/guard`
1324
+ );
1325
+ }
1326
+
1282
1327
  const importLine = imports.size > 0
1283
1328
  ? `import { ${[...imports].join(', ')} } from 'svelte-realtime/client';\n`
1284
1329
  : '';