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 +56 -0
- package/cli.js +102 -29
- package/client.js +20 -2
- package/package.json +1 -1
- package/server.d.ts +15 -0
- package/server.js +168 -42
- package/shared/safe-assign.js +121 -0
- package/test.js +25 -1
- package/vite.js +47 -2
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 {
|
|
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(
|
|
146
|
+
run('git', ['clone', DEMO_REPO, name]);
|
|
88
147
|
|
|
89
148
|
p.log.step('Installing dependencies');
|
|
90
|
-
run(
|
|
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(
|
|
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(
|
|
102
|
-
run(
|
|
103
|
-
run(
|
|
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
|
-
/**
|
|
200
|
-
|
|
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
|
-
|
|
273
|
+
execFileSync(file, args, {
|
|
203
274
|
cwd,
|
|
204
275
|
stdio: 'inherit',
|
|
205
|
-
env
|
|
276
|
+
env,
|
|
277
|
+
shell: IS_WIN
|
|
206
278
|
});
|
|
207
279
|
} catch (e) {
|
|
208
|
-
|
|
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
|
-
|
|
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
|
|
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
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) =>
|
|
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
|
-
|
|
2821
|
-
|
|
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
|
|
6268
|
-
// `subscribeBatch` hook chain
|
|
6269
|
-
//
|
|
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)
|
|
6274
|
-
//
|
|
6275
|
-
//
|
|
6276
|
-
//
|
|
6277
|
-
// the
|
|
6278
|
-
|
|
6279
|
-
|
|
6280
|
-
|
|
6281
|
-
|
|
6282
|
-
|
|
6283
|
-
|
|
6284
|
-
|
|
6285
|
-
|
|
6286
|
-
|
|
6287
|
-
|
|
6288
|
-
|
|
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
|
-
|
|
7608
|
-
|
|
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
|
|
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
|
: '';
|