@reckona/mreact-server 0.0.180 → 0.0.181

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/src/flight.ts CHANGED
@@ -88,6 +88,10 @@ export interface ServerActionHandlerOptions {
88
88
  }
89
89
 
90
90
  const DEFAULT_MAX_BODY_BYTES = 1024 * 1024;
91
+ const MAX_SERVER_ACTION_ARGUMENT_DEPTH = 64;
92
+ const MAX_SERVER_ACTION_ARGUMENT_ARRAY_LENGTH = 2_000;
93
+ const MAX_SERVER_ACTION_ARGUMENT_OBJECT_KEYS = 200;
94
+ const SERVER_ACTION_FORBIDDEN_JSON_KEYS = new Set(["__proto__", "constructor", "prototype"]);
91
95
 
92
96
  /** Options for embedding a serialized Flight response in a script tag. */
93
97
  export interface FlightScriptOptions {
@@ -274,8 +278,38 @@ export type FlightTypedArrayName =
274
278
  | "BigInt64Array"
275
279
  | "BigUint64Array";
276
280
 
277
- const reactFlightBinaryRowTags = ["A", "O", "o", "U", "S", "s", "L", "l", "G", "g", "M", "m", "V"] as const;
278
- const reactFlightRowTags = ["C", "D", "E", "F", "H", "I", "J", "N", "P", "R", "T", "W", "X", "x", "r"] as const;
281
+ const reactFlightBinaryRowTags = [
282
+ "A",
283
+ "O",
284
+ "o",
285
+ "U",
286
+ "S",
287
+ "s",
288
+ "L",
289
+ "l",
290
+ "G",
291
+ "g",
292
+ "M",
293
+ "m",
294
+ "V",
295
+ ] as const;
296
+ const reactFlightRowTags = [
297
+ "C",
298
+ "D",
299
+ "E",
300
+ "F",
301
+ "H",
302
+ "I",
303
+ "J",
304
+ "N",
305
+ "P",
306
+ "R",
307
+ "T",
308
+ "W",
309
+ "X",
310
+ "x",
311
+ "r",
312
+ ] as const;
279
313
  const reactFlightModelTokens = [
280
314
  "$",
281
315
  "$$",
@@ -425,8 +459,7 @@ export function createServerActionHandler(
425
459
  const allowedActionKeys = options.allowedActions?.map((reference) =>
426
460
  serverActionKey(reference.moduleId, reference.exportName),
427
461
  );
428
- const allowedActionSet =
429
- allowedActionKeys === undefined ? undefined : new Set(allowedActionKeys);
462
+ const allowedActionSet = allowedActionKeys === undefined ? undefined : new Set(allowedActionKeys);
430
463
 
431
464
  return async (request: Request): Promise<Response> => {
432
465
  if (request.method !== "POST") {
@@ -485,7 +518,20 @@ export function createServerActionHandler(
485
518
  const action = getServerAction(actionEntry);
486
519
  const validateArgs = getServerActionArgsValidator(actionEntry);
487
520
  const boundArgs = Array.isArray(payload.bound) ? payload.bound : [];
488
- const args = [...boundArgs, ...(Array.isArray(payload.args) ? payload.args : [])];
521
+ const extraArgs = Array.isArray(payload.args) ? payload.args : [];
522
+ const argsStructure = validateServerActionJsonArgumentStructure(boundArgs, extraArgs);
523
+
524
+ if (!argsStructure.valid) {
525
+ return jsonResponse(
526
+ {
527
+ ok: false,
528
+ error: "Invalid server action argument structure.",
529
+ },
530
+ 400,
531
+ );
532
+ }
533
+
534
+ const args = [...boundArgs, ...extraArgs];
489
535
  const validationResult = validateArgs?.(args);
490
536
 
491
537
  if (validationResult !== undefined && validationResult !== true) {
@@ -501,11 +547,7 @@ export function createServerActionHandler(
501
547
  );
502
548
  }
503
549
 
504
- const authorizationResult = await options.authorize?.(
505
- request,
506
- reference,
507
- args,
508
- );
550
+ const authorizationResult = await options.authorize?.(request, reference, args);
509
551
 
510
552
  if (authorizationResult !== undefined && authorizationResult !== true) {
511
553
  return jsonResponse(
@@ -580,9 +622,10 @@ export function toReactFlightRows(response: FlightResponse): string {
580
622
  rows.push(
581
623
  `${formatReactFlightId(wireId)}:F${JSON.stringify({
582
624
  id: serverActionKey(reference.moduleId, reference.exportName),
583
- bound: reference.bound === undefined
584
- ? null
585
- : reference.bound.map((value) => encodeReactFlightModel(value, state)),
625
+ bound:
626
+ reference.bound === undefined
627
+ ? null
628
+ : reference.bound.map((value) => encodeReactFlightModel(value, state)),
586
629
  name: reference.exportName,
587
630
  })}`,
588
631
  );
@@ -637,7 +680,9 @@ export function fromReactFlightRows(rows: string): FlightResponse {
637
680
  }
638
681
 
639
682
  if (row.tag === "F") {
640
- serverReferences.push(parseReactFlightServerReference(row.id, row.payload, modelChunks, errorChunks));
683
+ serverReferences.push(
684
+ parseReactFlightServerReference(row.id, row.payload, modelChunks, errorChunks),
685
+ );
641
686
  continue;
642
687
  }
643
688
 
@@ -687,10 +732,7 @@ export function fromReactFlightRows(rows: string): FlightResponse {
687
732
  }
688
733
 
689
734
  /** Merges additional React Flight row text into an existing Flight response. */
690
- export function mergeReactFlightRows(
691
- response: FlightResponse,
692
- rows: string,
693
- ): FlightResponse {
735
+ export function mergeReactFlightRows(response: FlightResponse, rows: string): FlightResponse {
694
736
  // Issue 081 note: same finding as `fromReactFlightRows` — the native
695
737
  // merge path is slower than the JS one given the double-JSON tax.
696
738
  const modelChunks = new Map<number, unknown>();
@@ -707,7 +749,9 @@ export function mergeReactFlightRows(
707
749
  }
708
750
 
709
751
  if (row.tag === "F") {
710
- serverReferences.push(parseReactFlightServerReference(row.id, row.payload, modelChunks, errorChunks));
752
+ serverReferences.push(
753
+ parseReactFlightServerReference(row.id, row.payload, modelChunks, errorChunks),
754
+ );
711
755
  continue;
712
756
  }
713
757
 
@@ -773,9 +817,7 @@ export function renderFlightPreloadLinks(
773
817
  seen.add(chunk);
774
818
  return true;
775
819
  })
776
- .map((chunk) =>
777
- `<link rel="modulepreload" href="${escapeAttribute(chunk)}"${nonceAttribute}>`,
778
- )
820
+ .map((chunk) => `<link rel="modulepreload" href="${escapeAttribute(chunk)}"${nonceAttribute}>`)
779
821
  .join("");
780
822
  }
781
823
 
@@ -885,11 +927,7 @@ interface ReactFlightEncodingState {
885
927
  }
886
928
 
887
929
  function encodeReactFlightModel(model: FlightModel, state: ReactFlightEncodingState): unknown {
888
- if (
889
- model === null ||
890
- typeof model === "number" ||
891
- typeof model === "boolean"
892
- ) {
930
+ if (model === null || typeof model === "number" || typeof model === "boolean") {
893
931
  return model;
894
932
  }
895
933
 
@@ -967,7 +1005,9 @@ function encodeReactFlightModel(model: FlightModel, state: ReactFlightEncodingSt
967
1005
  if (model.kind === "error") {
968
1006
  const id = state.nextWireId;
969
1007
  state.nextWireId += 1;
970
- state.outlineRows.push(`${formatReactFlightId(id)}:E${JSON.stringify(encodeReactFlightError(model))}`);
1008
+ state.outlineRows.push(
1009
+ `${formatReactFlightId(id)}:E${JSON.stringify(encodeReactFlightError(model))}`,
1010
+ );
971
1011
  return `$Z${formatReactFlightId(id)}`;
972
1012
  }
973
1013
 
@@ -999,10 +1039,7 @@ function encodeReactFlightModel(model: FlightModel, state: ReactFlightEncodingSt
999
1039
  return encodeReactFlightProps(model, state);
1000
1040
  }
1001
1041
 
1002
- function allocateReactFlightOutlineRow(
1003
- state: ReactFlightEncodingState,
1004
- payload: unknown,
1005
- ): number {
1042
+ function allocateReactFlightOutlineRow(state: ReactFlightEncodingState, payload: unknown): number {
1006
1043
  const id = state.nextWireId;
1007
1044
  state.nextWireId += 1;
1008
1045
  state.outlineRows.push(`${formatReactFlightId(id)}:${JSON.stringify(payload)}`);
@@ -1031,10 +1068,7 @@ function encodeReactFlightProps(
1031
1068
  return Object.fromEntries(
1032
1069
  Object.entries(props)
1033
1070
  .filter((entry): entry is [string, FlightModel] => entry[1] !== undefined)
1034
- .map(([key, value]) => [
1035
- key,
1036
- encodeReactFlightModel(value, state),
1037
- ]),
1071
+ .map(([key, value]) => [key, encodeReactFlightModel(value, state)]),
1038
1072
  );
1039
1073
  }
1040
1074
 
@@ -1083,7 +1117,7 @@ function parseReactFlightRow(line: string): ReactFlightRow {
1083
1117
  }
1084
1118
 
1085
1119
  function looksLikeUnsupportedReactFlightTag(tag: string, body: string): boolean {
1086
- return /^[A-Z]$/.test(tag) && (body[1] === "{" || body[1] === "[" || body[1] === "\"");
1120
+ return /^[A-Z]$/.test(tag) && (body[1] === "{" || body[1] === "[" || body[1] === '"');
1087
1121
  }
1088
1122
 
1089
1123
  function parseReactFlightTextChunk(payload: string): string {
@@ -1201,7 +1235,9 @@ function createReactFlightBinaryModel(
1201
1235
  };
1202
1236
  }
1203
1237
 
1204
- function getReactFlightTypedArrayName(tag: Exclude<ReactFlightBinaryRowTag, "A" | "V">): FlightTypedArrayName {
1238
+ function getReactFlightTypedArrayName(
1239
+ tag: Exclude<ReactFlightBinaryRowTag, "A" | "V">,
1240
+ ): FlightTypedArrayName {
1205
1241
  switch (tag) {
1206
1242
  case "O":
1207
1243
  return "Int8Array";
@@ -1344,11 +1380,7 @@ function decodeReactFlightModel(
1344
1380
  context: FlightDecodeContext = createFlightDecodeContext(),
1345
1381
  ): FlightModel {
1346
1382
  if (depth > MAX_FLIGHT_DECODE_DEPTH) flightTooDeep();
1347
- if (
1348
- value === null ||
1349
- typeof value === "number" ||
1350
- typeof value === "boolean"
1351
- ) {
1383
+ if (value === null || typeof value === "number" || typeof value === "boolean") {
1352
1384
  return value;
1353
1385
  }
1354
1386
 
@@ -1457,10 +1489,18 @@ function decodeReactFlightString(
1457
1489
  }
1458
1490
 
1459
1491
  if (/^\$Q[0-9a-f]+$/i.test(value)) {
1460
- const decoded = decodeReactFlightChunk(value.slice(2), modelChunks, errorChunks, depth + 1, context);
1492
+ const decoded = decodeReactFlightChunk(
1493
+ value.slice(2),
1494
+ modelChunks,
1495
+ errorChunks,
1496
+ depth + 1,
1497
+ context,
1498
+ );
1461
1499
  const entries = Array.isArray(decoded)
1462
1500
  ? decoded.map((entry): [FlightModel, FlightModel] =>
1463
- Array.isArray(entry) ? [entry[0] ?? { kind: "undefined" }, entry[1] ?? { kind: "undefined" }] : [entry, { kind: "undefined" }],
1501
+ Array.isArray(entry)
1502
+ ? [entry[0] ?? { kind: "undefined" }, entry[1] ?? { kind: "undefined" }]
1503
+ : [entry, { kind: "undefined" }],
1464
1504
  )
1465
1505
  : [];
1466
1506
 
@@ -1471,7 +1511,13 @@ function decodeReactFlightString(
1471
1511
  }
1472
1512
 
1473
1513
  if (/^\$W[0-9a-f]+$/i.test(value)) {
1474
- const decoded = decodeReactFlightChunk(value.slice(2), modelChunks, errorChunks, depth + 1, context);
1514
+ const decoded = decodeReactFlightChunk(
1515
+ value.slice(2),
1516
+ modelChunks,
1517
+ errorChunks,
1518
+ depth + 1,
1519
+ context,
1520
+ );
1475
1521
 
1476
1522
  return {
1477
1523
  kind: "set",
@@ -1480,7 +1526,13 @@ function decodeReactFlightString(
1480
1526
  }
1481
1527
 
1482
1528
  if (/^\$K[0-9a-f]+$/i.test(value)) {
1483
- const decoded = decodeReactFlightChunk(value.slice(2), modelChunks, errorChunks, depth + 1, context);
1529
+ const decoded = decodeReactFlightChunk(
1530
+ value.slice(2),
1531
+ modelChunks,
1532
+ errorChunks,
1533
+ depth + 1,
1534
+ context,
1535
+ );
1484
1536
  const entries = Array.isArray(decoded)
1485
1537
  ? decoded.flatMap((entry): [string, FlightModel][] =>
1486
1538
  Array.isArray(entry) && typeof entry[0] === "string"
@@ -1496,7 +1548,13 @@ function decodeReactFlightString(
1496
1548
  }
1497
1549
 
1498
1550
  if (/^\$i[0-9a-f]+$/i.test(value)) {
1499
- const decoded = decodeReactFlightChunk(value.slice(2), modelChunks, errorChunks, depth + 1, context);
1551
+ const decoded = decodeReactFlightChunk(
1552
+ value.slice(2),
1553
+ modelChunks,
1554
+ errorChunks,
1555
+ depth + 1,
1556
+ context,
1557
+ );
1500
1558
 
1501
1559
  return {
1502
1560
  kind: "iterable",
@@ -1505,11 +1563,13 @@ function decodeReactFlightString(
1505
1563
  }
1506
1564
 
1507
1565
  if (/^\$Z[0-9a-f]+$/i.test(value)) {
1508
- return errorChunks.get(parseReactFlightId(value.slice(2))) ?? {
1509
- kind: "error",
1510
- name: "Error",
1511
- message: "Unknown React Flight error.",
1512
- };
1566
+ return (
1567
+ errorChunks.get(parseReactFlightId(value.slice(2))) ?? {
1568
+ kind: "error",
1569
+ name: "Error",
1570
+ message: "Unknown React Flight error.",
1571
+ }
1572
+ );
1513
1573
  }
1514
1574
 
1515
1575
  if (value === "$Y" || value.startsWith("$E")) {
@@ -1628,10 +1688,7 @@ function encodeReactFlightError(model: FlightErrorModel): Record<string, unknown
1628
1688
 
1629
1689
  function isFlightErrorModel(model: FlightModel): model is FlightErrorModel {
1630
1690
  return (
1631
- typeof model === "object" &&
1632
- model !== null &&
1633
- !Array.isArray(model) &&
1634
- model.kind === "error"
1691
+ typeof model === "object" && model !== null && !Array.isArray(model) && model.kind === "error"
1635
1692
  );
1636
1693
  }
1637
1694
 
@@ -1664,10 +1721,7 @@ async function serializeFlightValue(
1664
1721
  return { kind: "undefined" };
1665
1722
  }
1666
1723
 
1667
- if (
1668
- typeof awaited === "string" ||
1669
- typeof awaited === "boolean"
1670
- ) {
1724
+ if (typeof awaited === "string" || typeof awaited === "boolean") {
1671
1725
  return awaited;
1672
1726
  }
1673
1727
 
@@ -1722,10 +1776,13 @@ async function serializeFlightValue(
1722
1776
  return {
1723
1777
  kind: "map",
1724
1778
  entries: await Promise.all(
1725
- Array.from(awaited.entries()).map(async ([key, value]) => [
1726
- await serializeFlightValue(key, state, depth + 1),
1727
- await serializeFlightValue(value, state, depth + 1),
1728
- ] as [FlightModel, FlightModel]),
1779
+ Array.from(awaited.entries()).map(
1780
+ async ([key, value]) =>
1781
+ [
1782
+ await serializeFlightValue(key, state, depth + 1),
1783
+ await serializeFlightValue(value, state, depth + 1),
1784
+ ] as [FlightModel, FlightModel],
1785
+ ),
1729
1786
  ),
1730
1787
  };
1731
1788
  }
@@ -1743,10 +1800,10 @@ async function serializeFlightValue(
1743
1800
  return {
1744
1801
  kind: "form-data",
1745
1802
  entries: await Promise.all(
1746
- Array.from(awaited.entries()).map(async ([key, value]) => [
1747
- key,
1748
- await serializeFlightValue(value, state, depth + 1),
1749
- ] as [string, FlightModel]),
1803
+ Array.from(awaited.entries()).map(
1804
+ async ([key, value]) =>
1805
+ [key, await serializeFlightValue(value, state, depth + 1)] as [string, FlightModel],
1806
+ ),
1750
1807
  ),
1751
1808
  };
1752
1809
  }
@@ -1823,10 +1880,9 @@ async function serializeProps(
1823
1880
  depth: number,
1824
1881
  ): Promise<Record<string, FlightModel>> {
1825
1882
  const entries = await Promise.all(
1826
- Object.entries(props).map(async ([key, value]) => [
1827
- key,
1828
- await serializeFlightValue(value, state, depth + 1),
1829
- ] as const),
1883
+ Object.entries(props).map(
1884
+ async ([key, value]) => [key, await serializeFlightValue(value, state, depth + 1)] as const,
1885
+ ),
1830
1886
  );
1831
1887
 
1832
1888
  return Object.fromEntries(entries);
@@ -1839,18 +1895,14 @@ async function serializeObject(
1839
1895
  ): Promise<FlightObjectModel> {
1840
1896
  return Object.fromEntries(
1841
1897
  await Promise.all(
1842
- Object.entries(object).map(async ([key, value]) => [
1843
- key,
1844
- await serializeFlightValue(value, state, depth + 1),
1845
- ] as const),
1898
+ Object.entries(object).map(
1899
+ async ([key, value]) => [key, await serializeFlightValue(value, state, depth + 1)] as const,
1900
+ ),
1846
1901
  ),
1847
1902
  ) as FlightObjectModel;
1848
1903
  }
1849
1904
 
1850
- function getClientReferenceId(
1851
- reference: ClientReference,
1852
- state: FlightSerializationState,
1853
- ): number {
1905
+ function getClientReferenceId(reference: ClientReference, state: FlightSerializationState): number {
1854
1906
  const key = `${reference.moduleId}:${reference.exportName}`;
1855
1907
  const existing = state.clientReferenceIndexes.get(key);
1856
1908
 
@@ -1873,9 +1925,10 @@ async function getServerReferenceId(
1873
1925
  reference: ServerReference,
1874
1926
  state: FlightSerializationState,
1875
1927
  ): Promise<number> {
1876
- const serializedBound = reference.bound === undefined
1877
- ? undefined
1878
- : await Promise.all(reference.bound.map((value) => serializeFlightValue(value, state, 0)));
1928
+ const serializedBound =
1929
+ reference.bound === undefined
1930
+ ? undefined
1931
+ : await Promise.all(reference.bound.map((value) => serializeFlightValue(value, state, 0)));
1879
1932
  const key = `${reference.moduleId}:${reference.exportName}:${JSON.stringify(serializedBound ?? null)}`;
1880
1933
  const existing = state.serverReferenceIndexes.get(key);
1881
1934
 
@@ -1971,6 +2024,62 @@ async function readServerActionPayload(
1971
2024
  }
1972
2025
  }
1973
2026
 
2027
+ function validateServerActionJsonArgumentStructure(
2028
+ boundArgs: readonly unknown[],
2029
+ args: readonly unknown[],
2030
+ ): { valid: true } | { valid: false } {
2031
+ const stack: { depth: number; value: unknown }[] = [
2032
+ { depth: 0, value: boundArgs },
2033
+ { depth: 0, value: args },
2034
+ ];
2035
+ const seen = new WeakSet<object>();
2036
+
2037
+ while (stack.length > 0) {
2038
+ const current = stack.pop();
2039
+ if (current === undefined) {
2040
+ continue;
2041
+ }
2042
+
2043
+ if (current.depth > MAX_SERVER_ACTION_ARGUMENT_DEPTH) {
2044
+ return { valid: false };
2045
+ }
2046
+
2047
+ if (current.value === null || typeof current.value !== "object") {
2048
+ continue;
2049
+ }
2050
+
2051
+ if (seen.has(current.value)) {
2052
+ continue;
2053
+ }
2054
+ seen.add(current.value);
2055
+
2056
+ if (Array.isArray(current.value)) {
2057
+ if (current.value.length > MAX_SERVER_ACTION_ARGUMENT_ARRAY_LENGTH) {
2058
+ return { valid: false };
2059
+ }
2060
+
2061
+ for (const item of current.value) {
2062
+ stack.push({ depth: current.depth + 1, value: item });
2063
+ }
2064
+ continue;
2065
+ }
2066
+
2067
+ const entries = Object.entries(current.value);
2068
+ if (entries.length > MAX_SERVER_ACTION_ARGUMENT_OBJECT_KEYS) {
2069
+ return { valid: false };
2070
+ }
2071
+
2072
+ for (const [key, value] of entries) {
2073
+ if (SERVER_ACTION_FORBIDDEN_JSON_KEYS.has(key)) {
2074
+ return { valid: false };
2075
+ }
2076
+ stack.push({ depth: current.depth + 1, value });
2077
+ }
2078
+ }
2079
+
2080
+ return { valid: true };
2081
+ }
2082
+
1974
2083
  function getServerAction(entry: ServerAction | ServerActionDescriptor): ServerAction {
1975
2084
  return typeof entry === "function" ? entry : entry.action;
1976
2085
  }
@@ -2078,10 +2187,7 @@ function validateServerActionNonce(
2078
2187
 
2079
2188
  if (replayProtection.seen.has(nonce)) {
2080
2189
  return {
2081
- response: jsonResponse(
2082
- { ok: false, error: "Server action nonce was already used." },
2083
- 409,
2084
- ),
2190
+ response: jsonResponse({ ok: false, error: "Server action nonce was already used." }, 409),
2085
2191
  };
2086
2192
  }
2087
2193