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.
- package/CLAUDE.md +2 -2
- package/package.json +5 -9
- package/packages/cli/dist/bin.js +1074 -521
- package/packages/core/dist/index.js +1071 -516
- package/packages/core/public/bundled-component-provenance.json +2 -2
- package/packages/core/public/js/tina4-dev-admin-split.html +202 -0
- package/packages/core/public/js/tina4-dev-admin.min.js +147 -138
- 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 +209 -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/metrics.ts +5 -1
- package/packages/core/src/middleware.ts +8 -1
- package/packages/core/src/optionalPackage.ts +2 -2
- package/packages/core/src/plan.ts +4 -4
- 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 +1055 -502
- 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
package/packages/core/src/mcp.ts
CHANGED
|
@@ -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
|
|
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:
|
|
869
|
-
//
|
|
870
|
-
//
|
|
871
|
-
|
|
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
|
-
|
|
874
|
+
realRoot = fs.realpathSync(projectRoot);
|
|
875
875
|
} catch {
|
|
876
|
-
|
|
876
|
+
return resolved; // a root that no longer exists holds nothing to link through
|
|
877
877
|
}
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
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
|
-
|
|
1962
|
-
|
|
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.
|
|
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";
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|
|
@@ -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:
|
|
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(
|
|
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;
|