@uniflowed/router 0.0.0-alpha.11 → 0.0.0-alpha.13

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.
@@ -0,0 +1,382 @@
1
+ // @flow
2
+ //
3
+ // Internal to `@uniflowed/router`: what a server action's arguments and its
4
+ // result are allowed to be.
5
+ //
6
+ // A `"use server"` export is a public HTTP endpoint from the moment it exists.
7
+ // The one decision that makes it a feature rather than a remote-code-execution
8
+ // surface is what the bytes on the wire are permitted to become, and this
9
+ // module is that decision, written once and applied on both sides of it.
10
+ //
11
+ // # The boundary
12
+ //
13
+ // **A server action's arguments are plain JSON data and nothing else.**
14
+ //
15
+ // One JSON object, `{"args": [...]}`, at most `MAX_ACTION_BODY_BYTES` of valid
16
+ // UTF-8, holding at most `MAX_ACTION_ARGUMENTS` values, nested at most
17
+ // `MAX_ACTION_DEPTH` deep, with at most `MAX_ACTION_VALUES` values in total.
18
+ // Each of those values is `null`, a boolean, a finite number, a string, an
19
+ // array of them, or a plain object whose keys are ordinary strings. The result
20
+ // travels back under exactly the same grammar, plus `undefined` for an action
21
+ // that returns nothing.
22
+ //
23
+ // Nothing in a payload can name a function, a module, a class, a prototype, a
24
+ // React element, an id or a reference, and nothing in it is revived into an
25
+ // object the sender chose. `decodeActionArguments` produces values a
26
+ // `JSON.parse` already produced; what this adds is the refusal of everything
27
+ // `JSON.parse` would have let through — which is the whole of what a decoder
28
+ // has to get right.
29
+ //
30
+ // # What is deliberately absent, and why
31
+ //
32
+ // * **A reference format.** React's Flight payload can carry a reference to a
33
+ // client module, a promise, or an element, and a decoder that reconstructs
34
+ // those is a decoder that constructs attacker-chosen objects. uf has no such
35
+ // payload (ubugeeei-prod/uf#252) and this grammar is not the place to grow
36
+ // one quietly.
37
+ // * **Class instances, `Map`, `Set`, `Date`, `RegExp`, typed arrays.** Each
38
+ // would need a tag in the payload saying which constructor to call, and a
39
+ // tag naming a constructor is the oracle every deserialisation CVE is made
40
+ // of. An action that wants a date takes an ISO string and parses it, where
41
+ // the parse is the application's and is checked.
42
+ // * **`FormData` and `<form action={fn}>`.** Multipart parsing is its own
43
+ // attack surface with its own bounds, and a form post is a *simple* CORS
44
+ // request — it reaches a server with the visitor's cookies and no preflight.
45
+ // Both are worth having and neither is worth having by accident; see the
46
+ // security note in `./action-endpoint.js`.
47
+ // * **Cycles and shared references.** A value that refers to itself is
48
+ // rejected rather than encoded, because the alternative is a marker in the
49
+ // payload that says "this is the object you saw earlier", which is a
50
+ // reference format by another name.
51
+ //
52
+ // # Why the same module runs on both sides
53
+ //
54
+ // The browser refuses to *send* what the server would refuse to receive, so a
55
+ // value that cannot cross is a mistake at the call site with the argument's
56
+ // position in the message, rather than a 400 with nothing in it. Two
57
+ // implementations of one grammar is how the two come to disagree, and the
58
+ // side that is lenient is always the server.
59
+ //
60
+ // Pure: no imports, no platform APIs beyond `JSON`, so the browser half of
61
+ // `@uniflowed/router` can reach it without reaching anything server-only.
62
+
63
+ /** The request header carrying the action id. */
64
+ export const ACTION_HEADER: string = "uf-action";
65
+
66
+ /**
67
+ * The only content type an action call may be sent with.
68
+ *
69
+ * Not decoration. `application/json` is not one of the three types a form can
70
+ * produce, so a cross-origin `<form>` — which is sent with the visitor's
71
+ * cookies and no preflight — cannot reach the decoder at all. It is the second
72
+ * of the three independent things standing between this endpoint and a CSRF,
73
+ * the others being the `Origin` check and `ACTION_HEADER` itself, which is a
74
+ * header no simple request may carry.
75
+ */
76
+ export const ACTION_CONTENT_TYPE: string = "application/json";
77
+
78
+ /** Largest request body the endpoint will read, in bytes. */
79
+ export const MAX_ACTION_BODY_BYTES: number = 1024 * 1024;
80
+
81
+ /** Most positional arguments an action may be called with. */
82
+ export const MAX_ACTION_ARGUMENTS: number = 16;
83
+
84
+ /** Deepest nesting a payload may have. */
85
+ export const MAX_ACTION_DEPTH: number = 24;
86
+
87
+ /** Most values, of any kind, one payload may hold. */
88
+ export const MAX_ACTION_VALUES: number = 10000;
89
+
90
+ /**
91
+ * Everything that may cross the wire.
92
+ *
93
+ * Recursive on purpose, and closed on purpose: this type is what
94
+ * `ServerActionBoundary` in `../action.js` holds every action's parameters and
95
+ * return value against, so an action that takes a `Map` is a `uf check` error
96
+ * rather than a request that arrives with an empty object in it.
97
+ */
98
+ export type ActionValue =
99
+ | null
100
+ | boolean
101
+ | number
102
+ | string
103
+ | $ReadOnlyArray<ActionValue>
104
+ | { readonly [string]: ActionValue };
105
+
106
+ /**
107
+ * A value that is outside the grammar, and where in the payload it was.
108
+ *
109
+ * Thrown on the browser side, where the path is the argument the caller
110
+ * passed. On the server side it is caught and becomes a `400` with none of
111
+ * this in it: the sender does not get told which part of what they sent was
112
+ * the part that was refused.
113
+ */
114
+ export class ActionValueError extends Error {
115
+ /** Where in the payload the offending value sat, e.g. `argument 2.name`. */
116
+ path: string;
117
+
118
+ constructor(path: string, reason: string) {
119
+ super(`@uniflowed/router: ${path} ${reason}.`);
120
+ this.name = "ActionValueError";
121
+ this.path = path;
122
+ }
123
+ }
124
+
125
+ /**
126
+ * Keys that are never a property in this grammar.
127
+ *
128
+ * `JSON.parse` gives `__proto__` an *own* data property rather than changing
129
+ * the prototype, so a payload carrying one is not itself pollution — it
130
+ * becomes pollution in the first line of application code that spreads or
131
+ * merges it. Refused here, where there is one place to refuse it, rather than
132
+ * left for every action to remember. `uf_pm::detect` draws the same line for
133
+ * manifest JSON.
134
+ */
135
+ const FORBIDDEN_KEYS: $ReadOnlyArray<string> = ["__proto__", "constructor", "prototype"];
136
+
137
+ /**
138
+ * Whether an object is one this grammar carries.
139
+ *
140
+ * The prototype is compared, not `instanceof` and not a duck-type: a class
141
+ * instance, a `Map`, a `Date` and a React element all pass every structural
142
+ * test somebody might reach for, and each of them loses its meaning on the
143
+ * wire. `Object.create(null)` is accepted because it is what a careful caller
144
+ * builds and what this module's own decoder could produce.
145
+ */
146
+ function isPlainObject(value: interface {}): boolean {
147
+ const prototype: mixed = Object.getPrototypeOf(value);
148
+ return prototype === PLAIN_PROTOTYPE || prototype === null;
149
+ }
150
+
151
+ /**
152
+ * The prototype every object literal has, captured rather than named.
153
+ *
154
+ * `Object.prototype` is not on Flow's `Object` statics, and the usual
155
+ * substitute — asking whether the prototype's own prototype is `null` — also
156
+ * accepts `Object.create(Object.create(null))`, which is a different set of
157
+ * values. One `{}` at module scope is the exact answer and costs nothing.
158
+ */
159
+ const PLAIN_PROTOTYPE: mixed = Object.getPrototypeOf({});
160
+
161
+ /** One entry of the explicit walk stack. */
162
+ type Pending = {| readonly value: mixed, readonly path: string, readonly depth: number |};
163
+
164
+ /**
165
+ * Refuse a value that cannot cross the wire, naming where it was.
166
+ *
167
+ * An explicit stack rather than recursion, for the reason the RSC graph walk
168
+ * gives: a payload arrives from the network, its nesting is the sender's
169
+ * choice, and a recursive walk over it is a stack overflow with an attacker's
170
+ * hand on the depth. Every bound is checked as the walk runs rather than
171
+ * afterwards, so a payload that busts one is refused before the rest of it is
172
+ * visited.
173
+ */
174
+ export function checkActionValue(root: mixed, label: string): void {
175
+ const stack: Array<Pending> = [{ value: root, path: label, depth: 0 }];
176
+ const seen = new Set<mixed>();
177
+ let remaining = MAX_ACTION_VALUES;
178
+
179
+ while (stack.length > 0) {
180
+ const pending = stack.pop();
181
+ if (pending == null) {
182
+ break;
183
+ }
184
+ const { value, path, depth } = pending;
185
+
186
+ remaining -= 1;
187
+ if (remaining < 0) {
188
+ throw new ActionValueError(label, `holds more than ${String(MAX_ACTION_VALUES)} values`);
189
+ }
190
+ if (depth > MAX_ACTION_DEPTH) {
191
+ throw new ActionValueError(path, `is nested deeper than ${String(MAX_ACTION_DEPTH)}`);
192
+ }
193
+
194
+ if (value === null) {
195
+ continue;
196
+ }
197
+ const kind = typeof value;
198
+ if (kind === "boolean" || kind === "string") {
199
+ continue;
200
+ }
201
+ if (kind === "number") {
202
+ // `NaN` and the infinities have no JSON spelling; `JSON.stringify` writes
203
+ // `null` for each, so accepting one here would mean an action was called
204
+ // with a different number from the one the caller passed.
205
+ if (!Number.isFinite(value)) {
206
+ throw new ActionValueError(path, "is not a finite number");
207
+ }
208
+ continue;
209
+ }
210
+ if (kind !== "object") {
211
+ // `undefined`, a function, a symbol, a bigint. Named rather than
212
+ // lumped together, because "a function cannot cross" is the sentence
213
+ // that tells a caller they passed a callback to a server action.
214
+ throw new ActionValueError(path, `is a ${kind}, which cannot cross to a server action`);
215
+ }
216
+
217
+ const object: interface {} = value as $FlowFixMe;
218
+ if (seen.has(object)) {
219
+ throw new ActionValueError(path, "refers to a value that already appeared in the payload");
220
+ }
221
+ seen.add(object);
222
+
223
+ if (Array.isArray(object)) {
224
+ const items: $ReadOnlyArray<mixed> = object as $FlowFixMe;
225
+ for (let index = 0; index < items.length; index += 1) {
226
+ stack.push({ value: items[index], path: `${path}[${String(index)}]`, depth: depth + 1 });
227
+ }
228
+ continue;
229
+ }
230
+
231
+ if (!isPlainObject(object)) {
232
+ throw new ActionValueError(
233
+ path,
234
+ "is a class instance, a Map, a Set, a Date, a React element or another object with a " +
235
+ "prototype, and only arrays and plain objects cross to a server action",
236
+ );
237
+ }
238
+
239
+ // Symbol keys are silently dropped by `JSON.stringify`, so a payload built
240
+ // with one would arrive missing a property nobody could see was missing.
241
+ if (Object.getOwnPropertySymbols(object).length > 0) {
242
+ throw new ActionValueError(path, "has a symbol key, which has no spelling on the wire");
243
+ }
244
+
245
+ const record: { readonly [string]: mixed } = object as $FlowFixMe;
246
+ for (const key of Object.getOwnPropertyNames(object)) {
247
+ if (FORBIDDEN_KEYS.includes(key)) {
248
+ throw new ActionValueError(`${path}.${key}`, "is a key this grammar never carries");
249
+ }
250
+ stack.push({ value: record[key], path: `${path}.${key}`, depth: depth + 1 });
251
+ }
252
+ }
253
+ }
254
+
255
+ /**
256
+ * The request body for a call, or a named failure.
257
+ *
258
+ * The browser's half. Refusing here is what turns "the server answered 400"
259
+ * into "argument 2.createdAt is a class instance", at the call site, with a
260
+ * stack that reaches the component.
261
+ */
262
+ export function encodeActionArguments(args: $ReadOnlyArray<mixed>): string {
263
+ if (args.length > MAX_ACTION_ARGUMENTS) {
264
+ throw new ActionValueError(
265
+ "the call",
266
+ `passes ${String(args.length)} arguments, and an action takes at most ` +
267
+ String(MAX_ACTION_ARGUMENTS),
268
+ );
269
+ }
270
+ for (let index = 0; index < args.length; index += 1) {
271
+ checkActionValue(args[index], `argument ${String(index + 1)}`);
272
+ }
273
+ return JSON.stringify({ args });
274
+ }
275
+
276
+ /**
277
+ * The arguments a request body holds, or a named failure.
278
+ *
279
+ * The server's half, and the one that faces the network. The shape is exact —
280
+ * one object, one key — because a payload with a key nobody reads is a payload
281
+ * whose sender believed something about it that is not true, and because the
282
+ * only way to keep this closed as it grows is to refuse anything that is not
283
+ * this today.
284
+ */
285
+ export function decodeActionArguments(text: string): Array<ActionValue> {
286
+ let parsed: mixed;
287
+ try {
288
+ parsed = JSON.parse(text);
289
+ } catch {
290
+ throw new ActionValueError("the body", "is not JSON");
291
+ }
292
+ if (parsed == null || typeof parsed !== "object" || Array.isArray(parsed)) {
293
+ throw new ActionValueError("the body", "is not a JSON object");
294
+ }
295
+ const keys: $ReadOnlyArray<string> = Object.getOwnPropertyNames(parsed);
296
+ if (keys.length !== 1 || keys[0] !== "args") {
297
+ throw new ActionValueError("the body", 'has keys other than "args"');
298
+ }
299
+ const args: mixed = parsed.args;
300
+ if (!Array.isArray(args)) {
301
+ throw new ActionValueError("the body", 'has an "args" that is not an array');
302
+ }
303
+ if (args.length > MAX_ACTION_ARGUMENTS) {
304
+ throw new ActionValueError(
305
+ "the body",
306
+ `passes more than ${String(MAX_ACTION_ARGUMENTS)} arguments`,
307
+ );
308
+ }
309
+ for (let index = 0; index < args.length; index += 1) {
310
+ checkActionValue(args[index], `argument ${String(index + 1)}`);
311
+ }
312
+ return args as $FlowFixMe;
313
+ }
314
+
315
+ /**
316
+ * The response body for a result, or a named failure.
317
+ *
318
+ * An action that returns nothing answers `{}` rather than `{"value":null}`:
319
+ * JSON has no `undefined`, and turning one into `null` would make an action
320
+ * declared `Promise<void>` resolve to something on the browser side.
321
+ */
322
+ export function encodeActionResult(value: mixed): string {
323
+ if (value === undefined) {
324
+ return "{}";
325
+ }
326
+ checkActionValue(value, "the result");
327
+ return JSON.stringify({ value });
328
+ }
329
+
330
+ /**
331
+ * The result a response body holds, or a named failure.
332
+ *
333
+ * The same grammar in the other direction, and checked rather than trusted:
334
+ * the browser is talking to whatever answered, which on a compromised network
335
+ * is not the server. What it can be handed is therefore data and never an
336
+ * object of somebody's choosing, exactly as on the way out.
337
+ */
338
+ export function decodeActionResult(text: string): ActionValue | void {
339
+ let parsed: mixed;
340
+ try {
341
+ parsed = JSON.parse(text);
342
+ } catch {
343
+ throw new ActionValueError("the answer", "is not JSON");
344
+ }
345
+ if (parsed == null || typeof parsed !== "object" || Array.isArray(parsed)) {
346
+ throw new ActionValueError("the answer", "is not a JSON object");
347
+ }
348
+ const keys: $ReadOnlyArray<string> = Object.getOwnPropertyNames(parsed);
349
+ if (keys.length === 0) {
350
+ return undefined;
351
+ }
352
+ if (keys.length !== 1 || keys[0] !== "value") {
353
+ throw new ActionValueError("the answer", 'has keys other than "value"');
354
+ }
355
+ const value: mixed = parsed.value;
356
+ checkActionValue(value, "the result");
357
+ return value as $FlowFixMe;
358
+ }
359
+
360
+ /**
361
+ * Whether `text` is the canonical spelling of an action id.
362
+ *
363
+ * Sixty-four lowercase hexadecimal characters, which is what
364
+ * `uf_rsc::ActionId::to_hex` writes. Checked before the id reaches the table
365
+ * so an oversized or repeated header cannot make the endpoint do work, and
366
+ * hand-written rather than a regular expression because this reads a header a
367
+ * client chose (`docs/security.md`, rule 5).
368
+ */
369
+ export function isActionId(text: string): boolean {
370
+ if (text.length !== 64) {
371
+ return false;
372
+ }
373
+ for (let index = 0; index < text.length; index += 1) {
374
+ const code = text.charCodeAt(index);
375
+ const digit = code >= 0x30 && code <= 0x39;
376
+ const lower = code >= 0x61 && code <= 0x66;
377
+ if (!digit && !lower) {
378
+ return false;
379
+ }
380
+ }
381
+ return true;
382
+ }