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
|
@@ -307,12 +307,36 @@ export class MongoBackend implements QueueBackend {
|
|
|
307
307
|
}
|
|
308
308
|
}
|
|
309
309
|
else if (operation === "size") {
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
310
|
+
// ADR-0022 dec 7: size(status) must not answer a different question.
|
|
311
|
+
// The dead aliases count the .dead_letter store (== deadLetters().length);
|
|
312
|
+
// reserved/completed count by status; else pending. data = status.
|
|
313
|
+
const status = data || "pending";
|
|
314
|
+
let count: number;
|
|
315
|
+
if (status === "dead" || status === "failed" || status === "dead_letter") {
|
|
316
|
+
count = await col.countDocuments({ queue: queueName + ".dead_letter" });
|
|
317
|
+
} else if (status === "reserved" || status === "completed") {
|
|
318
|
+
count = await col.countDocuments({ queue: queueName, status });
|
|
319
|
+
} else {
|
|
320
|
+
count = await col.countDocuments({ queue: queueName, status: "pending" });
|
|
321
|
+
}
|
|
314
322
|
process.stdout.write(String(count));
|
|
315
323
|
}
|
|
324
|
+
else if (operation === "reject") {
|
|
325
|
+
// Dead-letter NOW, no retry (ADR-0023). Floor attempts at maxRetries
|
|
326
|
+
// so it reads as exhausted (parity with fail()'s dead-letter branch
|
|
327
|
+
// and size("dead")). data = JSON { id, reason, maxRetries }.
|
|
328
|
+
const info = JSON.parse(data);
|
|
329
|
+
const doc = await col.findOne({ queue: queueName, id: info.id });
|
|
330
|
+
if (doc) {
|
|
331
|
+
const attempts = Math.max((doc.attempts || 0) + 1, info.maxRetries);
|
|
332
|
+
await col.insertOne({
|
|
333
|
+
...doc, _id: undefined, attempts,
|
|
334
|
+
status: "dead", queue: queueName + ".dead_letter", error: info.reason,
|
|
335
|
+
});
|
|
336
|
+
await col.deleteOne({ _id: doc._id, queue: queueName });
|
|
337
|
+
}
|
|
338
|
+
process.stdout.write("__OK__");
|
|
339
|
+
}
|
|
316
340
|
else if (operation === "clear") {
|
|
317
341
|
await col.deleteMany({ queue: queueName });
|
|
318
342
|
process.stdout.write("__CLEARED__");
|
|
@@ -559,12 +583,22 @@ export class MongoBackend implements QueueBackend {
|
|
|
559
583
|
}
|
|
560
584
|
}
|
|
561
585
|
|
|
562
|
-
size(queue: string): number {
|
|
563
|
-
const result = this.execSync("size", queue);
|
|
586
|
+
size(queue: string, status: string = "pending"): number {
|
|
587
|
+
const result = this.execSync("size", queue, status);
|
|
564
588
|
const num = parseInt(result, 10);
|
|
565
589
|
return isNaN(num) ? 0 : num;
|
|
566
590
|
}
|
|
567
591
|
|
|
592
|
+
/**
|
|
593
|
+
* Dead-letter a job immediately — no retry (ADR-0023 reject()). Distinct from
|
|
594
|
+
* fail(): fail() requeues until maxRetries is spent. attempts is floored at
|
|
595
|
+
* maxRetries so deadLetters()/size("dead") (which filter/scope to the
|
|
596
|
+
* dead-letter store) see it.
|
|
597
|
+
*/
|
|
598
|
+
reject(queue: string, id: string, reason: string, maxRetries: number): void {
|
|
599
|
+
this.execSync("reject", queue, JSON.stringify({ id, reason, maxRetries }));
|
|
600
|
+
}
|
|
601
|
+
|
|
568
602
|
clear(queue: string): void {
|
|
569
603
|
this.execSync("clear", queue);
|
|
570
604
|
}
|
|
@@ -2056,9 +2056,14 @@ export async function startServer(config?: Tina4Config): Promise<{
|
|
|
2056
2056
|
// Auto-generate a per-machine dev secret to a gitignored .env.local when one
|
|
2057
2057
|
// is missing (dev only, never CI/prod). Must run after env load and before
|
|
2058
2058
|
// any auth use. Local import avoids a load-time cycle through auth.
|
|
2059
|
-
const { ensureDevSecret } = await import("./auth.js");
|
|
2059
|
+
const { ensureDevSecret, requireBootSecret } = await import("./auth.js");
|
|
2060
2060
|
ensureDevSecret();
|
|
2061
2061
|
|
|
2062
|
+
// Refuse to serve with a secret anyone can reproduce: blank outside dev, or
|
|
2063
|
+
// set but shorter than 32 bytes in any mode (ADR-0079 s2). Throws the
|
|
2064
|
+
// actionable message naming TINA4_SECRET.
|
|
2065
|
+
requireBootSecret();
|
|
2066
|
+
|
|
2062
2067
|
// Refuse to boot with pre-3.12 un-prefixed env vars set.
|
|
2063
2068
|
_checkLegacyEnvVars();
|
|
2064
2069
|
|
|
@@ -160,6 +160,16 @@ const CREATE_TABLE: Record<string, string> = {
|
|
|
160
160
|
`,
|
|
161
161
|
};
|
|
162
162
|
|
|
163
|
+
/** How many times ensureTable() tries the CREATE before a failure is real. */
|
|
164
|
+
const CREATE_ATTEMPTS = 5;
|
|
165
|
+
/** Base pause between tries; it grows linearly (50, 100, 150, 200 ms). */
|
|
166
|
+
const CREATE_RETRY_DELAY_MS = 50;
|
|
167
|
+
|
|
168
|
+
/** Block this thread for `ms` - the SessionHandler interface is synchronous. */
|
|
169
|
+
function sleepSync(ms: number): void {
|
|
170
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
|
171
|
+
}
|
|
172
|
+
|
|
163
173
|
/**
|
|
164
174
|
* Read a column out of a result row, case-insensitively.
|
|
165
175
|
*
|
|
@@ -223,6 +233,12 @@ export class DatabaseSessionHandler implements SessionHandler {
|
|
|
223
233
|
private get sqlite(): any {
|
|
224
234
|
if (this.sqliteHandle === null) {
|
|
225
235
|
this.sqliteHandle = new DatabaseSync(this.dbPath as string);
|
|
236
|
+
// Wait up to 5 s for another process's write lock instead of failing at
|
|
237
|
+
// once with "database is locked" - node:sqlite's default is NO wait, so
|
|
238
|
+
// racing processes collided on the first overlap (five of six died,
|
|
239
|
+
// measured). 5000 ms matches PHP's busyTimeout(5000) and Python's sqlite3
|
|
240
|
+
// default. Set BEFORE the WAL pragma so that write waits too.
|
|
241
|
+
this.sqliteHandle.exec("PRAGMA busy_timeout = 5000");
|
|
226
242
|
this.sqliteHandle.exec("PRAGMA journal_mode = WAL");
|
|
227
243
|
}
|
|
228
244
|
return this.sqliteHandle;
|
|
@@ -342,16 +358,57 @@ export class DatabaseSessionHandler implements SessionHandler {
|
|
|
342
358
|
}
|
|
343
359
|
|
|
344
360
|
/**
|
|
345
|
-
* Ensure the session table exists (called once on first use)
|
|
361
|
+
* Ensure the session table exists (called once on first use) - safely when
|
|
362
|
+
* several processes race to create it.
|
|
363
|
+
*
|
|
364
|
+
* Every per-engine statement above is check-then-act, so two processes can
|
|
365
|
+
* both find the table absent and both CREATE; the loser errors, and every
|
|
366
|
+
* engine spells that differently (a pg_type unique violation on PostgreSQL,
|
|
367
|
+
* Msg 2714 on SQL Server, an RDB$RELATIONS unique violation on Firebird). So
|
|
368
|
+
* a failure is judged by RE-CHECKING whether the table now exists, never by
|
|
369
|
+
* parsing the message - the rescue the Firebird note above has always
|
|
370
|
+
* assumed, and which was not here. MEASURED on the lab before it: six real
|
|
371
|
+
* processes racing lost one to five of them on PostgreSQL, SQLite and
|
|
372
|
+
* Firebird.
|
|
373
|
+
*
|
|
374
|
+
* The re-check is RETRIED because the loser can fail BEFORE the winner has
|
|
375
|
+
* committed. On MySQL, CREATE TABLE IF NOT EXISTS takes a shared metadata
|
|
376
|
+
* lock, checks, then upgrades it to exclusive; two upgrades deadlock, and when
|
|
377
|
+
* MySQL cannot back the victim off silently it answers 1213 "Deadlock found"
|
|
378
|
+
* while the winner's table is still invisible (tina4-php CI run 35972320442).
|
|
379
|
+
* Bounded, so a genuine failure (no CREATE grant) still throws. Same attempts
|
|
380
|
+
* and delays in all four frameworks.
|
|
346
381
|
*/
|
|
347
382
|
private ensureTable(): void {
|
|
348
383
|
if (this.initialized) return;
|
|
349
384
|
const ddl = CREATE_TABLE[this.engine];
|
|
350
|
-
|
|
351
|
-
|
|
385
|
+
for (let attempt = 1; ; attempt++) {
|
|
386
|
+
try {
|
|
387
|
+
if (this.target === null) this.sqlite.exec(ddl);
|
|
388
|
+
else sqlCommandSync(this.target, ddl, []);
|
|
389
|
+
break;
|
|
390
|
+
} catch (error) {
|
|
391
|
+
if (this.tableExists()) break;
|
|
392
|
+
if (attempt >= CREATE_ATTEMPTS) throw error;
|
|
393
|
+
sleepSync(CREATE_RETRY_DELAY_MS * attempt);
|
|
394
|
+
}
|
|
395
|
+
}
|
|
352
396
|
this.initialized = true;
|
|
353
397
|
}
|
|
354
398
|
|
|
399
|
+
/**
|
|
400
|
+
* Whether tina4_session exists, asked the one way every engine answers alike:
|
|
401
|
+
* a query against it that returns no rows succeeds only if it is there.
|
|
402
|
+
*/
|
|
403
|
+
private tableExists(): boolean {
|
|
404
|
+
try {
|
|
405
|
+
this.query("SELECT 1 FROM tina4_session WHERE 1 = 0", []);
|
|
406
|
+
return true;
|
|
407
|
+
} catch {
|
|
408
|
+
return false;
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
|
|
355
412
|
read(sessionId: string): SessionData | null {
|
|
356
413
|
this.ensureTable();
|
|
357
414
|
|
package/packages/core/src/sso.ts
CHANGED
|
@@ -13,6 +13,15 @@ import type { Router } from "./router.js";
|
|
|
13
13
|
export class SsoError extends Error {}
|
|
14
14
|
|
|
15
15
|
type Json = Record<string, any>;
|
|
16
|
+
|
|
17
|
+
export { liveSessionIdentity } from "./ssoIdentity.js";
|
|
18
|
+
import { liveSessionIdentity } from "./ssoIdentity.js";
|
|
19
|
+
|
|
20
|
+
/** When the provider's access token lapses; 0 when it gave no lifetime (never "now"). */
|
|
21
|
+
function expiresAt(tokens: Json): number {
|
|
22
|
+
const lifetime = Number(tokens.expires_in ?? 0);
|
|
23
|
+
return lifetime > 0 ? Math.floor(Date.now() / 1000) + lifetime : 0;
|
|
24
|
+
}
|
|
16
25
|
type SessionLike = {
|
|
17
26
|
get(key: string): unknown;
|
|
18
27
|
set(key: string, value: unknown): void;
|
|
@@ -225,14 +234,13 @@ export class Sso {
|
|
|
225
234
|
session!.regenerate();
|
|
226
235
|
session!.set(Sso.SESSION_KEY, {
|
|
227
236
|
version: 1, identity, access_token: tokens.access_token, refresh_token: tokens.refresh_token,
|
|
228
|
-
id_token: tokens.id_token, expires_at:
|
|
237
|
+
id_token: tokens.id_token, expires_at: expiresAt(tokens),
|
|
229
238
|
});
|
|
230
239
|
return { identity, return_to: Sso.safeReturn(pending.return_to) };
|
|
231
240
|
}
|
|
232
241
|
|
|
233
242
|
identity(requestOrSession: any): Json | null {
|
|
234
|
-
const
|
|
235
|
-
const identity = stored?.identity ?? null;
|
|
243
|
+
const identity = liveSessionIdentity(this.session(requestOrSession)?.get(Sso.SESSION_KEY));
|
|
236
244
|
if (identity && requestOrSession?.session) requestOrSession.user = identity;
|
|
237
245
|
return identity;
|
|
238
246
|
}
|
|
@@ -251,7 +259,7 @@ export class Sso {
|
|
|
251
259
|
const identity = this.normalize(claims);
|
|
252
260
|
session!.set(Sso.SESSION_KEY, { ...stored, identity, access_token: tokens.access_token,
|
|
253
261
|
refresh_token: tokens.refresh_token ?? stored.refresh_token, id_token: tokens.id_token ?? stored.id_token,
|
|
254
|
-
expires_at:
|
|
262
|
+
expires_at: expiresAt(tokens) });
|
|
255
263
|
return identity;
|
|
256
264
|
} catch (error) { session?.delete(Sso.SESSION_KEY); throw error; }
|
|
257
265
|
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The stored SSO identity, only while it is live (ADR-0079 s5). `stored` is the
|
|
3
|
+
* reserved _tina4_sso session value. The identity needs an issuer and a
|
|
4
|
+
* subject, and a numeric expires_at that is 0 (the provider gave no lifetime)
|
|
5
|
+
* or still in the future.
|
|
6
|
+
*
|
|
7
|
+
* Its own dependency-free module, so the auth gate can use it without
|
|
8
|
+
* importing the OIDC client (and everything the client imports).
|
|
9
|
+
*/
|
|
10
|
+
export function liveSessionIdentity(stored: unknown): Record<string, any> | null {
|
|
11
|
+
if (!stored || typeof stored !== "object") return null;
|
|
12
|
+
const identity = (stored as Record<string, any>).identity;
|
|
13
|
+
if (!identity || typeof identity !== "object" || !identity.issuer || !identity.subject) return null;
|
|
14
|
+
const expiresAt = (stored as Record<string, any>).expires_at ?? 0;
|
|
15
|
+
if (typeof expiresAt !== "number") return null;
|
|
16
|
+
if (expiresAt > 0 && Math.floor(Date.now() / 1000) >= expiresAt) return null;
|
|
17
|
+
return identity;
|
|
18
|
+
}
|
|
19
|
+
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Outbound SSRF guard (ADR-0084).
|
|
3
|
+
*
|
|
4
|
+
* The Api client and Web Push refuse, by default, to connect to a private or
|
|
5
|
+
* internal address. Before each connection - the initial URL and every redirect
|
|
6
|
+
* hop the Api client follows - the host is resolved to its IP address(es) and
|
|
7
|
+
* the request is refused if any resolved address is loopback, private,
|
|
8
|
+
* link-local (including the cloud metadata address 169.254.169.254), unspecified
|
|
9
|
+
* or CGNAT. A non-http(s) scheme is refused.
|
|
10
|
+
*
|
|
11
|
+
* Off by default; TINA4_ALLOW_PRIVATE_REQUESTS (truthy) or an explicit allow-list
|
|
12
|
+
* of hosts / host:port / CIDRs opts out. Zero external dependencies - node:net +
|
|
13
|
+
* node:dns.
|
|
14
|
+
*/
|
|
15
|
+
import { isIP } from "node:net";
|
|
16
|
+
import { lookup } from "node:dns/promises";
|
|
17
|
+
|
|
18
|
+
export const ALLOW_PRIVATE_ENV = "TINA4_ALLOW_PRIVATE_REQUESTS";
|
|
19
|
+
|
|
20
|
+
/** Truthiness identical to ADR-0070's set (trimmed, lower-cased). */
|
|
21
|
+
const TRUTHY: ReadonlySet<string> = new Set(["1", "true", "yes", "on"]);
|
|
22
|
+
|
|
23
|
+
/** Raised when the SSRF guard refuses an outbound request. */
|
|
24
|
+
export class SsrfError extends Error {
|
|
25
|
+
constructor(message: string) {
|
|
26
|
+
super(message);
|
|
27
|
+
this.name = "SsrfError";
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
interface Cidr {
|
|
32
|
+
net: Uint8Array;
|
|
33
|
+
bits: number;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* The blocked address space, one definition shared with the contract fixture.
|
|
38
|
+
* 0.0.0.0/8 covers the unspecified address and the "this network" block.
|
|
39
|
+
*/
|
|
40
|
+
const BLOCKED_CIDRS: readonly string[] = [
|
|
41
|
+
"0.0.0.0/8", "10.0.0.0/8", "127.0.0.0/8", "169.254.0.0/16",
|
|
42
|
+
"172.16.0.0/12", "192.168.0.0/16", "100.64.0.0/10",
|
|
43
|
+
"::1/128", "::/128", "fc00::/7", "fe80::/10",
|
|
44
|
+
];
|
|
45
|
+
|
|
46
|
+
function v4ToBytes(ip: string): Uint8Array | null {
|
|
47
|
+
const parts = ip.split(".");
|
|
48
|
+
if (parts.length !== 4) return null;
|
|
49
|
+
const bytes = new Uint8Array(4);
|
|
50
|
+
for (let i = 0; i < 4; i++) {
|
|
51
|
+
const n = Number(parts[i]);
|
|
52
|
+
if (!Number.isInteger(n) || n < 0 || n > 255 || !/^\d+$/.test(parts[i])) return null;
|
|
53
|
+
bytes[i] = n;
|
|
54
|
+
}
|
|
55
|
+
return bytes;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function v6ToBytes(input: string): Uint8Array | null {
|
|
59
|
+
let ip = input.split("%")[0];
|
|
60
|
+
// Embedded IPv4 tail (::ffff:1.2.3.4).
|
|
61
|
+
const lastColon = ip.lastIndexOf(":");
|
|
62
|
+
const tail = ip.slice(lastColon + 1);
|
|
63
|
+
if (tail.includes(".")) {
|
|
64
|
+
const v4 = v4ToBytes(tail);
|
|
65
|
+
if (!v4) return null;
|
|
66
|
+
const hextetA = ((v4[0] << 8) | v4[1]).toString(16);
|
|
67
|
+
const hextetB = ((v4[2] << 8) | v4[3]).toString(16);
|
|
68
|
+
ip = ip.slice(0, lastColon + 1) + hextetA + ":" + hextetB;
|
|
69
|
+
}
|
|
70
|
+
const halves = ip.split("::");
|
|
71
|
+
if (halves.length > 2) return null;
|
|
72
|
+
const head = halves[0] ? halves[0].split(":") : [];
|
|
73
|
+
const back = halves.length === 2 ? (halves[1] ? halves[1].split(":") : []) : [];
|
|
74
|
+
let groups: string[];
|
|
75
|
+
if (halves.length === 1) {
|
|
76
|
+
if (head.length !== 8) return null;
|
|
77
|
+
groups = head;
|
|
78
|
+
} else {
|
|
79
|
+
const missing = 8 - (head.length + back.length);
|
|
80
|
+
if (missing < 0) return null;
|
|
81
|
+
groups = [...head, ...Array(missing).fill("0"), ...back];
|
|
82
|
+
}
|
|
83
|
+
if (groups.length !== 8) return null;
|
|
84
|
+
const bytes = new Uint8Array(16);
|
|
85
|
+
for (let i = 0; i < 8; i++) {
|
|
86
|
+
if (!/^[0-9a-fA-F]{1,4}$/.test(groups[i])) return null;
|
|
87
|
+
const g = parseInt(groups[i], 16);
|
|
88
|
+
bytes[i * 2] = (g >> 8) & 0xff;
|
|
89
|
+
bytes[i * 2 + 1] = g & 0xff;
|
|
90
|
+
}
|
|
91
|
+
return bytes;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Parse an IP string (v4 or v6, IPv4-mapped included) to its packed bytes. */
|
|
95
|
+
function ipToBytes(ip: string): Uint8Array | null {
|
|
96
|
+
const cleaned = ip.split("%")[0];
|
|
97
|
+
const family = isIP(cleaned);
|
|
98
|
+
if (family === 4) return v4ToBytes(cleaned);
|
|
99
|
+
if (family === 6) {
|
|
100
|
+
const bytes = v6ToBytes(cleaned);
|
|
101
|
+
if (!bytes) return null;
|
|
102
|
+
// Unwrap IPv4-mapped IPv6 (::ffff:a.b.c.d) to its 4-byte IPv4.
|
|
103
|
+
const isMapped = bytes.subarray(0, 10).every((b) => b === 0)
|
|
104
|
+
&& bytes[10] === 0xff && bytes[11] === 0xff;
|
|
105
|
+
return isMapped ? bytes.subarray(12) : bytes;
|
|
106
|
+
}
|
|
107
|
+
return null;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function parseCidr(cidr: string): Cidr | null {
|
|
111
|
+
const [netStr, bitsStr] = cidr.split("/");
|
|
112
|
+
const net = ipToBytes(netStr);
|
|
113
|
+
if (!net) return null;
|
|
114
|
+
return { net, bits: Number(bitsStr) };
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const BLOCKED: readonly Cidr[] = BLOCKED_CIDRS
|
|
118
|
+
.map(parseCidr)
|
|
119
|
+
.filter((c): c is Cidr => c !== null);
|
|
120
|
+
|
|
121
|
+
function inCidr(packed: Uint8Array, cidr: Cidr): boolean {
|
|
122
|
+
if (packed.length !== cidr.net.length) return false;
|
|
123
|
+
const whole = Math.floor(cidr.bits / 8);
|
|
124
|
+
const remainder = cidr.bits % 8;
|
|
125
|
+
for (let i = 0; i < whole; i++) {
|
|
126
|
+
if (packed[i] !== cidr.net[i]) return false;
|
|
127
|
+
}
|
|
128
|
+
if (remainder === 0) return true;
|
|
129
|
+
const mask = (0xff << (8 - remainder)) & 0xff;
|
|
130
|
+
return (packed[whole] & mask) === (cidr.net[whole] & mask);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** True when TINA4_ALLOW_PRIVATE_REQUESTS opts out of the guard. */
|
|
134
|
+
export function allowPrivateRequests(): boolean {
|
|
135
|
+
return TRUTHY.has((process.env[ALLOW_PRIVATE_ENV] ?? "").trim().toLowerCase());
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Classify one IP string: true = private/internal, refuse it. An IPv4-mapped
|
|
140
|
+
* IPv6 address is classified by its embedded IPv4. An unparseable value is
|
|
141
|
+
* treated as blocked - the guard refuses what it cannot classify.
|
|
142
|
+
*/
|
|
143
|
+
export function isBlockedAddress(ip: string): boolean {
|
|
144
|
+
const packed = ipToBytes(ip);
|
|
145
|
+
if (!packed) return true;
|
|
146
|
+
return BLOCKED.some((cidr) => inCidr(packed, cidr));
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function matchesAllowList(host: string, port: number, resolved: string[], allowHosts?: string[]): boolean {
|
|
150
|
+
const hostLower = host.toLowerCase();
|
|
151
|
+
for (const raw of allowHosts ?? []) {
|
|
152
|
+
const entry = String(raw).trim().toLowerCase();
|
|
153
|
+
if (!entry) continue;
|
|
154
|
+
if (entry === hostLower || entry === `${hostLower}:${port}`) return true;
|
|
155
|
+
if (entry.includes("/")) {
|
|
156
|
+
const cidr = parseCidr(entry);
|
|
157
|
+
if (cidr && resolved.some((ip) => {
|
|
158
|
+
const packed = ipToBytes(ip);
|
|
159
|
+
return packed !== null && inCidr(packed, cidr);
|
|
160
|
+
})) {
|
|
161
|
+
return true;
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
return false;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function logBlock(host: string, ip: string): void {
|
|
169
|
+
// Log lazily to avoid a hard dependency cycle with the logger.
|
|
170
|
+
void import("./logger.js")
|
|
171
|
+
.then((mod) => mod.Log?.warning?.(`SSRF guard blocked outbound request to ${host} (${ip})`))
|
|
172
|
+
.catch(() => undefined);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Refuse `url` when it targets a private/internal address. Throws SsrfError for a
|
|
177
|
+
* non-http(s) scheme, an unresolvable host, or any resolved address in the
|
|
178
|
+
* blocked space. `allowHosts` is a list of hosts / host:port / CIDRs to permit.
|
|
179
|
+
*/
|
|
180
|
+
export async function guardUrl(url: string, allowHosts?: string[]): Promise<void> {
|
|
181
|
+
let parsed: URL;
|
|
182
|
+
try {
|
|
183
|
+
parsed = new URL(url);
|
|
184
|
+
} catch {
|
|
185
|
+
throw new SsrfError(`Blocked request: '${url}' is not a valid URL`);
|
|
186
|
+
}
|
|
187
|
+
const scheme = parsed.protocol.replace(/:$/, "").toLowerCase();
|
|
188
|
+
if (scheme !== "http" && scheme !== "https") {
|
|
189
|
+
throw new SsrfError(`Blocked request: URL scheme '${scheme || "(none)"}' is not http or https`);
|
|
190
|
+
}
|
|
191
|
+
const host = parsed.hostname.replace(/^\[|\]$/g, "");
|
|
192
|
+
if (!host) {
|
|
193
|
+
throw new SsrfError("Blocked request: URL has no host");
|
|
194
|
+
}
|
|
195
|
+
const port = parsed.port ? Number(parsed.port) : (scheme === "https" ? 443 : 80);
|
|
196
|
+
|
|
197
|
+
if (allowPrivateRequests()) return;
|
|
198
|
+
|
|
199
|
+
let resolved: string[];
|
|
200
|
+
try {
|
|
201
|
+
const results = await lookup(host, { all: true });
|
|
202
|
+
resolved = results.map((r) => r.address);
|
|
203
|
+
} catch {
|
|
204
|
+
throw new SsrfError(`Blocked request to ${host}: cannot resolve host`);
|
|
205
|
+
}
|
|
206
|
+
if (resolved.length === 0) {
|
|
207
|
+
throw new SsrfError(`Blocked request to ${host}: cannot resolve host`);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
if (matchesAllowList(host, port, resolved, allowHosts)) return;
|
|
211
|
+
|
|
212
|
+
for (const ip of resolved) {
|
|
213
|
+
if (isBlockedAddress(ip)) {
|
|
214
|
+
logBlock(host, ip);
|
|
215
|
+
throw new SsrfError(
|
|
216
|
+
`Blocked request to private/internal address ${ip} (host ${host}): ` +
|
|
217
|
+
`set ${ALLOW_PRIVATE_ENV}=true to allow, or pass an allow-list.`,
|
|
218
|
+
);
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
}
|
|
@@ -207,6 +207,9 @@ export class TestClient {
|
|
|
207
207
|
|
|
208
208
|
// Create a mock IncomingMessage
|
|
209
209
|
const socket = new Socket();
|
|
210
|
+
// The in-process client is a local caller and says so explicitly: an
|
|
211
|
+
// unknown (empty) peer is not loopback (ADR-0079 s4).
|
|
212
|
+
Object.defineProperty(socket, "remoteAddress", { value: "127.0.0.1" });
|
|
210
213
|
const rawReq = new IncomingMessage(socket);
|
|
211
214
|
rawReq.method = method.toUpperCase();
|
|
212
215
|
rawReq.url = path;
|
|
@@ -38,7 +38,7 @@ import type { WebSocketConnection } from "./websocketConnection.js";
|
|
|
38
38
|
import type { WebSocketRouteHandler } from "./types.js";
|
|
39
39
|
import { Router } from "./router.js";
|
|
40
40
|
import { Log } from "./logger.js";
|
|
41
|
-
import { validToken } from "./auth.js";
|
|
41
|
+
import { validToken, isIdentityPayload } from "./auth.js";
|
|
42
42
|
import { WsBackplaneManager, type WsEnvelope } from "./websocketBackplane.js";
|
|
43
43
|
|
|
44
44
|
// ── Constants ────────────────────────────────────────────────
|
|
@@ -211,7 +211,9 @@ export function wsAuthorized(
|
|
|
211
211
|
const token = wsToken(headers, queryString, subprotocol);
|
|
212
212
|
if (!token) return [null, false];
|
|
213
213
|
const payload = validToken(token);
|
|
214
|
-
|
|
214
|
+
// A form token is not an identity (ADR-0079 s1).
|
|
215
|
+
if (!isIdentityPayload(payload)) return [null, false];
|
|
216
|
+
return [payload, true];
|
|
215
217
|
}
|
|
216
218
|
|
|
217
219
|
/**
|
|
@@ -1415,6 +1415,12 @@ function _b64url(data) {
|
|
|
1415
1415
|
var _formTokenSessionId = "";
|
|
1416
1416
|
function _buildFormTokenJwt(descriptor = "") {
|
|
1417
1417
|
const secret = process.env.TINA4_SECRET ?? "";
|
|
1418
|
+
const secretBytes = Buffer.byteLength(secret, "utf8");
|
|
1419
|
+
if (secretBytes < 32) {
|
|
1420
|
+
throw new Error(
|
|
1421
|
+
`Auth: TINA4_SECRET ${secretBytes === 0 ? "is not set" : `is ${secretBytes} bytes`}; an HMAC JWT secret must be at least 32 bytes. Generate one with \`openssl rand -hex 32\` and set TINA4_SECRET in your environment or .env.`
|
|
1422
|
+
);
|
|
1423
|
+
}
|
|
1418
1424
|
const ttlMinutes = parseInt(process.env.TINA4_TOKEN_LIMIT || "60", 10);
|
|
1419
1425
|
const header = { alg: "HS256", typ: "JWT" };
|
|
1420
1426
|
const now = Math.floor(Date.now() / 1e3);
|
|
@@ -1794,6 +1794,15 @@ function _buildFormTokenJwt(descriptor: string = ""): string {
|
|
|
1794
1794
|
// AND a ''-forged token was accepted (CSRF-NODE-SECRET-SPLIT, feature 37).
|
|
1795
1795
|
// The generator and the validator MUST resolve the same secret.
|
|
1796
1796
|
const secret = process.env.TINA4_SECRET ?? "";
|
|
1797
|
+
// Same rule as core auth (ADR-0079 s2): never sign with a blank or short key,
|
|
1798
|
+
// which would make every form token reproducible by anyone.
|
|
1799
|
+
const secretBytes = Buffer.byteLength(secret, "utf8");
|
|
1800
|
+
if (secretBytes < 32) {
|
|
1801
|
+
throw new Error(
|
|
1802
|
+
`Auth: TINA4_SECRET ${secretBytes === 0 ? "is not set" : `is ${secretBytes} bytes`}; an HMAC JWT secret must be at least 32 bytes. ` +
|
|
1803
|
+
"Generate one with `openssl rand -hex 32` and set TINA4_SECRET in your environment or .env.",
|
|
1804
|
+
);
|
|
1805
|
+
}
|
|
1797
1806
|
const ttlMinutes = parseInt(process.env.TINA4_TOKEN_LIMIT || "60", 10);
|
|
1798
1807
|
|
|
1799
1808
|
const header = { alg: "HS256", typ: "JWT" };
|