tina4-nodejs 3.13.91 → 3.13.94
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 +16 -3
- package/README.md +1 -1
- package/package.json +12 -9
- package/packages/cli/dist/bin.js +1312 -987
- package/packages/core/dist/index.js +1312 -987
- package/packages/core/src/auth.ts +141 -21
- package/packages/core/src/devMailbox.ts +20 -44
- package/packages/core/src/index.ts +2 -2
- package/packages/core/src/messenger.ts +72 -0
- package/packages/core/src/queueBackends/kafkaBackend.ts +108 -12
- package/packages/core/src/sessionHandlers/childError.ts +72 -0
- package/packages/core/src/sessionHandlers/mongoClient.ts +9 -3
- package/packages/core/src/sessionHandlers/redisHandler.ts +18 -5
- package/packages/core/src/sessionHandlers/respClient.ts +5 -1
- package/packages/frond/dist/index.js +74 -31
- package/packages/frond/src/engine.ts +99 -33
- package/packages/orm/dist/index.js +3102 -2777
- package/packages/orm/src/adapters/sqlite.ts +4 -1
- package/packages/orm/src/database.ts +108 -8
- package/types/cli/src/bin.d.ts +92 -0
- package/types/cli/src/commands/build.d.ts +2 -0
- package/types/cli/src/commands/generate.d.ts +47 -0
- package/types/cli/src/commands/init.d.ts +1 -0
- package/types/cli/src/commands/metrics.d.ts +6 -0
- package/types/cli/src/commands/migrate.d.ts +1 -0
- package/types/cli/src/commands/migrateCreate.d.ts +1 -0
- package/types/cli/src/commands/migrateRollback.d.ts +1 -0
- package/types/cli/src/commands/migrateStatus.d.ts +1 -0
- package/types/cli/src/commands/queue.d.ts +20 -0
- package/types/cli/src/commands/routes.d.ts +1 -0
- package/types/cli/src/commands/seed.d.ts +1 -0
- package/types/cli/src/commands/serve.d.ts +6 -0
- package/types/cli/src/commands/test.d.ts +1 -0
- package/types/core/src/ai.d.ts +64 -0
- package/types/core/src/api.d.ts +262 -0
- package/types/core/src/auth.d.ts +154 -0
- package/types/core/src/authGate.d.ts +20 -0
- package/types/core/src/background.d.ts +34 -0
- package/types/core/src/cache.d.ts +160 -0
- package/types/core/src/constants.d.ts +38 -0
- package/types/core/src/container.d.ts +44 -0
- package/types/core/src/context/chunker.d.ts +31 -0
- package/types/core/src/context/index.d.ts +93 -0
- package/types/core/src/devAdmin.d.ts +179 -0
- package/types/core/src/devMailbox.d.ts +54 -0
- package/types/core/src/docs.d.ts +141 -0
- package/types/core/src/docsAutoDiscovery.d.ts +6 -0
- package/types/core/src/dotenv.d.ts +65 -0
- package/types/core/src/env.d.ts +28 -0
- package/types/core/src/errorOverlay.d.ts +36 -0
- package/types/core/src/events.d.ts +75 -0
- package/types/core/src/fakeData.d.ts +55 -0
- package/types/core/src/feedback.d.ts +90 -0
- package/types/core/src/graphql.d.ts +207 -0
- package/types/core/src/health.d.ts +22 -0
- package/types/core/src/htmlElement.d.ts +75 -0
- package/types/core/src/i18n.d.ts +37 -0
- package/types/core/src/index.d.ts +93 -0
- package/types/core/src/job.d.ts +39 -0
- package/types/core/src/logger.d.ts +123 -0
- package/types/core/src/mcp.d.ts +248 -0
- package/types/core/src/messenger.d.ts +191 -0
- package/types/core/src/metrics.d.ts +77 -0
- package/types/core/src/middleware.d.ts +207 -0
- package/types/core/src/mqtt.d.ts +257 -0
- package/types/core/src/mqttMessage.d.ts +67 -0
- package/types/core/src/plan.d.ts +96 -0
- package/types/core/src/projectIndex.d.ts +56 -0
- package/types/core/src/queue.d.ts +219 -0
- package/types/core/src/queueBackends/kafkaBackend.d.ts +117 -0
- package/types/core/src/queueBackends/liteBackend.d.ts +119 -0
- package/types/core/src/queueBackends/mongoBackend.d.ts +97 -0
- package/types/core/src/queueBackends/rabbitmqBackend.d.ts +55 -0
- package/types/core/src/rateLimiter.d.ts +49 -0
- package/types/core/src/request.d.ts +25 -0
- package/types/core/src/response.d.ts +28 -0
- package/types/core/src/routeDiscovery.d.ts +12 -0
- package/types/core/src/router.d.ts +355 -0
- package/types/core/src/scss.d.ts +19 -0
- package/types/core/src/server.d.ts +131 -0
- package/types/core/src/service.d.ts +115 -0
- package/types/core/src/session.d.ts +256 -0
- package/types/core/src/sessionHandlers/childError.d.ts +34 -0
- package/types/core/src/sessionHandlers/databaseHandler.d.ts +42 -0
- package/types/core/src/sessionHandlers/mongoClient.d.ts +24 -0
- package/types/core/src/sessionHandlers/mongoHandler.d.ts +61 -0
- package/types/core/src/sessionHandlers/redisHandler.d.ts +60 -0
- package/types/core/src/sessionHandlers/respClient.d.ts +22 -0
- package/types/core/src/sessionHandlers/valkeyHandler.d.ts +65 -0
- package/types/core/src/static.d.ts +2 -0
- package/types/core/src/test.d.ts +94 -0
- package/types/core/src/testClient.d.ts +36 -0
- package/types/core/src/testing.d.ts +58 -0
- package/types/core/src/types.d.ts +219 -0
- package/types/core/src/validator.d.ts +52 -0
- package/types/core/src/websocket.d.ts +376 -0
- package/types/core/src/websocketBackplane.d.ts +166 -0
- package/types/core/src/websocketConnection.d.ts +54 -0
- package/types/core/src/wsdl.d.ts +101 -0
- package/types/frond/src/engine.d.ts +263 -0
- package/types/frond/src/index.d.ts +2 -0
- package/types/orm/src/adapters/firebird.d.ts +138 -0
- package/types/orm/src/adapters/mongodb.d.ts +81 -0
- package/types/orm/src/adapters/mssql.d.ts +70 -0
- package/types/orm/src/adapters/mysql.d.ts +66 -0
- package/types/orm/src/adapters/odbc.d.ts +97 -0
- package/types/orm/src/adapters/postgres.d.ts +85 -0
- package/types/orm/src/adapters/sqlite.d.ts +56 -0
- package/types/orm/src/autoCrud.d.ts +73 -0
- package/types/orm/src/baseModel.d.ts +391 -0
- package/types/orm/src/cachedDatabase.d.ts +177 -0
- package/types/orm/src/database.d.ts +609 -0
- package/types/orm/src/databaseResult.d.ts +85 -0
- package/types/orm/src/docstore.d.ts +182 -0
- package/types/orm/src/fakeData.d.ts +22 -0
- package/types/orm/src/index.d.ts +40 -0
- package/types/orm/src/migration.d.ts +275 -0
- package/types/orm/src/model.d.ts +7 -0
- package/types/orm/src/query.d.ts +14 -0
- package/types/orm/src/queryBuilder.d.ts +173 -0
- package/types/orm/src/realtime/index.d.ts +7 -0
- package/types/orm/src/realtime/models/attachment.d.ts +43 -0
- package/types/orm/src/realtime/models/channel.d.ts +32 -0
- package/types/orm/src/realtime/models/channelMember.d.ts +32 -0
- package/types/orm/src/realtime/models/message.d.ts +36 -0
- package/types/orm/src/realtime/models/workspace.d.ts +26 -0
- package/types/orm/src/realtime/realtime.d.ts +24 -0
- package/types/orm/src/realtime/storage.d.ts +61 -0
- package/types/orm/src/seeder.d.ts +118 -0
- package/types/orm/src/sqlTranslator.d.ts +134 -0
- package/types/orm/src/types.d.ts +138 -0
- package/types/orm/src/validation.d.ts +6 -0
- package/types/swagger/src/generator.d.ts +46 -0
- package/types/swagger/src/index.d.ts +2 -0
- package/types/swagger/src/ui.d.ts +11 -0
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tina4 session handlers — turn a failed `execFileSync` child into a readable cause.
|
|
3
|
+
*
|
|
4
|
+
* The session-handler interface is synchronous but every backend client is async,
|
|
5
|
+
* so each command runs in a short-lived `node -e` child. When that child fails,
|
|
6
|
+
* `execFileSync` throws an error whose `.message` begins "Command failed:" and
|
|
7
|
+
* then embeds THE ENTIRE GENERATED SCRIPT — kilobytes of source with the real
|
|
8
|
+
* reason nowhere in it. Every handler used to throw exactly that, so an
|
|
9
|
+
* operator debugging a Redis outage got a wall of JavaScript instead of
|
|
10
|
+
* "connect ECONNREFUSED 127.0.0.1:6379".
|
|
11
|
+
*
|
|
12
|
+
* The children already write the real reason to stderr; `execFileSync` captures
|
|
13
|
+
* it on `err.stderr`. This module is the ONE place that prefers it, so the three
|
|
14
|
+
* call sites (respClient, mongoClient, redisHandler's npm path) cannot drift.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
/** Longest fallback we will pass through when there is no usable stderr. */
|
|
18
|
+
const MAX_FALLBACK = 200;
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Extract the most useful one-line cause from a thrown `execFileSync` error.
|
|
22
|
+
*
|
|
23
|
+
* Order of preference:
|
|
24
|
+
* 1. the child's own stderr — what it actually reported;
|
|
25
|
+
* 2. a timeout, named as such (a SIGTERM kill leaves stderr empty, so without
|
|
26
|
+
* this the caller would see the useless generic message);
|
|
27
|
+
* 3. a non-zero exit code with no output at all;
|
|
28
|
+
* 4. the error's own message, first line only and length-capped, so the
|
|
29
|
+
* generated script can never be dumped into a log.
|
|
30
|
+
*/
|
|
31
|
+
export function childFailureReason(err: unknown): string {
|
|
32
|
+
const e = (err ?? {}) as {
|
|
33
|
+
stderr?: Buffer | string;
|
|
34
|
+
message?: string;
|
|
35
|
+
signal?: string | null;
|
|
36
|
+
status?: number | null;
|
|
37
|
+
code?: string;
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
const stderr = String(e.stderr ?? "").trim();
|
|
41
|
+
if (stderr !== "") {
|
|
42
|
+
return firstLine(stderr);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// execFileSync's `timeout` option kills the child with a signal, so it exits
|
|
46
|
+
// with NOTHING on stderr. Say "timed out" rather than "Command failed".
|
|
47
|
+
if (e.code === "ETIMEDOUT" || e.signal) {
|
|
48
|
+
return `timed out or was killed (${e.code ?? e.signal})`;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
if (typeof e.status === "number" && e.status !== 0) {
|
|
52
|
+
return `child exited with code ${e.status} and no output`;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
return firstLine(String(e.message ?? "unknown error"));
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** First line of `text`, capped at MAX_FALLBACK characters. */
|
|
59
|
+
function firstLine(text: string): string {
|
|
60
|
+
const line = text.split("\n", 1)[0] ?? "";
|
|
61
|
+
return line.length > MAX_FALLBACK ? `${line.slice(0, MAX_FALLBACK)}...` : line;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Build the Error a session handler throws when its child command failed.
|
|
66
|
+
*
|
|
67
|
+
* `label` names the backend ("Redis", "Valkey", "MongoDB") so the message says
|
|
68
|
+
* which one broke; the wording is shared so all three read alike.
|
|
69
|
+
*/
|
|
70
|
+
export function childFailureError(label: string, err: unknown): Error {
|
|
71
|
+
return new Error(`${label} command failed: ${childFailureReason(err)}`);
|
|
72
|
+
}
|
|
@@ -22,6 +22,7 @@
|
|
|
22
22
|
* writes).
|
|
23
23
|
*/
|
|
24
24
|
import { execFileSync } from "node:child_process";
|
|
25
|
+
import { childFailureError } from "./childError.js";
|
|
25
26
|
|
|
26
27
|
export interface MongoTarget {
|
|
27
28
|
host: string;
|
|
@@ -90,8 +91,10 @@ export function mongoCommandSync(
|
|
|
90
91
|
process.stdout.write(out, () => process.exit(0));
|
|
91
92
|
} catch (err) {
|
|
92
93
|
try { if (client) await client.close(); } catch (e) {}
|
|
93
|
-
|
|
94
|
-
process.exit(
|
|
94
|
+
// Exit from the write CALLBACK: stderr to a pipe is an async write and
|
|
95
|
+
// a bare process.exit() truncates it, which left the parent with an
|
|
96
|
+
// empty stderr and nothing but execFileSync's script-dump message.
|
|
97
|
+
process.stderr.write(String((err && err.message) || err), () => process.exit(1));
|
|
95
98
|
}
|
|
96
99
|
})();
|
|
97
100
|
} else {
|
|
@@ -233,6 +236,9 @@ export function mongoCommandSync(
|
|
|
233
236
|
stdio: ["pipe", "pipe", "pipe"],
|
|
234
237
|
});
|
|
235
238
|
} catch (err) {
|
|
236
|
-
|
|
239
|
+
// The child's stderr carries the real reason (connection refused, auth
|
|
240
|
+
// failure, timeout); execFileSync's message carries the whole generated
|
|
241
|
+
// script. Prefer the former.
|
|
242
|
+
throw childFailureError(label, err);
|
|
237
243
|
}
|
|
238
244
|
}
|
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
* TINA4_SESSION_REDIS_DB (default: 0)
|
|
18
18
|
*/
|
|
19
19
|
import { execFileSync } from "node:child_process";
|
|
20
|
+
import { childFailureError } from "./childError.js";
|
|
20
21
|
import { createRequire } from "node:module";
|
|
21
22
|
import type { SessionHandler } from "../session.js";
|
|
22
23
|
import { respCommandSync } from "./respClient.js";
|
|
@@ -142,9 +143,17 @@ export class RedisNpmSessionHandler implements SessionHandler {
|
|
|
142
143
|
(async () => {
|
|
143
144
|
try {
|
|
144
145
|
const redis = require("redis");
|
|
146
|
+
// reconnectStrategy: false — this child runs ONE command and exits, so
|
|
147
|
+
// retrying inside it is pointless: the handler is called again on the
|
|
148
|
+
// next request anyway. With the driver's default strategy a refused
|
|
149
|
+
// connection never rejects, the child hangs until execFileSync's 5s
|
|
150
|
+
// timeout kills it, and the caller is told "timed out" when the truth
|
|
151
|
+
// is "connection refused". Off, connect() rejects in ~5ms with the real
|
|
152
|
+
// reason -- a better message AND no 5s stall per request when Redis is
|
|
153
|
+
// down.
|
|
145
154
|
const clientOpts = useUrl
|
|
146
|
-
? { url }
|
|
147
|
-
: { socket: { host, port }, password: password || undefined, database: db };
|
|
155
|
+
? { url, socket: { reconnectStrategy: false } }
|
|
156
|
+
: { socket: { host, port, reconnectStrategy: false }, password: password || undefined, database: db };
|
|
148
157
|
const client = redis.createClient(clientOpts);
|
|
149
158
|
client.on("error", () => {});
|
|
150
159
|
await client.connect();
|
|
@@ -158,8 +167,10 @@ export class RedisNpmSessionHandler implements SessionHandler {
|
|
|
158
167
|
const out = (result === null || result === undefined) ? "__NULL__" : String(result);
|
|
159
168
|
process.stdout.write(out, () => process.exit(0));
|
|
160
169
|
} catch (err) {
|
|
161
|
-
|
|
162
|
-
process.exit(
|
|
170
|
+
// Exit from the write CALLBACK: stderr to a pipe is an async write and
|
|
171
|
+
// a bare process.exit() truncates it, which left the parent with an
|
|
172
|
+
// empty stderr and nothing but execFileSync's script-dump message.
|
|
173
|
+
process.stderr.write(String((err && err.message) || err), () => process.exit(1));
|
|
163
174
|
}
|
|
164
175
|
})();
|
|
165
176
|
`;
|
|
@@ -171,7 +182,9 @@ export class RedisNpmSessionHandler implements SessionHandler {
|
|
|
171
182
|
stdio: ["pipe", "pipe", "pipe"],
|
|
172
183
|
});
|
|
173
184
|
} catch (err) {
|
|
174
|
-
|
|
185
|
+
// The child's stderr carries the driver's real reason; execFileSync's
|
|
186
|
+
// message carries the whole generated script. Prefer the former.
|
|
187
|
+
throw childFailureError("Redis", err);
|
|
175
188
|
}
|
|
176
189
|
if (result === "__NULL__") return ""; // genuine key miss
|
|
177
190
|
return result;
|
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
* then returns the LAST (command) reply.
|
|
17
17
|
*/
|
|
18
18
|
import { execFileSync } from "node:child_process";
|
|
19
|
+
import { childFailureError } from "./childError.js";
|
|
19
20
|
|
|
20
21
|
export interface RespTarget {
|
|
21
22
|
host: string;
|
|
@@ -161,7 +162,10 @@ export function respCommandSync(target: RespTarget, args: string[], label = "Red
|
|
|
161
162
|
// Non-zero exit = socket error / timeout / closed connection: a transport
|
|
162
163
|
// FAILURE, not a key miss. Surface it so the Session boundary logs + degrades
|
|
163
164
|
// (or re-throws under strict mode).
|
|
164
|
-
|
|
165
|
+
//
|
|
166
|
+
// Report the CHILD's stderr, not execFileSync's message -- that message
|
|
167
|
+
// embeds the whole generated script and buries the actual reason.
|
|
168
|
+
throw childFailureError(label, err);
|
|
165
169
|
}
|
|
166
170
|
if (result === "__NULL__") return ""; // genuine key miss
|
|
167
171
|
if (result.startsWith("__ERR__")) {
|
|
@@ -41,6 +41,29 @@ var TERMINATOR_TAGS = /* @__PURE__ */ new Set([
|
|
|
41
41
|
"endset",
|
|
42
42
|
"endspaceless"
|
|
43
43
|
]);
|
|
44
|
+
var GATEABLE_TAGS = /* @__PURE__ */ new Set([
|
|
45
|
+
"autoescape",
|
|
46
|
+
"cache",
|
|
47
|
+
"for",
|
|
48
|
+
"from",
|
|
49
|
+
"if",
|
|
50
|
+
"import",
|
|
51
|
+
"include",
|
|
52
|
+
"live",
|
|
53
|
+
"macro",
|
|
54
|
+
"set",
|
|
55
|
+
"spaceless"
|
|
56
|
+
]);
|
|
57
|
+
var BLOCK_TAG_ENDS = {
|
|
58
|
+
autoescape: "endautoescape",
|
|
59
|
+
cache: "endcache",
|
|
60
|
+
for: "endfor",
|
|
61
|
+
if: "endif",
|
|
62
|
+
live: "endlive",
|
|
63
|
+
macro: "endmacro",
|
|
64
|
+
set: "endset",
|
|
65
|
+
spaceless: "endspaceless"
|
|
66
|
+
};
|
|
44
67
|
function jsonText(value) {
|
|
45
68
|
try {
|
|
46
69
|
const text = JSON.stringify(value);
|
|
@@ -1680,42 +1703,27 @@ var Frond = class _Frond {
|
|
|
1680
1703
|
if (stripA && i + 1 < tokens.length && tokens[i + 1][0] === "TEXT") {
|
|
1681
1704
|
tokens[i + 1] = ["TEXT", tokens[i + 1][1].replace(LEADING_WS_RE, "")];
|
|
1682
1705
|
}
|
|
1683
|
-
if (tag
|
|
1684
|
-
|
|
1685
|
-
|
|
1686
|
-
|
|
1687
|
-
|
|
1688
|
-
|
|
1689
|
-
output.push(result);
|
|
1690
|
-
i = skip;
|
|
1691
|
-
}
|
|
1706
|
+
if (!this.tagPermitted(tag)) {
|
|
1707
|
+
i = this.skipDeniedTag(tokens, i, tag, content);
|
|
1708
|
+
} else if (tag === "if") {
|
|
1709
|
+
const [result, skip] = this.handleIf(tokens, i, context);
|
|
1710
|
+
output.push(result);
|
|
1711
|
+
i = skip;
|
|
1692
1712
|
} else if (tag === "for") {
|
|
1693
|
-
|
|
1694
|
-
|
|
1695
|
-
|
|
1696
|
-
} else {
|
|
1697
|
-
const [result, skip] = this.handleFor(tokens, i, context);
|
|
1698
|
-
output.push(result);
|
|
1699
|
-
i = skip;
|
|
1700
|
-
}
|
|
1713
|
+
const [result, skip] = this.handleFor(tokens, i, context);
|
|
1714
|
+
output.push(result);
|
|
1715
|
+
i = skip;
|
|
1701
1716
|
} else if (tag === "set") {
|
|
1702
|
-
|
|
1703
|
-
if (this._sandbox && this._allowedTags !== null && !this._allowedTags.has("set")) {
|
|
1704
|
-
i = isBlockSet ? this.skipBlock(tokens, i, "set", "endset") : i + 1;
|
|
1705
|
-
} else if (isBlockSet) {
|
|
1717
|
+
if (!content.includes("=")) {
|
|
1706
1718
|
i = this.handleSetBlock(tokens, i, context);
|
|
1707
1719
|
} else {
|
|
1708
1720
|
this.handleSet(content, context);
|
|
1709
1721
|
i++;
|
|
1710
1722
|
}
|
|
1711
1723
|
} else if (tag === "include") {
|
|
1712
|
-
|
|
1713
|
-
|
|
1714
|
-
|
|
1715
|
-
const result = this.handleInclude(content, context);
|
|
1716
|
-
output.push(result);
|
|
1717
|
-
i++;
|
|
1718
|
-
}
|
|
1724
|
+
const result = this.handleInclude(content, context);
|
|
1725
|
+
output.push(result);
|
|
1726
|
+
i++;
|
|
1719
1727
|
} else if (tag === "macro") {
|
|
1720
1728
|
const skip = this.handleMacro(tokens, i, context);
|
|
1721
1729
|
i = skip;
|
|
@@ -1760,6 +1768,41 @@ var Frond = class _Frond {
|
|
|
1760
1768
|
}
|
|
1761
1769
|
return output.join("");
|
|
1762
1770
|
}
|
|
1771
|
+
/**
|
|
1772
|
+
* May this filter RUN under the current sandbox?
|
|
1773
|
+
*
|
|
1774
|
+
* The escaping decision has to ask this rather than read the filter name out of
|
|
1775
|
+
* the source. Node carries safety as a FLAG rather than as a value-level marker
|
|
1776
|
+
* (Python and Ruby return a SafeString, PHP prepends a RAW_MARKER -- all three
|
|
1777
|
+
* produced only by actually running the filter), so here the name alone was
|
|
1778
|
+
* enough to suppress auto-escaping even when the filter was denied and skipped.
|
|
1779
|
+
*/
|
|
1780
|
+
filterPermitted(name) {
|
|
1781
|
+
if (!this._sandbox || this._allowedFilters === null) return true;
|
|
1782
|
+
return this._allowedFilters.has(name);
|
|
1783
|
+
}
|
|
1784
|
+
/**
|
|
1785
|
+
* May this tag run under the current sandbox?
|
|
1786
|
+
*
|
|
1787
|
+
* One gate for every tag, so the allow-list governs the whole tag vocabulary
|
|
1788
|
+
* instead of the four names that happened to be checked individually.
|
|
1789
|
+
*/
|
|
1790
|
+
tagPermitted(tag) {
|
|
1791
|
+
if (!this._sandbox || this._allowedTags === null) return true;
|
|
1792
|
+
if (!GATEABLE_TAGS.has(tag)) return true;
|
|
1793
|
+
return this._allowedTags.has(tag);
|
|
1794
|
+
}
|
|
1795
|
+
/**
|
|
1796
|
+
* Consume a denied tag WITHOUT running it, returning the index past its body.
|
|
1797
|
+
*
|
|
1798
|
+
* Advancing a single token past a body-owning tag would leave the body's tokens
|
|
1799
|
+
* to render at the TOP level, leaking exactly the content the sandbox denied.
|
|
1800
|
+
*/
|
|
1801
|
+
skipDeniedTag(tokens, start, tag, content) {
|
|
1802
|
+
const closeTag = BLOCK_TAG_ENDS[tag];
|
|
1803
|
+
if (closeTag === void 0 || tag === "set" && content.includes("=")) return start + 1;
|
|
1804
|
+
return this.skipBlock(tokens, start, tag, closeTag);
|
|
1805
|
+
}
|
|
1763
1806
|
skipBlock(tokens, start, openTag, closeTag) {
|
|
1764
1807
|
let depth = 0;
|
|
1765
1808
|
let i = start + 1;
|
|
@@ -1767,7 +1810,7 @@ var Frond = class _Frond {
|
|
|
1767
1810
|
if (tokens[i][0] === "BLOCK") {
|
|
1768
1811
|
const [content] = stripTag(tokens[i][1]);
|
|
1769
1812
|
const tag = content.split(/\s+/)[0] || "";
|
|
1770
|
-
if (tag === openTag) depth++;
|
|
1813
|
+
if (tag === openTag && !(openTag === "set" && content.includes("="))) depth++;
|
|
1771
1814
|
else if (tag === closeTag) {
|
|
1772
1815
|
if (depth === 0) return i + 1;
|
|
1773
1816
|
depth--;
|
|
@@ -1952,11 +1995,11 @@ var Frond = class _Frond {
|
|
|
1952
1995
|
for (const [fname, rawArgs] of filters) {
|
|
1953
1996
|
const args = rawArgs.map((a) => a instanceof VarRef ? evalExpr(a.name, context) : a);
|
|
1954
1997
|
if (fname === "raw" || fname === "safe") {
|
|
1955
|
-
isSafe = true;
|
|
1998
|
+
if (this.filterPermitted(fname)) isSafe = true;
|
|
1956
1999
|
continue;
|
|
1957
2000
|
}
|
|
1958
2001
|
if (fname === "escape" || fname === "e") {
|
|
1959
|
-
isSafe = true;
|
|
2002
|
+
if (this.filterPermitted(fname)) isSafe = true;
|
|
1960
2003
|
}
|
|
1961
2004
|
if (this._sandbox && this._allowedFilters !== null) {
|
|
1962
2005
|
if (!this._allowedFilters.has(fname)) {
|
|
@@ -59,6 +59,35 @@ const TERMINATOR_TAGS = new Set([
|
|
|
59
59
|
"endif", "endlive", "endmacro", "endraw", "endset", "endspaceless",
|
|
60
60
|
]);
|
|
61
61
|
|
|
62
|
+
/**
|
|
63
|
+
* Author-written tags the sandbox allow-list governs. Mirrors Python's
|
|
64
|
+
* _GATEABLE_TAGS, PHP's GATEABLE_TAGS and Ruby's GATEABLE_TAGS. A tag absent from
|
|
65
|
+
* this set is structural, not an author capability, and is never gated -- `block`
|
|
66
|
+
* and `extends` are template inheritance, and `raw` is consumed by the tokenizer.
|
|
67
|
+
* Both spellings of set ({% set x = 1 %} and {% set x %}...{% endset %}) dispatch
|
|
68
|
+
* under "set", so one entry covers the pair.
|
|
69
|
+
*/
|
|
70
|
+
const GATEABLE_TAGS = new Set([
|
|
71
|
+
"autoescape", "cache", "for", "from", "if", "import", "include", "live",
|
|
72
|
+
"macro", "set", "spaceless",
|
|
73
|
+
]);
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Gateable tags that OWN A BODY, mapped to the terminator closing it. A denied tag
|
|
77
|
+
* has to consume its body or the body's tokens render at the top level, leaking
|
|
78
|
+
* exactly the content the sandbox denied.
|
|
79
|
+
*/
|
|
80
|
+
const BLOCK_TAG_ENDS: Record<string, string> = {
|
|
81
|
+
autoescape: "endautoescape",
|
|
82
|
+
cache: "endcache",
|
|
83
|
+
for: "endfor",
|
|
84
|
+
if: "endif",
|
|
85
|
+
live: "endlive",
|
|
86
|
+
macro: "endmacro",
|
|
87
|
+
set: "endset",
|
|
88
|
+
spaceless: "endspaceless",
|
|
89
|
+
};
|
|
90
|
+
|
|
62
91
|
/**
|
|
63
92
|
* Serialize a value to compact JSON text that is always valid JSON.
|
|
64
93
|
*
|
|
@@ -2082,48 +2111,36 @@ export class Frond {
|
|
|
2082
2111
|
tokens[i + 1] = ["TEXT", tokens[i + 1][1].replace(LEADING_WS_RE, "")];
|
|
2083
2112
|
}
|
|
2084
2113
|
|
|
2085
|
-
if (tag
|
|
2086
|
-
//
|
|
2087
|
-
|
|
2088
|
-
|
|
2089
|
-
|
|
2090
|
-
|
|
2091
|
-
|
|
2092
|
-
|
|
2093
|
-
|
|
2094
|
-
|
|
2114
|
+
if (!this.tagPermitted(tag)) {
|
|
2115
|
+
// ONE sandbox gate for the whole tag vocabulary. Previously only if, for,
|
|
2116
|
+
// set and include were checked, so every other tag ignored the allow-list
|
|
2117
|
+
// -- {% autoescape false %} could switch escaping off from inside a
|
|
2118
|
+
// sandbox whose tags were restricted to something else entirely.
|
|
2119
|
+
i = this.skipDeniedTag(tokens, i, tag, content);
|
|
2120
|
+
} else if (tag === "if") {
|
|
2121
|
+
const [result, skip] = this.handleIf(tokens, i, context);
|
|
2122
|
+
output.push(result);
|
|
2123
|
+
i = skip;
|
|
2095
2124
|
} else if (tag === "for") {
|
|
2096
|
-
|
|
2097
|
-
|
|
2098
|
-
|
|
2099
|
-
} else {
|
|
2100
|
-
const [result, skip] = this.handleFor(tokens, i, context);
|
|
2101
|
-
output.push(result);
|
|
2102
|
-
i = skip;
|
|
2103
|
-
}
|
|
2125
|
+
const [result, skip] = this.handleFor(tokens, i, context);
|
|
2126
|
+
output.push(result);
|
|
2127
|
+
i = skip;
|
|
2104
2128
|
} else if (tag === "set") {
|
|
2105
2129
|
// An assignment has an "="; without one this is the BLOCK form,
|
|
2106
2130
|
// {% set name %}...{% endset %}, which captures its rendered body. A
|
|
2107
2131
|
// bare includes() is exact here, not a shortcut: the block form's tag
|
|
2108
2132
|
// content is only ever "set <name>", so an "=" anywhere -- even inside
|
|
2109
2133
|
// a quoted value like {% set m = "a = b" %} -- means assignment.
|
|
2110
|
-
|
|
2111
|
-
if (this._sandbox && this._allowedTags !== null && !this._allowedTags.has("set")) {
|
|
2112
|
-
i = isBlockSet ? this.skipBlock(tokens, i, "set", "endset") : i + 1;
|
|
2113
|
-
} else if (isBlockSet) {
|
|
2134
|
+
if (!content.includes("=")) {
|
|
2114
2135
|
i = this.handleSetBlock(tokens, i, context);
|
|
2115
2136
|
} else {
|
|
2116
2137
|
this.handleSet(content, context);
|
|
2117
2138
|
i++;
|
|
2118
2139
|
}
|
|
2119
2140
|
} else if (tag === "include") {
|
|
2120
|
-
|
|
2121
|
-
|
|
2122
|
-
|
|
2123
|
-
const result = this.handleInclude(content, context);
|
|
2124
|
-
output.push(result);
|
|
2125
|
-
i++;
|
|
2126
|
-
}
|
|
2141
|
+
const result = this.handleInclude(content, context);
|
|
2142
|
+
output.push(result);
|
|
2143
|
+
i++;
|
|
2127
2144
|
} else if (tag === "macro") {
|
|
2128
2145
|
const skip = this.handleMacro(tokens, i, context);
|
|
2129
2146
|
i = skip;
|
|
@@ -2176,6 +2193,46 @@ export class Frond {
|
|
|
2176
2193
|
return output.join("");
|
|
2177
2194
|
}
|
|
2178
2195
|
|
|
2196
|
+
/**
|
|
2197
|
+
* May this filter RUN under the current sandbox?
|
|
2198
|
+
*
|
|
2199
|
+
* The escaping decision has to ask this rather than read the filter name out of
|
|
2200
|
+
* the source. Node carries safety as a FLAG rather than as a value-level marker
|
|
2201
|
+
* (Python and Ruby return a SafeString, PHP prepends a RAW_MARKER -- all three
|
|
2202
|
+
* produced only by actually running the filter), so here the name alone was
|
|
2203
|
+
* enough to suppress auto-escaping even when the filter was denied and skipped.
|
|
2204
|
+
*/
|
|
2205
|
+
private filterPermitted(name: string): boolean {
|
|
2206
|
+
if (!this._sandbox || this._allowedFilters === null) return true;
|
|
2207
|
+
return this._allowedFilters.has(name);
|
|
2208
|
+
}
|
|
2209
|
+
|
|
2210
|
+
/**
|
|
2211
|
+
* May this tag run under the current sandbox?
|
|
2212
|
+
*
|
|
2213
|
+
* One gate for every tag, so the allow-list governs the whole tag vocabulary
|
|
2214
|
+
* instead of the four names that happened to be checked individually.
|
|
2215
|
+
*/
|
|
2216
|
+
private tagPermitted(tag: string): boolean {
|
|
2217
|
+
if (!this._sandbox || this._allowedTags === null) return true;
|
|
2218
|
+
if (!GATEABLE_TAGS.has(tag)) return true; // structural, not an author capability
|
|
2219
|
+
return this._allowedTags.has(tag);
|
|
2220
|
+
}
|
|
2221
|
+
|
|
2222
|
+
/**
|
|
2223
|
+
* Consume a denied tag WITHOUT running it, returning the index past its body.
|
|
2224
|
+
*
|
|
2225
|
+
* Advancing a single token past a body-owning tag would leave the body's tokens
|
|
2226
|
+
* to render at the TOP level, leaking exactly the content the sandbox denied.
|
|
2227
|
+
*/
|
|
2228
|
+
private skipDeniedTag(tokens: Token[], start: number, tag: string, content: string): number {
|
|
2229
|
+
const closeTag = BLOCK_TAG_ENDS[tag];
|
|
2230
|
+
// {% set x = 1 %} is an assignment and owns no body; {% set x %}...{% endset %}
|
|
2231
|
+
// captures one. Same exact-"=" test the dispatch uses.
|
|
2232
|
+
if (closeTag === undefined || (tag === "set" && content.includes("="))) return start + 1;
|
|
2233
|
+
return this.skipBlock(tokens, start, tag, closeTag);
|
|
2234
|
+
}
|
|
2235
|
+
|
|
2179
2236
|
private skipBlock(tokens: Token[], start: number, openTag: string, closeTag: string): number {
|
|
2180
2237
|
let depth = 0;
|
|
2181
2238
|
let i = start + 1;
|
|
@@ -2183,7 +2240,9 @@ export class Frond {
|
|
|
2183
2240
|
if (tokens[i][0] === "BLOCK") {
|
|
2184
2241
|
const [content] = stripTag(tokens[i][1]);
|
|
2185
2242
|
const tag = content.split(/\s+/)[0] || "";
|
|
2186
|
-
|
|
2243
|
+
// A nested assignment-form set opens no body, so it must not nest -- counting
|
|
2244
|
+
// it would consume past the real {% endset %} and swallow trailing content.
|
|
2245
|
+
if (tag === openTag && !(openTag === "set" && content.includes("="))) depth++;
|
|
2187
2246
|
else if (tag === closeTag) {
|
|
2188
2247
|
if (depth === 0) return i + 1;
|
|
2189
2248
|
depth--;
|
|
@@ -2388,12 +2447,19 @@ export class Frond {
|
|
|
2388
2447
|
for (const [fname, rawArgs] of filters) {
|
|
2389
2448
|
const args = rawArgs.map((a) => (a instanceof VarRef ? evalExpr(a.name, context) : a));
|
|
2390
2449
|
if (fname === "raw" || fname === "safe") {
|
|
2391
|
-
|
|
2450
|
+
// Decide from what was permitted to RUN, not from what the source asked
|
|
2451
|
+
// for. Marking the value safe here regardless meant a DENIED raw produced
|
|
2452
|
+
// byte-identical output to an allowed one -- the allow-list entry that
|
|
2453
|
+
// governs XSS escaping did nothing at all.
|
|
2454
|
+
if (this.filterPermitted(fname)) isSafe = true;
|
|
2392
2455
|
continue;
|
|
2393
2456
|
}
|
|
2394
|
-
// escape/e filter marks output as safe (already escaped)
|
|
2457
|
+
// escape/e filter marks output as safe (already escaped) -- but ONLY when it
|
|
2458
|
+
// is permitted to run. Node's escape returns a plain string, so this flag is
|
|
2459
|
+
// what suppresses auto-escaping; setting it for a DENIED escape emitted the
|
|
2460
|
+
// value unescaped, having never escaped it.
|
|
2395
2461
|
if (fname === "escape" || fname === "e") {
|
|
2396
|
-
isSafe = true;
|
|
2462
|
+
if (this.filterPermitted(fname)) isSafe = true;
|
|
2397
2463
|
}
|
|
2398
2464
|
|
|
2399
2465
|
// Sandbox: check filter access
|