tina4-nodejs 3.13.150 → 3.13.151

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.
Files changed (56) hide show
  1. package/CLAUDE.md +2 -2
  2. package/package.json +5 -9
  3. package/packages/cli/dist/bin.js +1045 -518
  4. package/packages/core/dist/index.js +1042 -513
  5. package/packages/core/src/aiClient.ts +2 -1
  6. package/packages/core/src/api.ts +2 -1
  7. package/packages/core/src/auth.ts +1 -1
  8. package/packages/core/src/cache.ts +6 -1
  9. package/packages/core/src/devAdmin.ts +176 -123
  10. package/packages/core/src/dispatchPipeline.ts +4 -0
  11. package/packages/core/src/docs.ts +5 -3
  12. package/packages/core/src/errorOverlay.ts +18 -9
  13. package/packages/core/src/index.ts +2 -1
  14. package/packages/core/src/mcp.ts +53 -16
  15. package/packages/core/src/messenger.ts +22 -30
  16. package/packages/core/src/middleware.ts +8 -1
  17. package/packages/core/src/optionalPackage.ts +2 -2
  18. package/packages/core/src/plan.ts +3 -3
  19. package/packages/core/src/push.ts +1 -1
  20. package/packages/core/src/request.ts +185 -25
  21. package/packages/core/src/router.ts +3 -2
  22. package/packages/core/src/server.ts +32 -10
  23. package/packages/core/src/session.ts +44 -0
  24. package/packages/core/src/slashTrim.ts +39 -0
  25. package/packages/core/src/wsdl.ts +66 -48
  26. package/packages/core/templates/crud/form.twig +5 -5
  27. package/packages/core/templates/crud/table.twig +2 -2
  28. package/packages/core/templates/errors/302.twig +4 -3
  29. package/packages/frond/dist/index.js +206 -64
  30. package/packages/frond/src/engine.ts +278 -69
  31. package/packages/frond/src/index.ts +1 -1
  32. package/packages/orm/dist/index.js +1026 -499
  33. package/packages/orm/src/crudRender.ts +5 -5
  34. package/packages/orm/src/crudTable.ts +1 -1
  35. package/packages/orm/src/database.ts +15 -29
  36. package/packages/orm/src/databaseResult.ts +11 -1
  37. package/packages/orm/src/realtime/index.ts +1 -0
  38. package/packages/orm/src/realtime/realtime.ts +2 -1
  39. package/packages/orm/src/realtime/s3Client.ts +276 -0
  40. package/packages/orm/src/realtime/storage.ts +19 -60
  41. package/packages/orm/src/sqlTranslator.ts +58 -42
  42. package/packages/swagger/dist/index.js +25 -4
  43. package/packages/swagger/src/generator.ts +7 -4
  44. package/packages/swagger/src/ui.ts +2 -1
  45. package/types/core/src/devAdmin.d.ts +40 -0
  46. package/types/core/src/index.d.ts +2 -1
  47. package/types/core/src/middleware.d.ts +1 -0
  48. package/types/core/src/optionalPackage.d.ts +2 -2
  49. package/types/core/src/request.d.ts +5 -1
  50. package/types/core/src/session.d.ts +25 -0
  51. package/types/core/src/slashTrim.d.ts +6 -0
  52. package/types/frond/src/engine.d.ts +11 -0
  53. package/types/frond/src/index.d.ts +1 -1
  54. package/types/orm/src/realtime/index.d.ts +1 -0
  55. package/types/orm/src/realtime/s3Client.d.ts +48 -0
  56. package/types/orm/src/realtime/storage.d.ts +9 -7
