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.
- package/CLAUDE.md +2 -2
- package/package.json +5 -9
- package/packages/cli/dist/bin.js +1045 -518
- package/packages/core/dist/index.js +1042 -513
- package/packages/core/src/aiClient.ts +2 -1
- package/packages/core/src/api.ts +2 -1
- package/packages/core/src/auth.ts +1 -1
- package/packages/core/src/cache.ts +6 -1
- package/packages/core/src/devAdmin.ts +176 -123
- package/packages/core/src/dispatchPipeline.ts +4 -0
- package/packages/core/src/docs.ts +5 -3
- package/packages/core/src/errorOverlay.ts +18 -9
- package/packages/core/src/index.ts +2 -1
- package/packages/core/src/mcp.ts +53 -16
- package/packages/core/src/messenger.ts +22 -30
- package/packages/core/src/middleware.ts +8 -1
- package/packages/core/src/optionalPackage.ts +2 -2
- package/packages/core/src/plan.ts +3 -3
- package/packages/core/src/push.ts +1 -1
- package/packages/core/src/request.ts +185 -25
- package/packages/core/src/router.ts +3 -2
- package/packages/core/src/server.ts +32 -10
- package/packages/core/src/session.ts +44 -0
- package/packages/core/src/slashTrim.ts +39 -0
- package/packages/core/src/wsdl.ts +66 -48
- package/packages/core/templates/crud/form.twig +5 -5
- package/packages/core/templates/crud/table.twig +2 -2
- package/packages/core/templates/errors/302.twig +4 -3
- package/packages/frond/dist/index.js +206 -64
- package/packages/frond/src/engine.ts +278 -69
- package/packages/frond/src/index.ts +1 -1
- package/packages/orm/dist/index.js +1026 -499
- package/packages/orm/src/crudRender.ts +5 -5
- package/packages/orm/src/crudTable.ts +1 -1
- package/packages/orm/src/database.ts +15 -29
- package/packages/orm/src/databaseResult.ts +11 -1
- package/packages/orm/src/realtime/index.ts +1 -0
- package/packages/orm/src/realtime/realtime.ts +2 -1
- package/packages/orm/src/realtime/s3Client.ts +276 -0
- package/packages/orm/src/realtime/storage.ts +19 -60
- package/packages/orm/src/sqlTranslator.ts +58 -42
- package/packages/swagger/dist/index.js +25 -4
- package/packages/swagger/src/generator.ts +7 -4
- package/packages/swagger/src/ui.ts +2 -1
- package/types/core/src/devAdmin.d.ts +40 -0
- package/types/core/src/index.d.ts +2 -1
- package/types/core/src/middleware.d.ts +1 -0
- package/types/core/src/optionalPackage.d.ts +2 -2
- package/types/core/src/request.d.ts +5 -1
- package/types/core/src/session.d.ts +25 -0
- package/types/core/src/slashTrim.d.ts +6 -0
- package/types/frond/src/engine.d.ts +11 -0
- package/types/frond/src/index.d.ts +1 -1
- package/types/orm/src/realtime/index.d.ts +1 -0
- package/types/orm/src/realtime/s3Client.d.ts +48 -0
- 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.
|
|
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.
|
|
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
|
-
|
|
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
|
|
15
|
-
*
|
|
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
|
|
94
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
304
|
-
const
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
365
|
+
Reflect.set(files, disposition.name, file);
|
|
359
366
|
}
|
|
360
367
|
} else if (disposition.name) {
|
|
361
|
-
// Regular field
|
|
362
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
444
|
-
|
|
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
|
|
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
|
|
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,
|
|
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
|
-
//
|
|
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")
|
|
404
|
-
else if (process.platform === "win32")
|
|
405
|
-
else
|
|
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
|
|
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
|
-
|
|
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, () =>
|
|
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
|
+
}
|