tina4-nodejs 3.13.149 → 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 (60) hide show
  1. package/CLAUDE.md +2 -2
  2. package/package.json +5 -9
  3. package/packages/cli/dist/bin.js +1074 -521
  4. package/packages/core/dist/index.js +1071 -516
  5. package/packages/core/public/bundled-component-provenance.json +2 -2
  6. package/packages/core/public/js/tina4-dev-admin-split.html +202 -0
  7. package/packages/core/public/js/tina4-dev-admin.min.js +147 -138
  8. package/packages/core/src/aiClient.ts +2 -1
  9. package/packages/core/src/api.ts +2 -1
  10. package/packages/core/src/auth.ts +1 -1
  11. package/packages/core/src/cache.ts +6 -1
  12. package/packages/core/src/devAdmin.ts +209 -123
  13. package/packages/core/src/dispatchPipeline.ts +4 -0
  14. package/packages/core/src/docs.ts +5 -3
  15. package/packages/core/src/errorOverlay.ts +18 -9
  16. package/packages/core/src/index.ts +2 -1
  17. package/packages/core/src/mcp.ts +53 -16
  18. package/packages/core/src/messenger.ts +22 -30
  19. package/packages/core/src/metrics.ts +5 -1
  20. package/packages/core/src/middleware.ts +8 -1
  21. package/packages/core/src/optionalPackage.ts +2 -2
  22. package/packages/core/src/plan.ts +4 -4
  23. package/packages/core/src/push.ts +1 -1
  24. package/packages/core/src/request.ts +185 -25
  25. package/packages/core/src/router.ts +3 -2
  26. package/packages/core/src/server.ts +32 -10
  27. package/packages/core/src/session.ts +44 -0
  28. package/packages/core/src/slashTrim.ts +39 -0
  29. package/packages/core/src/wsdl.ts +66 -48
  30. package/packages/core/templates/crud/form.twig +5 -5
  31. package/packages/core/templates/crud/table.twig +2 -2
  32. package/packages/core/templates/errors/302.twig +4 -3
  33. package/packages/frond/dist/index.js +206 -64
  34. package/packages/frond/src/engine.ts +278 -69
  35. package/packages/frond/src/index.ts +1 -1
  36. package/packages/orm/dist/index.js +1055 -502
  37. package/packages/orm/src/crudRender.ts +5 -5
  38. package/packages/orm/src/crudTable.ts +1 -1
  39. package/packages/orm/src/database.ts +15 -29
  40. package/packages/orm/src/databaseResult.ts +11 -1
  41. package/packages/orm/src/realtime/index.ts +1 -0
  42. package/packages/orm/src/realtime/realtime.ts +2 -1
  43. package/packages/orm/src/realtime/s3Client.ts +276 -0
  44. package/packages/orm/src/realtime/storage.ts +19 -60
  45. package/packages/orm/src/sqlTranslator.ts +58 -42
  46. package/packages/swagger/dist/index.js +25 -4
  47. package/packages/swagger/src/generator.ts +7 -4
  48. package/packages/swagger/src/ui.ts +2 -1
  49. package/types/core/src/devAdmin.d.ts +40 -0
  50. package/types/core/src/index.d.ts +2 -1
  51. package/types/core/src/middleware.d.ts +1 -0
  52. package/types/core/src/optionalPackage.d.ts +2 -2
  53. package/types/core/src/request.d.ts +5 -1
  54. package/types/core/src/session.d.ts +25 -0
  55. package/types/core/src/slashTrim.d.ts +6 -0
  56. package/types/frond/src/engine.d.ts +11 -0
  57. package/types/frond/src/index.d.ts +1 -1
  58. package/types/orm/src/realtime/index.d.ts +1 -0
  59. package/types/orm/src/realtime/s3Client.d.ts +48 -0
  60. package/types/orm/src/realtime/storage.d.ts +9 -7
@@ -23,6 +23,7 @@ file, You can obtain one at https://mozilla.org/MPL/2.0/.
23
23
  // Built-in dev tools auto-register when TINA4_DEBUG=true and running on localhost.
24
24
 
25
25
  import * as fs from "node:fs";
26
+ import { rtrimSlashes } from "./slashTrim.js";
26
27
  import * as path from "node:path";
27
28
  import * as os from "node:os";
28
29
  import { spawnSync } from "node:child_process";