@@ -371,6 +371,26 @@ function sendCommand(
371
371
  /**
372
372
  * Build an RFC 2822 MIME message.
373
373
  */
374
+ // The text/html multipart-alternative body, identical in the with- and
375
+ // without-attachments paths of buildMimeMessage.
376
+ function pushAlternativeBody(lines: string[], altBoundary: string, text: string, body: string): void {
377
+ lines.push(`Content-Type: multipart/alternative; boundary="${altBoundary}"`);
378
+ lines.push("");
379
+ lines.push(`--${altBoundary}`);
380
+ lines.push("Content-Type: text/plain; charset=UTF-8");
381
+ lines.push("Content-Transfer-Encoding: 7bit");
382
+ lines.push("");
383
+ lines.push(text);
384
+ lines.push("");
385
+ lines.push(`--${altBoundary}`);
386
+ lines.push("Content-Type: text/html; charset=UTF-8");
387
+ lines.push("Content-Transfer-Encoding: 7bit");
388
+ lines.push("");
389
+ lines.push(body);
390
+ lines.push("");
391
+ lines.push(`--${altBoundary}--`);
392
+ }
393
+
374
394
  function buildMimeMessage(options: {
375
395
  from: string;
376
396
  fromName?: string;
@@ -423,21 +443,7 @@ function buildMimeMessage(options: {
423
443
 
424
444
  // Body part (with optional text alternative)
425
445
  if (hasTextAlt) {
426
- lines.push(`Content-Type: multipart/alternative; boundary="${altBoundary}"`);
427
- lines.push("");
428
- lines.push(`--${altBoundary}`);
429
- lines.push("Content-Type: text/plain; charset=UTF-8");
430
- lines.push("Content-Transfer-Encoding: 7bit");
431
- lines.push("");
432
- lines.push(options.text!);
433
- lines.push("");
434
- lines.push(`--${altBoundary}`);
435
- lines.push("Content-Type: text/html; charset=UTF-8");
436
- lines.push("Content-Transfer-Encoding: 7bit");
437
- lines.push("");
438
- lines.push(options.body);
439
- lines.push("");
440
- lines.push(`--${altBoundary}--`);
446
+ pushAlternativeBody(lines, altBoundary, options.text!, options.body);
441
447
  } else {
442
448
  const contentType = options.html ? "text/html" : "text/plain";
443
449
  lines.push(`Content-Type: ${contentType}; charset=UTF-8`);
@@ -469,21 +475,7 @@ function buildMimeMessage(options: {
469
475
  lines.push(`--${boundary}--`);
470
476
  } else if (hasTextAlt) {
471
477
  // Text alternative without attachments
472
- lines.push(`Content-Type: multipart/alternative; boundary="${altBoundary}"`);
473
- lines.push("");
474
- lines.push(`--${altBoundary}`);
475
- lines.push("Content-Type: text/plain; charset=UTF-8");
476
- lines.push("Content-Transfer-Encoding: 7bit");
477
- lines.push("");
478
- lines.push(options.text!);
479
- lines.push("");
480
- lines.push(`--${altBoundary}`);
481
- lines.push("Content-Type: text/html; charset=UTF-8");
482
- lines.push("Content-Transfer-Encoding: 7bit");
483
- lines.push("");
484
- lines.push(options.body);
485
- lines.push("");
486
- lines.push(`--${altBoundary}--`);
478
+ pushAlternativeBody(lines, altBoundary, options.text!, options.body);
487
479
  } else {
488
480
  // Simple message
489
481
  const contentType = options.html ? "text/html" : "text/plain";
@@ -931,6 +931,7 @@ export class RequestLogger {
931
931
  * TINA4_CSP — Content-Security-Policy (default: "default-src 'self'")
932
932
  * TINA4_REFERRER_POLICY — Referrer-Policy (default: "strict-origin-when-cross-origin")
933
933
  * TINA4_PERMISSIONS_POLICY — Permissions-Policy (default: "camera=(), microphone=(), geolocation=()")
934
+ * TINA4_COOP — Cross-Origin-Opener-Policy (default: "same-origin-allow-popups")
934
935
  *
935
936
  * Usage:
936
937
  * Router.use(SecurityHeadersMiddleware);
@@ -967,6 +968,7 @@ export class SecurityHeadersMiddleware {
967
968
  "Referrer-Policy": process.env.TINA4_REFERRER_POLICY ?? "strict-origin-when-cross-origin",
968
969
  "X-XSS-Protection": "0",
969
970
  "Permissions-Policy": process.env.TINA4_PERMISSIONS_POLICY ?? "camera=(), microphone=(), geolocation=()",
971
+ "Cross-Origin-Opener-Policy": process.env.TINA4_COOP ?? "same-origin-allow-popups",
970
972
  };
971
973
  }
972
974
 
@@ -996,7 +998,12 @@ export class SecurityHeadersMiddleware {
996
998
  // script-src (ADR-0088), already resolved in canonical, so the framework's
997
999
  // own inline <style>/<script> (and user templates using the Frond
998
1000
  // csp_nonce() global) run under the strict policy without 'unsafe-inline'.
999
- for (const name of ["Content-Security-Policy", "Referrer-Policy", "X-XSS-Protection", "Permissions-Policy"]) {
1001
+ // Cross-Origin-Opener-Policy severs the window-reference link to a
1002
+ // cross-origin opener (closing reverse-tab-nabbing + cross-window XS-Leaks
1003
+ // that X-Frame-Options does not). Default same-origin-allow-popups keeps
1004
+ // OAuth/payment popups working; TINA4_COOP=same-origin for full isolation,
1005
+ // unsafe-none to opt out. Secure-context only (https + http://localhost).
1006
+ for (const name of ["Content-Security-Policy", "Referrer-Policy", "X-XSS-Protection", "Permissions-Policy", "Cross-Origin-Opener-Policy"]) {
1000
1007
  res.header(name, canonical[name]);
1001
1008
  }
1002
1009
 
@@ -11,8 +11,8 @@ file, You can obtain one at https://mozilla.org/MPL/2.0/.
11
11
  * APPLICATION installs, never the framework (ADR-0067).
12
12
  *
13
13
  * tina4-nodejs installs as exactly one package. `pg`, `mongodb`, `redis` and
14
- * the `@aws-sdk` S3 client are optional peerDependencies, which npm does not
15
- * install, so for most apps "missing" is the normal state. Every feature that
14
+ * the SQL drivers are optional peerDependencies, which npm does not install, so
15
+ * for most apps "missing" is the normal state. Every feature that
16
16
  * reaches for one must therefore fail at the point of use with the exact
17
17
  * command that fixes it - never a bare MODULE_NOT_FOUND, never a silent no-op.
18
18
  */
@@ -18,6 +18,7 @@ file, You can obtain one at https://mozilla.org/MPL/2.0/.
18
18
  */
19
19
 
20
20
  import * as fs from "node:fs";
21
+ import { trimChar } from "./slashTrim.js";
21
22
  import * as path from "node:path";
22
23
 
23
24
  const PLAN_DIR = "plan";
@@ -90,9 +91,8 @@ function archiveDir(): string {
90
91
  }
91
92
 
92
93
  function slugify(title: string): string {
93
- const slug = (title || "").trim().toLowerCase()
94
- .replace(/[^A-Za-z0-9_-]+/g, "-")
95
- .replace(/^-+|-+$/g, "");
94
+ const dashed = (title || "").trim().toLowerCase().replace(/[^A-Za-z0-9_-]+/g, "-");
95
+ const slug = trimChar(dashed, 45); // ReDoS-safe edge-dash strip (45 = '-')
96
96
  return slug.slice(0, 80) || `plan-${Math.floor(Date.now() / 1000)}`;
97
97
  }
98
98
 
@@ -70,7 +70,7 @@ function encodeBase64Url(value: Uint8Array): string {
70
70
  return Buffer.from(value).toString("base64")
71
71
  .replace(/\+/g, "-")
72
72
  .replace(/\//g, "_")
73
- .replace(/=+$/g, "");
73
+ .replace(/={1,2}$/, "");
74
74
  }
75
75
 
76
76
  function decodeBase64Url(value: string, name: string): Buffer {
@@ -7,7 +7,7 @@ file, You can obtain one at https://mozilla.org/MPL/2.0/.
7
7
  */
8
8
 
9
9
  import type { IncomingMessage, IncomingHttpHeaders } from "node:http";
10
- import { mkdirSync, realpathSync, writeFileSync } from "node:fs";
10
+ import { closeSync, constants, fstatSync, ftruncateSync, lstatSync, mkdirSync, openSync, realpathSync, unlinkSync, writeFileSync } from "node:fs";
11
11
  import { dirname, join } from "node:path";
12
12
  import type { Tina4Request, UploadedFile } from "./types.js";
13
13
  import { resolveClientIp, isTrustedProxy } from "./trustedProxy.js";
@@ -84,7 +84,12 @@ export function createRequest(req: IncomingMessage): Tina4Request {
84
84
  try {
85
85
  const url = new URL(req.url ?? "/", `${proto}://${host}`);
86
86
  for (const [key, value] of url.searchParams) {
87
- query[key] = value;
87
+ if (isUnsafeFieldName(key)) continue;
88
+ // Reflect.set, not query[key]=value: the key is already filtered
89
+ // (isUnsafeFieldName drops __proto__/constructor/prototype), and the
90
+ // reflective write keeps that guarantee legible to static analysis
91
+ // (js/remote-property-injection).
92
+ Reflect.set(query, key, value);
88
93
  }
89
94
  path = url.pathname;
90
95
  queryString = url.search.replace(/^\?/, "");
@@ -96,7 +101,8 @@ export function createRequest(req: IncomingMessage): Tina4Request {
96
101
  queryString = questionIdx >= 0 ? rawTarget.slice(questionIdx + 1) : "";
97
102
  try {
98
103
  for (const [key, value] of new URLSearchParams(queryString)) {
99
- query[key] = value;
104
+ if (isUnsafeFieldName(key)) continue;
105
+ Reflect.set(query, key, value); // filtered key; reflective write (see above)
100
106
  }
101
107
  } catch { /* unparseable query — leave query empty, still route the path */ }
102
108
  fullUrl = `${proto}://${host}${rawTarget}`;
@@ -121,7 +127,9 @@ export function createRequest(req: IncomingMessage): Tina4Request {
121
127
  if (cookieHeader) {
122
128
  for (const pair of cookieHeader.split(";")) {
123
129
  const [k, ...v] = pair.trim().split("=");
124
- if (k) cookies[k.trim()] = v.join("=").trim();
130
+ const name = k ? k.trim() : "";
131
+ // Reflective write of an already-filtered cookie name (js/remote-property-injection).
132
+ if (name && !isUnsafeFieldName(name)) Reflect.set(cookies, name, v.join("=").trim());
125
133
  }
126
134
  }
127
135
  w.cookies = cookies;
@@ -273,13 +281,7 @@ async function parseBody(req: Tina4Request): Promise<void> {
273
281
  req.body = str;
274
282
  }
275
283
  } else if (contentType.includes("application/x-www-form-urlencoded")) {
276
- const str = raw.toString("utf-8");
277
- const params = new URLSearchParams(str);
278
- const obj: Record<string, string> = {};
279
- for (const [key, value] of params) {
280
- obj[key] = value;
281
- }
282
- req.body = obj;
284
+ req.body = nestFormFields([...new URLSearchParams(raw.toString("utf-8"))]);
283
285
  } else {
284
286
  req.body = raw.toString("utf-8");
285
287
  }
@@ -300,8 +302,8 @@ function extractBoundary(contentType: string): string | null {
300
302
  export function parseMultipart(
301
303
  body: Buffer,
302
304
  boundary: string,
303
- ): { fields: Record<string, string>; files: Record<string, UploadedFile | UploadedFile[]> } {
304
- const fields: Record<string, string> = {};
305
+ ): { fields: Record<string, FormValue>; files: Record<string, UploadedFile | UploadedFile[]> } {
306
+ const pairs: Array<[string, string]> = [];
305
307
  const files: Record<string, UploadedFile | UploadedFile[]> = {};
306
308
 
307
309
  const delimiter = Buffer.from(`--${boundary}`);
@@ -313,7 +315,7 @@ export function parseMultipart(
313
315
 
314
316
  // Find first delimiter
315
317
  const firstIdx = bufferIndexOf(body, delimiter, offset);
316
- if (firstIdx === -1) return { fields, files };
318
+ if (firstIdx === -1) return { fields: {}, files };
317
319
  offset = firstIdx + delimiter.length;
318
320
 
319
321
  // Skip CRLF after delimiter
@@ -350,16 +352,21 @@ export function parseMultipart(
350
352
  content: Buffer.from(content),
351
353
  size: content.length,
352
354
  };
353
- // Dict keyed by field name — multiple files under same name become array
354
- if (files[disposition.name]) {
355
+ // Dict keyed by field name — multiple files under same name become array.
356
+ // A file field named __proto__/constructor/prototype is never legitimate
357
+ // and is dropped (js/remote-property-injection).
358
+ if (isUnsafeFieldName(disposition.name)) {
359
+ // skip
360
+ } else if (files[disposition.name]) {
355
361
  const existing = files[disposition.name];
356
- files[disposition.name] = Array.isArray(existing) ? [...existing, file] : [existing, file];
362
+ // Reflective write of an already-filtered file-field name (js/remote-property-injection).
363
+ Reflect.set(files, disposition.name, Array.isArray(existing) ? [...existing, file] : [existing, file]);
357
364
  } else {
358
- files[disposition.name] = file;
365
+ Reflect.set(files, disposition.name, file);
359
366
  }
360
367
  } else if (disposition.name) {
361
- // Regular field
362
- fields[disposition.name] = content.toString("utf-8");
368
+ // Regular field, nested once every part is read
369
+ pairs.push([disposition.name, content.toString("utf-8")]);
363
370
  }
364
371
 
365
372
  // Move past the delimiter
@@ -377,7 +384,7 @@ export function parseMultipart(
377
384
  }
378
385
  }
379
386
 
380
- return { fields, files };
387
+ return { fields: nestFormFields(pairs), files };
381
388
  }
382
389
 
383
390
  /**
@@ -406,13 +413,34 @@ export function saveUpload(file: UploadedFile, targetDir: string, filename?: str
406
413
  mkdirSync(targetDir, { recursive: true });
407
414
  const dest = join(targetDir, base);
408
415
  // Defence in depth: the resolved parent of the destination must be exactly
409
- // the resolved target dir (guards a pre-existing symlink at target/base).
416
+ // the resolved target dir.
410
417
  const realDir = realpathSync(targetDir);
411
418
  const realParent = realpathSync(dirname(dest));
412
419
  if (realParent !== realDir) {
413
420
  throw new Error(`refusing to write outside ${targetDir}: ${JSON.stringify(raw)}`);
414
421
  }
415
- writeFileSync(dest, file.content);
422
+ // Never write THROUGH a link already at target/base: a symlink (even a
423
+ // dangling one) leads out of targetDir, and a hard link shares its bytes
424
+ // with a file elsewhere. Such an entry is removed so the upload gets a file
425
+ // of its own; a plain file is rewritten in place and keeps its mode.
426
+ if (lstatSync(dest, { throwIfNoEntry: false })?.isSymbolicLink()) unlinkSync(dest);
427
+ const flags = constants.O_WRONLY | constants.O_CREAT | (constants.O_NOFOLLOW ?? 0);
428
+ // 0o600: a newly created upload is owner-only, never group/other readable or
429
+ // writable (CWE-732). The mode applies only when O_CREAT makes the file; a
430
+ // plain file rewritten in place keeps its own mode, per the note above.
431
+ let fd = openSync(dest, flags, 0o600);
432
+ try {
433
+ if (fstatSync(fd).nlink > 1) {
434
+ closeSync(fd);
435
+ fd = -1;
436
+ unlinkSync(dest);
437
+ fd = openSync(dest, flags | constants.O_EXCL, 0o600);
438
+ }
439
+ ftruncateSync(fd, 0);
440
+ writeFileSync(fd, file.content);
441
+ } finally {
442
+ if (fd >= 0) closeSync(fd);
443
+ }
416
444
  return dest;
417
445
  }
418
446
 
@@ -440,6 +468,138 @@ function parseDisposition(headers: string): { name: string; filename?: string }
440
468
  }
441
469
 
442
470
  function parsePartContentType(headers: string): string | null {
443
- const match = headers.match(/Content-Type:\s*(.+?)(?:\r?\n|$)/i);
444
- return match?.[1]?.trim() ?? null;
471
+ // ReDoS-safe: a single negated class captures the rest of the line (it stops
472
+ // at CR/LF on its own, so no terminator alternation and no leading [ \t]* to
473
+ // overlap). Trim the surrounding whitespace in code.
474
+ const match = headers.match(/Content-Type:([^\r\n]*)/i);
475
+ const value = match?.[1]?.trim();
476
+ return value ? value : null;
477
+ }
478
+
479
+ /** A form field's value once its name is nested: `tags[]` a list, `user[name]` an object. */
480
+ export type FormValue = string | FormValue[] | { [key: string]: FormValue };
481
+
482
+ /** A field name nested this deep or deeper keeps its flat key. Rack's param_depth_limit. */
483
+ const FORM_DEPTH_LIMIT = 32;
484
+
485
+ /** A form with more fields than this is not nested at all. Rack's params_limit. */
486
+ const FORM_FIELD_LIMIT = 4096;
487
+
488
+ /** A field name longer than this keeps its flat key. */
489
+ const FORM_NAME_LIMIT = 1024;
490
+
491
+ /**
492
+ * The three keys that reach the prototype chain. A query param, cookie or file
493
+ * field literally named one of these is never legitimate, so the flat maps
494
+ * (query / cookies / files) drop it. The nested body builder does NOT drop
495
+ * `constructor`/`prototype` - a form MAY carry `user[constructor][name]` as
496
+ * data - and stays safe a different way: its containers are prototype-less
497
+ * (`Object.create(null)` in node() below), so assigning any of these as an own
498
+ * key cannot pollute a prototype. `__proto__` segments are dropped outright
499
+ * (the kept filter). Together these make prototype pollution structurally
500
+ * impossible, not merely filtered (js/prototype-polluting-assignment,
501
+ * js/remote-property-injection).
502
+ */
503
+ const isUnsafeFieldName = (key: string): boolean =>
504
+ key === "__proto__" || key === "constructor" || key === "prototype";
505
+
506
+ /**
507
+ * Nest form fields the way PHP and Rack (tina4-ruby) nest a form: `tags[]`
508
+ * collects an array, `user[name]` builds an object, `a[][x]` an array of
509
+ * objects, and a repeated plain name keeps its last value. Used for urlencoded
510
+ * and multipart bodies alike, so a form arrives in one shape however it was
511
+ * encoded. A field that cannot nest - its name conflicts with an earlier one
512
+ * (`a=1` then `a[b]=2`), nests too deep or is longer than FORM_NAME_LIMIT -
513
+ * keeps its flat key; the rest still nest. A later plain name replaces an
514
+ * earlier nested value of that name, as Rack and parse_str do. A field with a `__proto__` segment anywhere in its name is dropped.
515
+ */
516
+ function nestFormFields(pairs: Array<[string, string]>): Record<string, FormValue> {
517
+ const kept = pairs.filter(([name]) => !/(^|[[\]])__proto__([[\]]|$)/.test(name));
518
+ if (kept.length > FORM_FIELD_LIMIT) return Object.fromEntries(kept);
519
+ const nested = new WeakSet<object>();
520
+ // Prototype-less: assigning any user key (including `constructor`/`prototype`)
521
+ // as an own property on these containers cannot reach or mutate a prototype,
522
+ // so the nested body is safe by construction (js/prototype-polluting-assignment).
523
+ const node = (): Record<string, FormValue> => {
524
+ const created: Record<string, FormValue> = Object.create(null);
525
+ nested.add(created);
526
+ return created;
527
+ };
528
+ const isNode = (value: unknown): value is Record<string, FormValue> =>
529
+ typeof value === "object" && value !== null && nested.has(value);
530
+ const own = (params: Record<string, FormValue>, key: string): FormValue | undefined =>
531
+ Object.prototype.hasOwnProperty.call(params, key) ? params[key] : undefined;
532
+ const list = (params: Record<string, FormValue>, key: string): FormValue[] => {
533
+ let current = own(params, key);
534
+ if (current === undefined) { current = []; Reflect.set(params, key, current); }
535
+ if (!Array.isArray(current)) throw new RangeError(`expected an array for '${key}'`);
536
+ return current;
537
+ };
538
+ const object = (params: Record<string, FormValue>, key: string): Record<string, FormValue> => {
539
+ let current = own(params, key);
540
+ if (current === undefined) { current = node(); Reflect.set(params, key, current); }
541
+ if (!isNode(current)) throw new RangeError(`expected an object for '${key}'`);
542
+ return current;
543
+ };
544
+ const hasKey = (params: Record<string, FormValue>, key: string): boolean => {
545
+ if (key.includes("[]")) return false;
546
+ let level: FormValue | undefined = params;
547
+ for (const part of key.split(/[[\]]+/).filter(Boolean)) {
548
+ if (!isNode(level) || own(level, part) === undefined) return false;
549
+ level = level[part];
550
+ }
551
+ return true;
552
+ };
553
+ const splitName = (name: string, depth: number): [string, string] => {
554
+ if (depth === 0) {
555
+ const start = name.indexOf("[", 1);
556
+ return start === -1 ? [name, ""] : [name.slice(0, start), name.slice(start)];
557
+ }
558
+ if (name.startsWith("[]")) return ["[]", name.slice(2)];
559
+ const close = name.indexOf("]", 1);
560
+ return name.startsWith("[") && close !== -1 ? [name.slice(1, close), name.slice(close + 1)] : [name, ""];
561
+ };
562
+ // `a[][x]`: fill the last record unless it already has `x`, else start one.
563
+ const putListItem = (items: FormValue[], after: string, value: string, depth: number): void => {
564
+ let childKey = after[2] === "[" && after.endsWith("]") ? after.slice(3, -1) : "";
565
+ if (childKey === "" || childKey.includes("[") || childKey.includes("]")) childKey = after.slice(2);
566
+ const last = items[items.length - 1];
567
+ if (isNode(last) && !hasKey(last, childKey)) put(last, childKey, value, depth + 1);
568
+ else items.push(put(node(), childKey, value, depth + 1) as FormValue);
569
+ };
570
+ // Rack::QueryParser#normalize_params, ported from tina4-ruby's form parser.
571
+ const put = (params: Record<string, FormValue>, name: string, value: string, depth: number): FormValue | undefined => {
572
+ const [key, after] = splitName(name, depth);
573
+ if (key === "") return undefined;
574
+ if (after === "" && key === "[]" && depth !== 0) return [value];
575
+ // Reflective writes into prototype-less `node()` containers (js/remote-property-injection).
576
+ if (after === "") Reflect.set(params, key, value);
577
+ else if (after === "[") Reflect.set(params, name, value);
578
+ else if (after === "[]") list(params, key).push(value);
579
+ else if (after.startsWith("[]")) putListItem(list(params, key), after, value, depth);
580
+ else put(object(params, key), after, value, depth + 1);
581
+ return params;
582
+ };
583
+ // Counts brackets only up to the limit, so a name of millions of them costs nothing.
584
+ const tooDeep = (name: string): boolean => {
585
+ let count = 0;
586
+ for (let at = name.indexOf("["); at !== -1; at = name.indexOf("[", at + 1)) {
587
+ if (++count >= FORM_DEPTH_LIMIT) return true;
588
+ }
589
+ return false;
590
+ };
591
+ const fields = node();
592
+ for (const [name, value] of kept) {
593
+ if (name.length > FORM_NAME_LIMIT || tooDeep(name)) {
594
+ Reflect.set(fields, name, value); // prototype-less container (js/remote-property-injection)
595
+ continue;
596
+ }
597
+ try {
598
+ put(fields, name, value, 0);
599
+ } catch (error) {
600
+ if (!(error instanceof RangeError)) throw error;
601
+ Reflect.set(fields, name, value); // prototype-less container (js/remote-property-injection)
602
+ }
603
+ }
604
+ return fields;
445
605
  }
@@ -7,6 +7,7 @@ file, You can obtain one at https://mozilla.org/MPL/2.0/.
7
7
  */
8
8
 
9
9
  import type { RouteHandler, RouteDefinition, RouteMeta, Middleware, MiddlewareSpec, Tina4Request, Tina4Response, WebSocketRouteHandler, WebSocketRouteDefinition } from "./types.js";
10
+ import { trimSlashes, rtrimSlashes } from "./slashTrim.js";
10
11
  import { isTruthy } from "./dotenv.js";
11
12
  import { MiddlewareRunner, isMiddlewareClass } from "./middleware.js";
12
13
 
@@ -63,7 +64,7 @@ function coerceParam(raw: string, type: string | undefined): string | number {
63
64
  */
64
65
  function joinGroupPath(prefix: string, path: string): string {
65
66
  const full = `${prefix}/${path.replace(/^\/+/, "")}`;
66
- const trimmed = `/${full.replace(/^\/+|\/+$/g, "")}`;
67
+ const trimmed = `/${trimSlashes(full)}`;
67
68
  return trimmed.replace(/\/+/g, "/");
68
69
  }
69
70
 
@@ -369,7 +370,7 @@ export class Router {
369
370
  path.length > 1 &&
370
371
  path.endsWith("/")
371
372
  ) {
372
- const stripped = path.replace(/\/+$/, "");
373
+ const stripped = rtrimSlashes(path);
373
374
  if (stripped.length > 0) {
374
375
  const retry = this.matchRoute(routes, stripped);
375
376
  if (retry) return retry;
@@ -7,13 +7,14 @@ file, You can obtain one at https://mozilla.org/MPL/2.0/.
7
7
  */
8
8
 
9
9
  import { createServer, IncomingMessage, ServerResponse } from "node:http";
10
+ import { trimSlashes } from "./slashTrim.js";
10
11
  import { createTina4HttpServer, guardUnsafeHeaders, sendTransportRejection } from "./transport.js";
11
12
  import { randomBytes } from "node:crypto";
12
13
  import { resolve, dirname, join, relative } from "node:path";
13
14
  import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
14
15
  import { isatty } from "node:tty";
15
16
  import { fileURLToPath } from "node:url";
16
- import { execFileSync, exec } from "node:child_process";
17
+ import { execFileSync, execFile } from "node:child_process";
17
18
  import cluster from "node:cluster";
18
19
  import os from "node:os";
19
20
  import type { Socket } from "node:net";
@@ -37,6 +38,7 @@ import {
37
38
  compressionEtagIntercept,
38
39
  sessionAutoStart,
39
40
  } from "./dispatchPipeline.js";
41
+ import { runWithFormTokenSession, currentFormTokenSessionId } from "./session.js";
40
42
  import { createResponse, setDefaultTemplatesDir, wantsJson, negotiatedErrorBody } from "./response.js";
41
43
  import { MiddlewareChain, MiddlewareRunner, cors, requestLogger, isMiddlewareClass, attachCsrfFromEnv, SecurityHeadersMiddleware } from "./middleware.js";
42
44
  import { tryServeStatic } from "./static.js";
@@ -398,11 +400,24 @@ export function shouldOpenBrowser(isDevelopment: boolean, noBrowser = false): bo
398
400
  * Open the user's default browser after a short delay so the server is ready.
399
401
  */
400
402
  function openBrowser(url: string) {
401
- // exec imported at top of file (ESM)
403
+ // Only ever hand a well-formed http(s) dev URL to the OS browser launcher.
404
+ // TINA4_HOST / HOST are operator configuration, not remote input, but a value
405
+ // carrying shell or URL metacharacters must never reach the launch argv — an
406
+ // unusual host means "do not auto-open", never "run this". Host is a bare
407
+ // hostname / IPv4 / bracketed IPv6 literal; port is digits only. This guard
408
+ // is what sanitizes the env-derived host (js/indirect-command-line-injection,
409
+ // js/shell-command-constructed-from-input).
410
+ if (!/^https?:\/\/([A-Za-z0-9.-]+|\[[0-9A-Fa-f:]+\])(:\d{1,5})?\/?$/.test(url)) return;
411
+ // No shell on any platform: each launcher is an explicit binary and the URL
412
+ // is a single argv entry, never interpreted by a command processor. Windows
413
+ // opens via rundll32's FileProtocolHandler, NOT `cmd /c start` — `cmd` is a
414
+ // shell, so routing a host-derived URL through it is a command-injection sink
415
+ // (js/indirect-command-line-injection, js/shell-command-constructed-from-input);
416
+ // rundll32 is not, and it also avoids `start`'s argument-quoting pitfalls.
402
417
  setTimeout(() => {
403
- if (process.platform === "darwin") exec(`open ${url}`);
404
- else if (process.platform === "win32") exec(`start "" "${url}"`);
405
- else exec(`xdg-open ${url}`);
418
+ if (process.platform === "darwin") execFile("open", [url]);
419
+ else if (process.platform === "win32") execFile("rundll32", ["url.dll,FileProtocolHandler", url]);
420
+ else execFile("xdg-open", [url]);
406
421
  }, 2000);
407
422
  }
408
423
 
@@ -661,7 +676,7 @@ export function resetTemplateCache(): void {
661
676
  export function resolveTemplate(pathname: string, templatesDir: string): string | null {
662
677
  if (!templateAutoRoutingEnabled()) return null;
663
678
 
664
- const cleanPath = pathname.replace(/^\/+/, "").replace(/\/+$/, "") || "index";
679
+ const cleanPath = trimSlashes(pathname) || "index";
665
680
  const isDev = (process.env.TINA4_DEBUG ?? "false").toLowerCase() === "true";
666
681
 
667
682
  if (isDev) {
@@ -1078,7 +1093,10 @@ export function resolveHandlerArgs(
1078
1093
  routeParams: Record<string, unknown>,
1079
1094
  ): unknown[] {
1080
1095
  const fnStr = (handler as { toString(): string }).toString();
1081
- const argMatch = fnStr.match(/^(?:async\s*)?(?:function\s*\w*)?\s*\(([^)]*)\)/);
1096
+ // ReDoS-safe: the old `(?:function\s*\w*)?\s*\(` had two `\s*` straddling `\w*`
1097
+ // before `\(`, so whitespace could be split two ways (O(n^2)). A single
1098
+ // optional name and one trailing `\s*` removes the ambiguity.
1099
+ const argMatch = fnStr.match(/^(?:async\s*)?(?:function(?:\s+\w+)?\s*)?\(([^)]*)\)/);
1082
1100
  const argNames = argMatch?.[1]?.split(",").map((a: string) => a.trim().replace(/[:=].*/, "")) ?? [];
1083
1101
  const filteredArgs = argNames.filter((n: string) => n.length > 0);
1084
1102
  if (filteredArgs.length === 0) return [];
@@ -1705,8 +1723,9 @@ export async function buildDispatchContext(router: Router, base?: string): Promi
1705
1723
 
1706
1724
  let frondEngine: DispatchContext["frondEngine"] = null;
1707
1725
  try {
1708
- const { Frond, setCspNonceProvider } = await import("../../frond/src/engine.js");
1726
+ const { Frond, setCspNonceProvider, setFormTokenSessionProvider } = await import("../../frond/src/engine.js");
1709
1727
  setCspNonceProvider(currentCspNonce);
1728
+ setFormTokenSessionProvider(currentFormTokenSessionId);
1710
1729
  frondEngine = new Frond(templatesDir);
1711
1730
  } catch {
1712
1731
  // Frond not available — template-route fallback stays inert, same guard startServer() uses.
@@ -1918,7 +1937,9 @@ export async function runDispatch(
1918
1937
  // two requests interleaving on the one event loop never share it.
1919
1938
  const requestNonce = generateNonce();
1920
1939
  return Log.runWithRequestId(requestId, () =>
1921
- runWithCspNonce(requestNonce, () => dispatchInner(ctx, rawReq, rawRes, requestId)),
1940
+ runWithCspNonce(requestNonce, () =>
1941
+ runWithFormTokenSession(() => dispatchInner(ctx, rawReq, rawRes, requestId)),
1942
+ ),
1922
1943
  );
1923
1944
  }
1924
1945
 
@@ -2156,8 +2177,9 @@ export async function startServer(config?: Tina4Config): Promise<{
2156
2177
  let frondEngine: any = null;
2157
2178
  setDefaultTemplatesDir(templatesDir);
2158
2179
  try {
2159
- const { Frond, setCspNonceProvider } = await import("../../frond/src/engine.js");
2180
+ const { Frond, setCspNonceProvider, setFormTokenSessionProvider } = await import("../../frond/src/engine.js");
2160
2181
  setCspNonceProvider(currentCspNonce);
2182
+ setFormTokenSessionProvider(currentFormTokenSessionId);
2161
2183
  frondEngine = new Frond(templatesDir);
2162
2184
 
2163
2185
  // Always-on Frond {% live %} refresh endpoint. Re-renders a server-rendered
@@ -32,6 +32,7 @@ file, You can obtain one at https://mozilla.org/MPL/2.0/.
32
32
  * session.get("user"); // { name: "Alice" }
33
33
  * session.destroy();
34
34
  */
35
+ import { AsyncLocalStorage } from "node:async_hooks";
35
36
  import { createHash, randomBytes } from "node:crypto";
36
37
  import { existsSync, mkdirSync, readFileSync, writeFileSync, unlinkSync, readdirSync } from "node:fs";
37
38
  import { join } from "node:path";
@@ -1060,3 +1061,46 @@ export function sessionCookieToSend(
1060
1061
  const socketEncrypted = (rawReq.socket as { encrypted?: boolean } | undefined)?.encrypted === true;
1061
1062
  return buildSessionCookie(newSid, ttl, undefined, forwardedProto, socketEncrypted);
1062
1063
  }
1064
+
1065
+ // ── Form-token session (CSRF binding) ─────────────────────────────────
1066
+ /**
1067
+ * The session of the request being dispatched, for CSRF form-token binding.
1068
+ *
1069
+ * Frond's form_token() reads it through the provider core registers
1070
+ * (setFormTokenSessionProvider), so a token is bound to the session that
1071
+ * rendered it. It lives in an AsyncLocalStorage, like the CSP nonce, so two
1072
+ * requests interleaving on the one event loop never bind to each other's session.
1073
+ */
1074
+ interface BindableSession {
1075
+ getSessionId(): string | null;
1076
+ isFresh(): boolean;
1077
+ set(key: string, value: unknown): void;
1078
+ }
1079
+
1080
+ const sessionStore = new AsyncLocalStorage<{ session: BindableSession | null }>();
1081
+
1082
+ /** Run `fn` with a request scope the dispatcher publishes its session into. */
1083
+ export function runWithFormTokenSession<T>(fn: () => T): T {
1084
+ return sessionStore.run({ session: null }, fn);
1085
+ }
1086
+
1087
+ /** Publish the current request's session (no-op outside a request scope). */
1088
+ export function setFormTokenSession(session: BindableSession | null): void {
1089
+ const store = sessionStore.getStore();
1090
+ if (store) store.session = session;
1091
+ }
1092
+
1093
+ /**
1094
+ * The current request's session id, or "" outside a request.
1095
+ *
1096
+ * A session that holds nothing is sent no cookie, so the form's own POST would
1097
+ * arrive under a newly minted id and fail its own token. Rendering a bound
1098
+ * token therefore stores a marker, which makes the session persist.
1099
+ */
1100
+ export function currentFormTokenSessionId(): string {
1101
+ const session = sessionStore.getStore()?.session;
1102
+ const sid = session?.getSessionId();
1103
+ if (!session || !sid) return "";
1104
+ if (session.isFresh()) session.set("_tina4_csrf", true);
1105
+ return sid;
1106
+ }