tina4-nodejs 3.13.138 → 3.13.140
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 +1 -1
- package/packages/cli/dist/bin.js +683 -329
- package/packages/cli/src/commands/generate.ts +7 -13
- package/packages/core/dist/index.js +688 -329
- package/packages/core/src/api.ts +33 -4
- package/packages/core/src/auth.ts +73 -6
- package/packages/core/src/authGate.ts +27 -20
- package/packages/core/src/graphql.ts +92 -0
- package/packages/core/src/index.ts +1 -0
- package/packages/core/src/job.ts +8 -1
- package/packages/core/src/mcp.ts +3 -1
- package/packages/core/src/middleware.ts +4 -2
- package/packages/core/src/push.ts +16 -0
- package/packages/core/src/queue.ts +15 -2
- package/packages/core/src/queueBackends/liteBackend.ts +15 -0
- package/packages/core/src/queueBackends/mongoBackend.ts +40 -6
- package/packages/core/src/server.ts +6 -1
- package/packages/core/src/sessionHandlers/databaseHandler.ts +60 -3
- package/packages/core/src/sso.ts +12 -4
- package/packages/core/src/ssoIdentity.ts +19 -0
- package/packages/core/src/ssrf.ts +221 -0
- package/packages/core/src/testClient.ts +3 -0
- package/packages/core/src/websocket.ts +4 -2
- package/packages/frond/dist/index.js +6 -0
- package/packages/frond/src/engine.ts +9 -0
- package/packages/orm/dist/index.js +675 -320
- package/packages/orm/src/adapters/firebird.ts +14 -45
- package/packages/orm/src/adapters/mssql.ts +21 -58
- package/packages/orm/src/adapters/mysql.ts +10 -49
- package/packages/orm/src/adapters/odbc.ts +11 -42
- package/packages/orm/src/adapters/postgres.ts +20 -60
- package/packages/orm/src/adapters/sqlCrud.ts +178 -0
- package/packages/orm/src/adapters/sqlite.ts +46 -66
- package/packages/orm/src/index.ts +1 -1
- package/types/core/src/api.d.ts +6 -0
- package/types/core/src/auth.d.ts +30 -0
- package/types/core/src/graphql.d.ts +25 -0
- package/types/core/src/index.d.ts +1 -0
- package/types/core/src/job.d.ts +1 -0
- package/types/core/src/push.d.ts +5 -0
- package/types/core/src/queue.d.ts +7 -1
- package/types/core/src/queueBackends/liteBackend.d.ts +9 -0
- package/types/core/src/queueBackends/mongoBackend.d.ts +8 -1
- package/types/core/src/sessionHandlers/databaseHandler.d.ts +25 -1
- package/types/core/src/sso.d.ts +1 -0
- package/types/core/src/ssoIdentity.d.ts +10 -0
- package/types/core/src/ssrf.d.ts +19 -0
- package/types/orm/src/adapters/sqlCrud.d.ts +90 -0
- package/types/orm/src/adapters/sqlite.d.ts +17 -0
- package/types/orm/src/index.d.ts +1 -1
package/packages/core/src/api.ts
CHANGED
|
@@ -29,6 +29,7 @@ import { promises as fsp, createWriteStream } from "node:fs";
|
|
|
29
29
|
import { basename } from "node:path";
|
|
30
30
|
import { pipeline } from "node:stream/promises";
|
|
31
31
|
import { TINA4_VERSION } from "./version.js";
|
|
32
|
+
import { guardUrl } from "./ssrf.js";
|
|
32
33
|
|
|
33
34
|
export interface ApiResult {
|
|
34
35
|
http_code: number | null;
|
|
@@ -229,6 +230,11 @@ export interface ApiOptions {
|
|
|
229
230
|
* persisted; scoped to this instance.
|
|
230
231
|
*/
|
|
231
232
|
cookies?: boolean;
|
|
233
|
+
/**
|
|
234
|
+
* SSRF guard (ADR-0084): an explicit allow-list of hosts / host:port / CIDRs
|
|
235
|
+
* that bypass the private-address refusal even with the opt-out off.
|
|
236
|
+
*/
|
|
237
|
+
allowHosts?: string[];
|
|
232
238
|
}
|
|
233
239
|
|
|
234
240
|
/** True when two URLs share scheme + host + (effective) port. */
|
|
@@ -430,6 +436,7 @@ export class Api {
|
|
|
430
436
|
private transportFn?: ApiTransport;
|
|
431
437
|
private cookiesEnabled: boolean;
|
|
432
438
|
private cookies: Record<string, string>;
|
|
439
|
+
private allowHosts: string[];
|
|
433
440
|
|
|
434
441
|
/**
|
|
435
442
|
* Construct an Api client.
|
|
@@ -476,6 +483,7 @@ export class Api {
|
|
|
476
483
|
this.transportFn = undefined;
|
|
477
484
|
this.cookiesEnabled = false;
|
|
478
485
|
this.cookies = {};
|
|
486
|
+
this.allowHosts = [];
|
|
479
487
|
|
|
480
488
|
// Options-bag form — second arg is an object literal
|
|
481
489
|
if (typeof authHeaderOrOptions === "object" && authHeaderOrOptions !== null) {
|
|
@@ -487,6 +495,7 @@ export class Api {
|
|
|
487
495
|
this.retryBackoff = opts.retryBackoff ?? 0.5;
|
|
488
496
|
this.transportFn = opts.transport;
|
|
489
497
|
this.cookiesEnabled = opts.cookies ?? false;
|
|
498
|
+
this.allowHosts = opts.allowHosts ?? [];
|
|
490
499
|
|
|
491
500
|
// Bearer wins over basic-auth when both are passed
|
|
492
501
|
if (opts.bearerToken != null) {
|
|
@@ -842,13 +851,19 @@ export class Api {
|
|
|
842
851
|
* body-phase timeout is applied by the caller (streamBytes) via
|
|
843
852
|
* `res.destroy()`.
|
|
844
853
|
*/
|
|
845
|
-
private openStreamRequest(
|
|
854
|
+
private async openStreamRequest(
|
|
846
855
|
method: string,
|
|
847
856
|
url: string,
|
|
848
857
|
headers: Record<string, string>,
|
|
849
858
|
data: Buffer | undefined,
|
|
850
859
|
connectSec: number,
|
|
851
860
|
): Promise<{ res: http.IncomingMessage }> {
|
|
861
|
+
// SSRF guard (ADR-0084): refuse a private/internal target before connect.
|
|
862
|
+
try {
|
|
863
|
+
await guardUrl(url, this.allowHosts);
|
|
864
|
+
} catch (err) {
|
|
865
|
+
throw new ApiStreamError(err instanceof Error ? err.message : String(err), null);
|
|
866
|
+
}
|
|
852
867
|
return new Promise((resolve, reject) => {
|
|
853
868
|
let parsed: URL;
|
|
854
869
|
try {
|
|
@@ -915,12 +930,18 @@ export class Api {
|
|
|
915
930
|
targetUrl?: string,
|
|
916
931
|
): { headers: Record<string, string>; data: Buffer | undefined } {
|
|
917
932
|
const headers: Record<string, string> = { "User-Agent": `Tina4/${TINA4_VERSION}`, ...this.headers };
|
|
918
|
-
|
|
933
|
+
// Attach the configured Authorization / Cookie ONLY when the request
|
|
934
|
+
// target is same-origin as the configured base. A path that is itself
|
|
935
|
+
// an absolute off-origin URL (e.g. get("http://evil/x")) otherwise
|
|
936
|
+
// leaks the bearer token / session cookie to an attacker-chosen host —
|
|
937
|
+
// the same cross-origin strip already applied to followed redirects.
|
|
938
|
+
const sameOriginAsBase = targetUrl === undefined || sameOrigin(targetUrl, this.baseUrl);
|
|
939
|
+
if (this.authHeader && sameOriginAsBase) {
|
|
919
940
|
headers["Authorization"] = this.authHeader;
|
|
920
941
|
}
|
|
921
942
|
|
|
922
943
|
// Cookie jar: attach the accumulated Cookie header when enabled.
|
|
923
|
-
if (this.cookiesEnabled) {
|
|
944
|
+
if (this.cookiesEnabled && sameOriginAsBase) {
|
|
924
945
|
const cookieHeader = this.cookieHeader();
|
|
925
946
|
if (cookieHeader) {
|
|
926
947
|
headers["Cookie"] = cookieHeader;
|
|
@@ -1049,13 +1070,21 @@ export class Api {
|
|
|
1049
1070
|
* bearer token / session cookie never leaks to a host you didn't
|
|
1050
1071
|
* authenticate to.
|
|
1051
1072
|
*/
|
|
1052
|
-
private performRequest(
|
|
1073
|
+
private async performRequest(
|
|
1053
1074
|
method: string,
|
|
1054
1075
|
url: string,
|
|
1055
1076
|
headers: Record<string, string>,
|
|
1056
1077
|
data: Buffer | undefined,
|
|
1057
1078
|
redirectsLeft: number,
|
|
1058
1079
|
): Promise<NetworkResult> {
|
|
1080
|
+
// SSRF guard (ADR-0084): re-validated per hop (this method recurses on a
|
|
1081
|
+
// redirect), so a hop to a private/internal address is refused even when
|
|
1082
|
+
// the initial URL was public.
|
|
1083
|
+
try {
|
|
1084
|
+
await guardUrl(url, this.allowHosts);
|
|
1085
|
+
} catch (err) {
|
|
1086
|
+
return { kind: "error", error: err instanceof Error ? err.message : String(err) };
|
|
1087
|
+
}
|
|
1059
1088
|
return new Promise<NetworkResult>((resolve) => {
|
|
1060
1089
|
let parsed: URL;
|
|
1061
1090
|
try {
|
|
@@ -82,6 +82,58 @@ function _warnBlankSecret(): void {
|
|
|
82
82
|
void _logWarning(BLANK_SECRET_WARNING);
|
|
83
83
|
}
|
|
84
84
|
|
|
85
|
+
// ── Auth-token rules (ADR-0079) ────────────────────────────────────
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Token purposes that are never an identity (ADR-0079 s1). Frond's formToken()
|
|
89
|
+
* signs a JWT with the same TINA4_SECRET as an auth token, marked
|
|
90
|
+
* `type: "form"`. A form token proves where a write came from, never who the
|
|
91
|
+
* caller is, so every identity gate refuses these purposes. validToken itself
|
|
92
|
+
* stays purpose-neutral: the CSRF middleware needs it for form tokens.
|
|
93
|
+
*/
|
|
94
|
+
export const NON_IDENTITY_TOKEN_TYPES: readonly string[] = ["form"];
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Minimum HMAC key length in bytes (ADR-0079 s2): the HS256 output size, RFC
|
|
98
|
+
* 7518 s3.2. A blank key is the shortest case - anyone can reproduce it.
|
|
99
|
+
*/
|
|
100
|
+
export const MIN_SECRET_BYTES = 32;
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* True when a VERIFIED payload may stand for a caller's identity. A payload
|
|
104
|
+
* whose `type` is a reserved non-identity purpose (a Frond form token) is
|
|
105
|
+
* refused; application payloads are otherwise untouched.
|
|
106
|
+
*/
|
|
107
|
+
export function isIdentityPayload(payload: Record<string, unknown> | null | undefined): payload is Record<string, unknown> {
|
|
108
|
+
return payload != null && !NON_IDENTITY_TOKEN_TYPES.includes(payload.type as string);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** The actionable error for a weak HMAC key, or null when the key is usable. */
|
|
112
|
+
export function insecureSecretMessage(secret: string | undefined | null): string | null {
|
|
113
|
+
const length = Buffer.byteLength(secret ?? "", "utf8");
|
|
114
|
+
if (length >= MIN_SECRET_BYTES) return null;
|
|
115
|
+
const what = length === 0 ? "is not set" : `is ${length} bytes`;
|
|
116
|
+
return `Auth: TINA4_SECRET ${what}; an HMAC JWT secret must be at least ${MIN_SECRET_BYTES} bytes. ` +
|
|
117
|
+
"Generate one with `openssl rand -hex 32` and set TINA4_SECRET in your environment or .env.";
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Refuse to boot with a secret that makes tokens forgeable (ADR-0079 s2).
|
|
122
|
+
* Outside dev a blank TINA4_SECRET is refused; in any mode a set-but-short one
|
|
123
|
+
* is refused. Dev with a blank secret has already had one minted into
|
|
124
|
+
* .env.local by ensureDevSecret(). RS256 keys are PEMs and are not measured.
|
|
125
|
+
*
|
|
126
|
+
* @throws Error with the actionable message naming TINA4_SECRET.
|
|
127
|
+
*/
|
|
128
|
+
export function requireBootSecret(): void {
|
|
129
|
+
const algorithm = (process.env.TINA4_JWT_ALGORITHM ?? "").trim() || "HS256";
|
|
130
|
+
if (RSA_SIGN_ALGORITHMS.has(algorithm)) return;
|
|
131
|
+
const secret = process.env.TINA4_SECRET ?? "";
|
|
132
|
+
if (secret === "" && _isDev()) return;
|
|
133
|
+
const message = insecureSecretMessage(secret);
|
|
134
|
+
if (message) throw new Error(message);
|
|
135
|
+
}
|
|
136
|
+
|
|
85
137
|
/**
|
|
86
138
|
* Ensure a usable TINA4_SECRET exists. Run ONCE at server boot, after env load
|
|
87
139
|
* and before auth is used. Mirrors Python's `ensure_dev_secret()`.
|
|
@@ -460,6 +512,14 @@ export function validToken(token: string, secret?: string, algorithm?: string):
|
|
|
460
512
|
const header = JSON.parse(base64urlDecode(h).toString()) as Record<string, unknown>;
|
|
461
513
|
if (header.alg !== resolvedAlgorithm) return null;
|
|
462
514
|
|
|
515
|
+
// A blank or short HMAC key verifies tokens anyone can mint, so the token is
|
|
516
|
+
// REJECTED (fail closed with a 401, never a 500) and the operator is told
|
|
517
|
+
// why. ADR-0079 s2.
|
|
518
|
+
if (HMAC_DIGESTS.has(resolvedAlgorithm)) {
|
|
519
|
+
const weak = insecureSecretMessage(resolvedSecret);
|
|
520
|
+
if (weak) { void _logWarning(weak); return null; }
|
|
521
|
+
}
|
|
522
|
+
|
|
463
523
|
const signingInput = `${h}.${p}`;
|
|
464
524
|
if (!verifySignature(signingInput, sig, resolvedSecret, resolvedAlgorithm)) {
|
|
465
525
|
return null;
|
|
@@ -522,6 +582,8 @@ function sign(input: string, secret: string, algorithm: string): string {
|
|
|
522
582
|
// the header is the one that actually produced this signature.
|
|
523
583
|
const digest = HMAC_DIGESTS.get(algorithm);
|
|
524
584
|
if (digest) {
|
|
585
|
+
const weak = insecureSecretMessage(secret);
|
|
586
|
+
if (weak) throw new Error(weak);
|
|
525
587
|
return base64urlEncode(createHmac(digest, secret).update(input).digest());
|
|
526
588
|
}
|
|
527
589
|
const rsaAlgorithm = RSA_SIGN_ALGORITHMS.get(algorithm);
|
|
@@ -622,12 +684,14 @@ export function authMiddleware(secret?: string, algorithm?: string): Middleware
|
|
|
622
684
|
}
|
|
623
685
|
|
|
624
686
|
const token = authHeader.slice(7);
|
|
625
|
-
|
|
687
|
+
const payload = validToken(token, secret, algorithm);
|
|
688
|
+
// Invalid, expired, or a form token (not an identity, ADR-0079 s1).
|
|
689
|
+
if (!isIdentityPayload(payload)) {
|
|
626
690
|
res({ error: "Unauthorized" }, 401);
|
|
627
691
|
return;
|
|
628
692
|
}
|
|
629
693
|
|
|
630
|
-
(req as any).auth =
|
|
694
|
+
(req as any).auth = payload;
|
|
631
695
|
next();
|
|
632
696
|
};
|
|
633
697
|
}
|
|
@@ -648,11 +712,12 @@ export function refreshToken(
|
|
|
648
712
|
token: string,
|
|
649
713
|
expiresIn: number = 60,
|
|
650
714
|
): string | null {
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
const payload = getPayload(token);
|
|
715
|
+
const payload = validToken(token);
|
|
654
716
|
if (!payload) return null;
|
|
655
717
|
|
|
718
|
+
// Refresh preserves PURPOSE: a form token comes back as a form token (CSRF
|
|
719
|
+
// rotation) and so can still never pass an identity gate. The route gate
|
|
720
|
+
// issues a FreshToken only for an identity token. ADR-0079 s1.
|
|
656
721
|
// Strip standard timing claims so getToken sets fresh ones
|
|
657
722
|
const { iat: _iat, exp: _exp, ...claims } = payload;
|
|
658
723
|
return getToken(claims, expiresIn);
|
|
@@ -686,7 +751,9 @@ export function authenticateRequest(
|
|
|
686
751
|
// algorithm= silently got the env values instead, and `algorithm` defaulted to
|
|
687
752
|
// the literal "HS256" which additionally shadowed TINA4_JWT_ALGORITHM. Python,
|
|
688
753
|
// PHP and Ruby all honour these; Node was the last one that did not.
|
|
689
|
-
|
|
754
|
+
const payload = validToken(token, secret, algorithm);
|
|
755
|
+
// A form token is not an identity (ADR-0079 s1).
|
|
756
|
+
if (payload) return isIdentityPayload(payload) ? payload : null;
|
|
690
757
|
|
|
691
758
|
// Fallback: treat Bearer value as API key
|
|
692
759
|
if (validateApiKey(token)) {
|
|
@@ -17,7 +17,8 @@ file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
|
|
17
17
|
* verification layer lied. This is the same defect fixed in tina4-python under
|
|
18
18
|
* #PY2; extracting the gate here lets both callers share one implementation.
|
|
19
19
|
*/
|
|
20
|
-
import { validToken,
|
|
20
|
+
import { validToken, refreshToken, isIdentityPayload } from "./auth.js";
|
|
21
|
+
import { liveSessionIdentity } from "./ssoIdentity.js";
|
|
21
22
|
import type { Tina4Request, Tina4Response } from "./types.js";
|
|
22
23
|
|
|
23
24
|
/** Just the auth-relevant fields of a matched route. */
|
|
@@ -52,52 +53,58 @@ export function enforceRouteAuth(
|
|
|
52
53
|
return false;
|
|
53
54
|
}
|
|
54
55
|
|
|
56
|
+
// Token slots in priority order: Bearer header, body formToken (frond.js
|
|
57
|
+
// puts the auth token it received as a FreshToken there), session token.
|
|
58
|
+
// Each slot is accepted only when it carries an IDENTITY token: a Frond
|
|
59
|
+
// form token (`type: "form"`) proves where a write came from, not who sent
|
|
60
|
+
// it, so it is skipped and the search moves on (ADR-0079 s1).
|
|
55
61
|
const authHeader = req.headers.authorization ?? "";
|
|
56
62
|
const headerToken = authHeader.startsWith("Bearer ") ? authHeader.slice(7) : "";
|
|
57
63
|
|
|
58
|
-
let
|
|
64
|
+
let payload: Record<string, unknown> | null = null;
|
|
59
65
|
let tokenSource: "header" | "body" | "session" | "" = "";
|
|
66
|
+
let resolvedToken = "";
|
|
60
67
|
|
|
61
68
|
// Priority 1: Authorization Bearer header
|
|
62
|
-
if (headerToken
|
|
63
|
-
|
|
64
|
-
tokenSource = "header";
|
|
69
|
+
if (headerToken) {
|
|
70
|
+
const candidate = validToken(headerToken);
|
|
71
|
+
if (isIdentityPayload(candidate)) { payload = candidate; tokenSource = "header"; resolvedToken = headerToken; }
|
|
65
72
|
}
|
|
66
73
|
|
|
67
74
|
// Priority 2: formToken from request body
|
|
68
|
-
if (!
|
|
75
|
+
if (!payload) {
|
|
69
76
|
const bodyToken = (req.body as Record<string, unknown>)?.formToken as string | undefined;
|
|
70
|
-
if (bodyToken
|
|
71
|
-
|
|
72
|
-
tokenSource = "body";
|
|
77
|
+
if (bodyToken) {
|
|
78
|
+
const candidate = validToken(bodyToken);
|
|
79
|
+
if (isIdentityPayload(candidate)) { payload = candidate; tokenSource = "body"; resolvedToken = bodyToken; }
|
|
73
80
|
}
|
|
74
81
|
}
|
|
75
82
|
|
|
76
|
-
// Priority 3: Session
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
const identity =
|
|
80
|
-
if (identity
|
|
83
|
+
// Priority 3: Session — a provider-verified SSO identity while it is live
|
|
84
|
+
// (ADR-0079 s5), then the session token.
|
|
85
|
+
if (!payload) {
|
|
86
|
+
const identity = liveSessionIdentity((req as any).session?.get?.("_tina4_sso"));
|
|
87
|
+
if (identity) {
|
|
81
88
|
req.user = identity;
|
|
82
89
|
// RBAC guards apply to the SSO identity too (Feature 138).
|
|
83
90
|
return rbacForbidden(match, identity, res);
|
|
84
91
|
}
|
|
85
92
|
const sessionToken = (req as any).session?.get?.("token") as string | undefined;
|
|
86
|
-
if (sessionToken
|
|
87
|
-
|
|
88
|
-
tokenSource = "session";
|
|
93
|
+
if (sessionToken) {
|
|
94
|
+
const candidate = validToken(sessionToken);
|
|
95
|
+
if (isIdentityPayload(candidate)) { payload = candidate; tokenSource = "session"; resolvedToken = sessionToken; }
|
|
89
96
|
}
|
|
90
97
|
}
|
|
91
98
|
|
|
92
|
-
if (!
|
|
99
|
+
if (!payload) {
|
|
93
100
|
res.raw.writeHead(401, { "Content-Type": "application/json" });
|
|
94
101
|
res.raw.end(JSON.stringify({ error: "Unauthorized" }));
|
|
95
102
|
return true;
|
|
96
103
|
}
|
|
97
104
|
|
|
98
|
-
req.user =
|
|
105
|
+
req.user = payload;
|
|
99
106
|
|
|
100
|
-
// When a body
|
|
107
|
+
// When a body token authenticates, return a FreshToken header with a refreshed JWT
|
|
101
108
|
if (tokenSource === "body") {
|
|
102
109
|
const fresh = refreshToken(resolvedToken);
|
|
103
110
|
if (fresh) {
|
|
@@ -175,10 +175,17 @@ type ParsedDefinition = ParsedOperation | ParsedFragment;
|
|
|
175
175
|
class Parser {
|
|
176
176
|
private tokens: Token[];
|
|
177
177
|
private pos: number;
|
|
178
|
+
// F2: bound the parser's own recursion. A deeply nested query
|
|
179
|
+
// ("{a{a{a...}}}") otherwise recurses in parseSelectionSet until the call
|
|
180
|
+
// stack overflows — BEFORE the execution-time depth guard can run. Reusing
|
|
181
|
+
// TINA4_GRAPHQL_MAX_DEPTH keeps parse and execution on one bound.
|
|
182
|
+
private depth = 0;
|
|
183
|
+
private readonly maxDepth: number;
|
|
178
184
|
|
|
179
185
|
constructor(tokens: Token[]) {
|
|
180
186
|
this.tokens = tokens;
|
|
181
187
|
this.pos = 0;
|
|
188
|
+
this.maxDepth = graphqlMaxDepth();
|
|
182
189
|
}
|
|
183
190
|
|
|
184
191
|
peek(): Token | null {
|
|
@@ -272,6 +279,18 @@ class Parser {
|
|
|
272
279
|
}
|
|
273
280
|
|
|
274
281
|
private parseSelectionSet(): ParsedSelection[] {
|
|
282
|
+
this.depth++;
|
|
283
|
+
if (this.maxDepth > 0 && this.depth > this.maxDepth) {
|
|
284
|
+
throw new Error(`Query exceeds maximum depth of ${this.maxDepth}`);
|
|
285
|
+
}
|
|
286
|
+
try {
|
|
287
|
+
return this.parseSelectionSetInner();
|
|
288
|
+
} finally {
|
|
289
|
+
this.depth--;
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
private parseSelectionSetInner(): ParsedSelection[] {
|
|
275
294
|
this.expect("LBRACE");
|
|
276
295
|
const selections: ParsedSelection[] = [];
|
|
277
296
|
while (!this.match("RBRACE")) {
|
|
@@ -488,6 +507,24 @@ export function graphqlMaxDepth(): number {
|
|
|
488
507
|
return Number.isNaN(n) ? 50 : n;
|
|
489
508
|
}
|
|
490
509
|
|
|
510
|
+
/**
|
|
511
|
+
* F2: maximum number of expanded selection nodes a single query may request.
|
|
512
|
+
* The depth guard bounds NESTING but not WIDTH: a fragment that spreads another
|
|
513
|
+
* fragment many times (a "fragment bomb") stays shallow while expanding to
|
|
514
|
+
* millions of fields, and a query with thousands of aliases of one field is not
|
|
515
|
+
* deep at all. This budget bounds the expanded selection tree (fragments
|
|
516
|
+
* expanded, aliases counted) so both are rejected before any resolver runs.
|
|
517
|
+
* `TINA4_GRAPHQL_MAX_NODES` overrides the default (1000); `<= 0` disables it.
|
|
518
|
+
*/
|
|
519
|
+
export function graphqlMaxNodes(): number {
|
|
520
|
+
const raw = (process.env.TINA4_GRAPHQL_MAX_NODES ?? "1000").trim();
|
|
521
|
+
const n = parseInt(raw, 10);
|
|
522
|
+
return Number.isNaN(n) ? 1000 : n;
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
/** Thrown when a query's expanded selection count exceeds the node budget (F2). */
|
|
526
|
+
class ComplexityError extends Error {}
|
|
527
|
+
|
|
491
528
|
// ── GraphQL Engine ───────────────────────────────────────────
|
|
492
529
|
|
|
493
530
|
export class GraphQL {
|
|
@@ -515,6 +552,13 @@ export class GraphQL {
|
|
|
515
552
|
*/
|
|
516
553
|
public maxDepth: number = graphqlMaxDepth();
|
|
517
554
|
|
|
555
|
+
/**
|
|
556
|
+
* Maximum expanded selection nodes per query (from `TINA4_GRAPHQL_MAX_NODES`,
|
|
557
|
+
* default 1000; `<= 0` disables). Bounds query WIDTH — fragment bombs and
|
|
558
|
+
* alias explosions — which the depth guard does not. See {@link graphqlMaxNodes}.
|
|
559
|
+
*/
|
|
560
|
+
public maxNodes: number = graphqlMaxNodes();
|
|
561
|
+
|
|
518
562
|
/**
|
|
519
563
|
* Decorator-style resolver registration.
|
|
520
564
|
*
|
|
@@ -692,6 +736,20 @@ export class GraphQL {
|
|
|
692
736
|
}
|
|
693
737
|
|
|
694
738
|
const op = operations[0];
|
|
739
|
+
|
|
740
|
+
// F2: reject an over-complex query (fragment bomb / alias explosion) before
|
|
741
|
+
// any resolver runs.
|
|
742
|
+
if (this.maxNodes > 0) {
|
|
743
|
+
try {
|
|
744
|
+
this.assertComplexity(op.selections, fragments);
|
|
745
|
+
} catch (e: unknown) {
|
|
746
|
+
if (e instanceof ComplexityError) {
|
|
747
|
+
return { data: null, errors: [{ message: e.message }] };
|
|
748
|
+
}
|
|
749
|
+
throw e;
|
|
750
|
+
}
|
|
751
|
+
}
|
|
752
|
+
|
|
695
753
|
const resolvers = op.operation === "query" ? this.queries : this.mutations;
|
|
696
754
|
|
|
697
755
|
// Apply variable defaults
|
|
@@ -723,6 +781,40 @@ export class GraphQL {
|
|
|
723
781
|
* instead of recursing until the interpreter stack overflows. Top-level
|
|
724
782
|
* starts at depth 1; `maxDepth <= 0` disables the guard.
|
|
725
783
|
*/
|
|
784
|
+
/**
|
|
785
|
+
* Count the expanded selection nodes, throwing {@link ComplexityError} past
|
|
786
|
+
* `maxNodes` (F2). Fragment spreads are expanded (so a fragment bomb is
|
|
787
|
+
* counted, not the small unexpanded document) and every field — including
|
|
788
|
+
* each alias — counts once. The count is capped by `maxNodes` and the walk by
|
|
789
|
+
* `maxDepth`, so a circular fragment trips the budget instead of looping and
|
|
790
|
+
* the check itself can never cost more than the budget.
|
|
791
|
+
*/
|
|
792
|
+
private assertComplexity(
|
|
793
|
+
selections: ParsedSelection[],
|
|
794
|
+
fragments: Map<string, ParsedFragment>,
|
|
795
|
+
): void {
|
|
796
|
+
const depthCap = this.maxDepth > 0 ? this.maxDepth : 1000;
|
|
797
|
+
let count = 0;
|
|
798
|
+
const walk = (sels: ParsedSelection[], depth: number): void => {
|
|
799
|
+
if (depth > depthCap) return;
|
|
800
|
+
for (const sel of sels) {
|
|
801
|
+
count++;
|
|
802
|
+
if (count > this.maxNodes) {
|
|
803
|
+
throw new ComplexityError(`Query exceeds maximum complexity of ${this.maxNodes} nodes`);
|
|
804
|
+
}
|
|
805
|
+
if (sel.kind === "fragment_spread") {
|
|
806
|
+
const frag = fragments.get(sel.name);
|
|
807
|
+
if (frag) walk(frag.selections, depth + 1);
|
|
808
|
+
} else if (sel.kind === "inline_fragment") {
|
|
809
|
+
walk(sel.selections, depth + 1);
|
|
810
|
+
} else if (sel.kind === "field" && sel.selections) {
|
|
811
|
+
walk(sel.selections, depth + 1);
|
|
812
|
+
}
|
|
813
|
+
}
|
|
814
|
+
};
|
|
815
|
+
walk(selections, 1);
|
|
816
|
+
}
|
|
817
|
+
|
|
726
818
|
private async resolveSelectionsInto(
|
|
727
819
|
selections: ParsedSelection[],
|
|
728
820
|
resolvers: Map<string, QueryConfig>,
|
|
@@ -90,6 +90,7 @@ export type { ServiceOptions, ServiceContext, ServiceHandler, ServiceInfo } from
|
|
|
90
90
|
export { responseCache, clearCache, cacheStats, cacheGet, cacheSet, cacheDelete, cacheClear, cacheBackendStats, sweep, createBackend, _resetBackend } from "./cache.js";
|
|
91
91
|
export type { ResponseCacheConfig, CacheBackend } from "./cache.js";
|
|
92
92
|
export { Api, ApiStreamError, parseLineStream, parseSseStream } from "./api.js";
|
|
93
|
+
export { guardUrl, isBlockedAddress, allowPrivateRequests, SsrfError, ALLOW_PRIVATE_ENV } from "./ssrf.js";
|
|
93
94
|
export type { ApiResult, ApiOptions, ApiTransport, DownloadResult, UploadOptions, StreamOptions, SseEvent } from "./api.js";
|
|
94
95
|
export { Context, defaultContext, existingContext, fts5Supported, _sharedContexts } from "./context/index.js";
|
|
95
96
|
export type { SearchHit } from "./context/index.js";
|
package/packages/core/src/job.ts
CHANGED
|
@@ -47,6 +47,7 @@ export type QueueJob = JobData & JobLifecycle;
|
|
|
47
47
|
|
|
48
48
|
export interface JobQueueBridge {
|
|
49
49
|
_failJob(topic: string, job: QueueJob, reason: string, maxRetries: number): void;
|
|
50
|
+
_rejectJob(topic: string, job: QueueJob, reason: string, maxRetries: number): void;
|
|
50
51
|
_retryJob(topic: string, job: QueueJob, delaySeconds?: number): void;
|
|
51
52
|
_completeJob(topic: string, job: QueueJob): void;
|
|
52
53
|
getMaxRetries(): number;
|
|
@@ -74,7 +75,13 @@ export function createJob(data: JobData, queue: JobQueueBridge): QueueJob {
|
|
|
74
75
|
queue._failJob(job.topic, job, reason, queue.getMaxRetries());
|
|
75
76
|
},
|
|
76
77
|
reject(reason = "") {
|
|
77
|
-
|
|
78
|
+
// Reject permanently — dead-letter NOW, no retry (ADR-0023). Distinct
|
|
79
|
+
// from fail(): fail() retries until maxRetries is spent; reject() is for
|
|
80
|
+
// a message the consumer KNOWS is poison and sends it straight to the
|
|
81
|
+
// dead-letter store. Was a literal alias for fail() before 3.13.139.
|
|
82
|
+
job.status = "dead";
|
|
83
|
+
job.error = reason;
|
|
84
|
+
queue._rejectJob(job.topic, job, reason, queue.getMaxRetries());
|
|
78
85
|
},
|
|
79
86
|
retry(delaySeconds?: number) {
|
|
80
87
|
queue._retryJob(job.topic, job, delaySeconds);
|
package/packages/core/src/mcp.ts
CHANGED
|
@@ -238,7 +238,9 @@ function envTruthy(val: string | undefined): boolean {
|
|
|
238
238
|
* Python master parity: tina4_python.mcp.is_loopback.
|
|
239
239
|
*/
|
|
240
240
|
export function isLoopback(ip: string | undefined | null): boolean {
|
|
241
|
-
|
|
241
|
+
// An empty or missing peer is UNKNOWN and is not loopback: a runtime path
|
|
242
|
+
// that lost its socket address must fail closed (ADR-0079 s4).
|
|
243
|
+
if (ip == null || ip === "") return false;
|
|
242
244
|
let addr = ip.trim().toLowerCase();
|
|
243
245
|
if (addr.startsWith("::ffff:")) addr = addr.slice(7);
|
|
244
246
|
return addr === "::1" || addr === "localhost" || addr.startsWith("127.");
|
|
@@ -8,7 +8,7 @@ file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
|
|
8
8
|
|
|
9
9
|
import type { Tina4Request, Tina4Response, Middleware } from "./types.js";
|
|
10
10
|
import { HTTP_OK, HTTP_FORBIDDEN } from "./constants.js";
|
|
11
|
-
import { validToken, getPayload } from "./auth.js";
|
|
11
|
+
import { validToken, getPayload, isIdentityPayload } from "./auth.js";
|
|
12
12
|
import { Log } from "./logger.js";
|
|
13
13
|
import { isTruthy } from "./dotenv.js";
|
|
14
14
|
import { defaultRouter, type Router } from "./router.js";
|
|
@@ -1060,7 +1060,9 @@ function csrfBearerIsValid(req: Tina4Request, secret: string): boolean {
|
|
|
1060
1060
|
const authHeader = req.headers.authorization ?? "";
|
|
1061
1061
|
if (!authHeader.startsWith("Bearer ")) return false;
|
|
1062
1062
|
const bearerToken = authHeader.slice(7).trim();
|
|
1063
|
-
|
|
1063
|
+
// Only an IDENTITY token marks an API client; a form token in the Bearer
|
|
1064
|
+
// slot is not one and does not skip the check (ADR-0079 s1).
|
|
1065
|
+
return Boolean(bearerToken && isIdentityPayload(validToken(bearerToken, secret)));
|
|
1064
1066
|
}
|
|
1065
1067
|
|
|
1066
1068
|
function csrfRequestToken(req: Tina4Request): { token: string; fromQuery: boolean } {
|
|
@@ -21,6 +21,7 @@ import {
|
|
|
21
21
|
createSign,
|
|
22
22
|
randomBytes,
|
|
23
23
|
} from "node:crypto";
|
|
24
|
+
import { guardUrl, SsrfError } from "./ssrf.js";
|
|
24
25
|
|
|
25
26
|
const CURVE = "prime256v1";
|
|
26
27
|
const RECORD_SIZE = 4096;
|
|
@@ -40,6 +41,11 @@ export interface PushOptions {
|
|
|
40
41
|
privateKey?: string;
|
|
41
42
|
ttl?: number;
|
|
42
43
|
urgency?: "very-low" | "low" | "normal" | "high";
|
|
44
|
+
/**
|
|
45
|
+
* SSRF guard (ADR-0084): an explicit allow-list of hosts / host:port / CIDRs
|
|
46
|
+
* that bypass the private-endpoint refusal even with the opt-out off.
|
|
47
|
+
*/
|
|
48
|
+
allowHosts?: string[];
|
|
43
49
|
}
|
|
44
50
|
|
|
45
51
|
export interface PushResult {
|
|
@@ -267,10 +273,20 @@ export class Push {
|
|
|
267
273
|
}
|
|
268
274
|
|
|
269
275
|
private async deliver(endpoint: URL, config: { subject: string; publicKey: string; privateKey: string }, keys: { rawPublic: Buffer; rawPrivate: Buffer }, body: Buffer): Promise<PushResult> {
|
|
276
|
+
// SSRF guard (ADR-0084): refuse a private/internal push endpoint.
|
|
277
|
+
try {
|
|
278
|
+
await guardUrl(endpoint.toString(), this.options.allowHosts);
|
|
279
|
+
} catch (error) {
|
|
280
|
+
if (error instanceof SsrfError) throw new PushError(error.message);
|
|
281
|
+
throw error;
|
|
282
|
+
}
|
|
270
283
|
let response: Response;
|
|
271
284
|
try {
|
|
272
285
|
response = await fetch(endpoint, {
|
|
273
286
|
method: "POST",
|
|
287
|
+
// A real push service answers the POST directly; a redirect from a push
|
|
288
|
+
// endpoint is not followed to a private address (ADR-0084).
|
|
289
|
+
redirect: "manual",
|
|
274
290
|
headers: {
|
|
275
291
|
Authorization: `vapid t=${vapidToken(endpoint.toString(), config.subject, keys.rawPrivate, keys.rawPublic)}, k=${config.publicKey}`,
|
|
276
292
|
"Content-Encoding": "aes128gcm",
|
|
@@ -127,7 +127,7 @@ export interface ConsumeOptions {
|
|
|
127
127
|
export interface QueueBackendInterface {
|
|
128
128
|
push(queue: string, payload: unknown, delay?: number, priority?: number): string;
|
|
129
129
|
pop(queue: string): QueueJob | null;
|
|
130
|
-
size(queue: string): number;
|
|
130
|
+
size(queue: string, status?: string): number;
|
|
131
131
|
clear(queue: string): void;
|
|
132
132
|
/**
|
|
133
133
|
* Release whatever connection the backend holds, and be safe to call twice.
|
|
@@ -152,6 +152,7 @@ export interface QueueBackendInterface {
|
|
|
152
152
|
// persistent-connection rewrite lands.
|
|
153
153
|
complete?(queue: string, id: string): void;
|
|
154
154
|
fail?(queue: string, id: string, error: string, maxRetries: number, retryBackoff: number): void;
|
|
155
|
+
reject?(queue: string, id: string, reason: string, maxRetries: number): void;
|
|
155
156
|
/**
|
|
156
157
|
* Explicit manual re-queue: returns true if the id was found and revived,
|
|
157
158
|
* false otherwise (parity with Python's backend.retry_job()). A backend may
|
|
@@ -432,7 +433,7 @@ export class Queue {
|
|
|
432
433
|
const q = this.topic;
|
|
433
434
|
|
|
434
435
|
if (this.externalBackend) {
|
|
435
|
-
return this.externalBackend.size(q);
|
|
436
|
+
return this.externalBackend.size(q, status);
|
|
436
437
|
}
|
|
437
438
|
return this.liteBackend.size(q, status);
|
|
438
439
|
}
|
|
@@ -758,6 +759,18 @@ export class Queue {
|
|
|
758
759
|
this.liteBackend.failJob(queue, job, error, maxRetries, this._retryBackoff);
|
|
759
760
|
}
|
|
760
761
|
|
|
762
|
+
/**
|
|
763
|
+
* Dead-letter a job immediately — no retry (ADR-0023 reject()). Distinct from
|
|
764
|
+
* _failJob, which retries until maxRetries is spent.
|
|
765
|
+
*/
|
|
766
|
+
_rejectJob(queue: string, job: QueueJob, reason: string, maxRetries: number): void {
|
|
767
|
+
if (this.externalBackend?.reject) {
|
|
768
|
+
this.externalBackend.reject(queue, job.id, reason, maxRetries);
|
|
769
|
+
return;
|
|
770
|
+
}
|
|
771
|
+
this.liteBackend.rejectJob(queue, job, reason, maxRetries);
|
|
772
|
+
}
|
|
773
|
+
|
|
761
774
|
/**
|
|
762
775
|
* Re-queue a job back to the main queue directory with incremented attempts.
|
|
763
776
|
*/
|
|
@@ -693,6 +693,21 @@ export class LiteBackend {
|
|
|
693
693
|
* dead-letter file -- two spellings of the same intent that previously
|
|
694
694
|
* diverged.
|
|
695
695
|
*/
|
|
696
|
+
/**
|
|
697
|
+
* Reject a job permanently — dead-letter it NOW, no retry (ADR-0023).
|
|
698
|
+
*
|
|
699
|
+
* Unlike failJob(), which requeues until maxRetries is spent, rejectJob()
|
|
700
|
+
* moves a known-poison job straight to the dead-letter store. attempts is
|
|
701
|
+
* floored at maxRetries so deadLetters() (attempts >= maxRetries) returns it
|
|
702
|
+
* and size("dead") agrees.
|
|
703
|
+
*/
|
|
704
|
+
rejectJob(queue: string, job: QueueJob, reason: string, maxRetries: number): void {
|
|
705
|
+
this.clearReservation(queue, job.id);
|
|
706
|
+
job.attempts = Math.max((job.attempts || 0) + 1, maxRetries);
|
|
707
|
+
job.error = reason;
|
|
708
|
+
this.deadLetter(queue, job, reason);
|
|
709
|
+
}
|
|
710
|
+
|
|
696
711
|
retryJob(queue: string, job: QueueJob, delaySeconds?: number): void {
|
|
697
712
|
// Clear the reservation — the consumer acknowledged (with an explicit retry).
|
|
698
713
|
this.clearReservation(queue, job.id);
|