@@ -356,7 +357,7 @@ export class McpServer {
356
357
  private _sseChannels: Map<string, { buffer: string[]; wake: (() => void) | null }> = new Map();
357
358
 
358
359
  constructor(mcpPath: string, name = "Tina4 MCP", version = "1.0.0") {
359
- this.path = mcpPath.replace(/\/+$/, "");
360
+ this.path = rtrimSlashes(mcpPath);
360
361
  this.name = name;
361
362
  this.version = version;
362
363
  McpServer._instances.push(this);
@@ -865,26 +866,60 @@ function safePath(projectRoot: string, relPath: string): string {
865
866
  if (resolved !== projectRoot && !resolved.startsWith(rootPrefix)) {
866
867
  throw new Error(`Path escapes project directory: ${relPath}`);
867
868
  }
868
- // Belt-and-braces against symlink escapes: if the path exists, canonicalise
869
- // it and re-check containment. A symlink inside the tree pointing outside
870
- // would otherwise slip past the textual check. New paths (parent not yet
871
- // created) have no realpath and rely on the resolve() containment above.
872
- let real: string | null = null;
869
+ // Belt-and-braces against symlink escapes: canonicalise the path and
870
+ // re-check containment. A symlink inside the tree pointing outside would
871
+ // otherwise slip past the textual check.
872
+ let realRoot: string;
873
873
  try {
874
- real = fs.realpathSync(resolved);
874
+ realRoot = fs.realpathSync(projectRoot);
875
875
  } catch {
876
- real = null; // not created yet — textual guard above holds
876
+ return resolved; // a root that no longer exists holds nothing to link through
877
877
  }
878
- if (real !== null) {
879
- const realRoot = fs.realpathSync(projectRoot);
880
- const realPrefix = realRoot.endsWith(path.sep) ? realRoot : realRoot + path.sep;
881
- if (real !== realRoot && !real.startsWith(realPrefix)) {
882
- throw new Error(`Path escapes project directory: ${relPath}`);
883
- }
878
+ const real = realPathOf(resolved);
879
+ const realPrefix = realRoot.endsWith(path.sep) ? realRoot : realRoot + path.sep;
880
+ if (real === null || (real !== realRoot && !real.startsWith(realPrefix))) {
881
+ throw new Error(`Path escapes project directory: ${relPath}`);
884
882
  }
885
883
  return resolved;
886
884
  }
887
885
 
886
+ /**
887
+ * Where a path will land once every symlink in it is followed, including the
888
+ * parts that do not exist yet: a NEW file is created through its deepest
889
+ * existing parent, and a dangling symlink through the place it names. Null when
890
+ * that cannot be worked out (a link loop, an unreadable link).
891
+ */
892
+ function realPathOf(p: string, hops = 0): string | null {
893
+ let probe = p;
894
+ while (!fs.existsSync(probe) && !isSymlink(probe) && path.dirname(probe) !== probe) {
895
+ probe = path.dirname(probe);
896
+ }
897
+ try {
898
+ // .native: the JS realpathSync collapses "link/.." as text first.
899
+ return fs.realpathSync.native(probe);
900
+ } catch {
901
+ // A dangling symlink. Its target is joined as text, not normalised, so a
902
+ // ".." after a linked component is resolved the way the OS will follow it.
903
+ if (hops >= 40) return null;
904
+ let target: string;
905
+ try {
906
+ target = fs.readlinkSync(probe);
907
+ } catch {
908
+ return null;
909
+ }
910
+ const base = path.isAbsolute(target) ? target : path.dirname(probe) + path.sep + target;
911
+ return realPathOf(base + p.slice(probe.length), hops + 1);
912
+ }
913
+ }
914
+
915
+ function isSymlink(p: string): boolean {
916
+ try {
917
+ return fs.lstatSync(p).isSymbolicLink();
918
+ } catch {
919
+ return false;
920
+ }
921
+ }
922
+
888
923
  /**
889
924
  * Redact sensitive environment variable values.
890
925
  */
@@ -1958,8 +1993,10 @@ export function registerDevTools(server: McpServer): void {
1958
1993
  // 2. Coder-path normalization.
1959
1994
  rel = normalizeCoderPath(projectRoot, rel);
1960
1995
  // 3. Safe path resolution (sandbox check).
1961
- const resolved = path.resolve(projectRoot, rel);
1962
- if (!resolved.startsWith(projectRoot)) {
1996
+ let resolved: string;
1997
+ try {
1998
+ resolved = safePath(projectRoot, rel);
1999
+ } catch {
1963
2000
  return { error: `Path escapes project directory: ${rel}` };
1964
2001
  }
1965
2002
  // 4. Existence check.
@@ -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";
@@ -69,7 +69,11 @@ export function enginePath(): string | null {
69
69
  function runEngine(target: string): Record<string, any> {
70
70
  const binary = enginePath();
71
71
  if (!binary) throw new MetricsEngineError(`tina4 not found on PATH - ${INSTALL_HINT}`);
72
- const processResult = spawnSync(binary, ["metrics", "--path", target, "--json"], {
72
+ // --no-history: a dashboard READ must never write .tina4-metrics.json.
73
+ // Without it every scan records history, the file watcher sees the write and
74
+ // reloads the page, which re-scans - a self-inflicted ~2-second reload loop
75
+ // that makes the dashboard unusable.
76
+ const processResult = spawnSync(binary, ["metrics", "--path", target, "--json", "--no-history"], {
73
77
  encoding: "utf8",
74
78
  timeout: 60_000,
75
79
  maxBuffer: 64 * 1024 * 1024,
@@ -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
 
@@ -533,7 +533,7 @@ export const Plan = {
533
533
  const title = currentPlan.title || target;
534
534
  const goal = currentPlan.goal || "";
535
535
 
536
- const aiUrl = process.env.TINA4_AI_URL || "http://localhost:11437/api/chat";
536
+ const aiUrl = process.env.TINA4_AI_URL || "http://localhost:11435/api/chat";
537
537
  const aiModel = process.env.TINA4_AI_MODEL || "qwen2.5-coder:14b";
538
538
  const prompts = fleshPrompt(title, goal, existing, prompt);
539
539
  const fetched = await fetchFleshReply(aiUrl, aiModel, prompts);
@@ -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;