@linkshell/gateway 0.4.23 → 0.4.25
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/dist/gateway/src/embedded.js +1 -2
- package/dist/gateway/src/embedded.js.map +1 -1
- package/dist/gateway/src/index.js +1 -3
- package/dist/gateway/src/index.js.map +1 -1
- package/dist/gateway/src/tokens.d.ts +1 -0
- package/dist/gateway/src/tokens.js +32 -0
- package/dist/gateway/src/tokens.js.map +1 -1
- package/dist/gateway/src/tunnel.d.ts +3 -0
- package/dist/gateway/src/tunnel.js +103 -12
- package/dist/gateway/src/tunnel.js.map +1 -1
- package/dist/gateway/tsconfig.tsbuildinfo +1 -1
- package/package.json +1 -1
- package/src/embedded.ts +1 -2
- package/src/index.ts +1 -3
- package/src/tokens.ts +28 -0
- package/src/tunnel.ts +106 -12
package/src/tunnel.ts
CHANGED
|
@@ -17,6 +17,9 @@ export interface PendingTunnelRequest {
|
|
|
17
17
|
headersSent: boolean;
|
|
18
18
|
timeout: ReturnType<typeof setTimeout>;
|
|
19
19
|
tunnelCookie?: string;
|
|
20
|
+
sessionId: string;
|
|
21
|
+
port: number;
|
|
22
|
+
requestPath: string;
|
|
20
23
|
}
|
|
21
24
|
|
|
22
25
|
export interface PendingTunnelWs {
|
|
@@ -66,6 +69,17 @@ function extractToken(req: IncomingMessage, url: URL): string | null {
|
|
|
66
69
|
return null;
|
|
67
70
|
}
|
|
68
71
|
|
|
72
|
+
function stripGatewayCookies(cookie: string): string | null {
|
|
73
|
+
const kept = cookie
|
|
74
|
+
.split(";")
|
|
75
|
+
.map((part) => part.trim())
|
|
76
|
+
.filter((part) => {
|
|
77
|
+
const name = part.split("=", 1)[0]?.trim();
|
|
78
|
+
return name !== "lsh_tunnel" && name !== "lsh_token";
|
|
79
|
+
});
|
|
80
|
+
return kept.length > 0 ? kept.join("; ") : null;
|
|
81
|
+
}
|
|
82
|
+
|
|
69
83
|
function bindExplicitDeviceToken(
|
|
70
84
|
tokens: TokenManager,
|
|
71
85
|
sessionId: string,
|
|
@@ -82,6 +96,55 @@ function bindExplicitDeviceToken(
|
|
|
82
96
|
return tokens.bind(token, sessionId) ? token : null;
|
|
83
97
|
}
|
|
84
98
|
|
|
99
|
+
function forwardedUrl(path: string, url: URL): string {
|
|
100
|
+
const params = new URLSearchParams(url.searchParams);
|
|
101
|
+
params.delete("token");
|
|
102
|
+
params.delete("auth_token");
|
|
103
|
+
const query = params.toString();
|
|
104
|
+
return query ? `${path}?${query}` : path;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function stripGatewayAuthParams(pathname: string, searchParams: URLSearchParams, hash = ""): string {
|
|
108
|
+
const params = new URLSearchParams(searchParams);
|
|
109
|
+
params.delete("token");
|
|
110
|
+
params.delete("auth_token");
|
|
111
|
+
const query = params.toString();
|
|
112
|
+
return `${pathname}${query ? `?${query}` : ""}${hash}`;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function rewriteTunnelLocation(
|
|
116
|
+
location: string,
|
|
117
|
+
input: { sessionId: string; port: number; requestPath: string },
|
|
118
|
+
): string {
|
|
119
|
+
try {
|
|
120
|
+
const parsed = new URL(location, `http://127.0.0.1:${input.port}${input.requestPath}`);
|
|
121
|
+
const isLocal =
|
|
122
|
+
parsed.hostname === "127.0.0.1" ||
|
|
123
|
+
parsed.hostname === "localhost" ||
|
|
124
|
+
parsed.hostname === "::1";
|
|
125
|
+
if (!isLocal || parsed.port !== String(input.port)) return location;
|
|
126
|
+
const target = stripGatewayAuthParams(parsed.pathname, parsed.searchParams, parsed.hash);
|
|
127
|
+
return `/tunnel/${encodeURIComponent(input.sessionId)}/${input.port}${target}`;
|
|
128
|
+
} catch {
|
|
129
|
+
return location;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function rewriteTunnelResponseHeaders(
|
|
134
|
+
headers: Record<string, string | string[]>,
|
|
135
|
+
input: { sessionId: string; port: number; requestPath: string },
|
|
136
|
+
): Record<string, string | string[]> {
|
|
137
|
+
const rewritten = { ...headers };
|
|
138
|
+
const locationKey = Object.keys(rewritten).find((key) => key.toLowerCase() === "location");
|
|
139
|
+
if (!locationKey) return rewritten;
|
|
140
|
+
|
|
141
|
+
const location = rewritten[locationKey];
|
|
142
|
+
if (typeof location === "string") {
|
|
143
|
+
rewritten[locationKey] = rewriteTunnelLocation(location, input);
|
|
144
|
+
}
|
|
145
|
+
return rewritten;
|
|
146
|
+
}
|
|
147
|
+
|
|
85
148
|
export function tokenFromTunnelCookie(
|
|
86
149
|
req: IncomingMessage,
|
|
87
150
|
sessionId: string,
|
|
@@ -108,7 +171,7 @@ export function parseTunnelCookie(req: IncomingMessage): { sessionId: string; po
|
|
|
108
171
|
return { sessionId, port, token };
|
|
109
172
|
}
|
|
110
173
|
|
|
111
|
-
function errorResponse(res: ServerResponse, status: number, message: string): void {
|
|
174
|
+
function errorResponse(res: ServerResponse, status: number, message: string, diagnostic?: string): void {
|
|
112
175
|
if (res.headersSent) return;
|
|
113
176
|
res.writeHead(status, {
|
|
114
177
|
"content-type": "text/plain",
|
|
@@ -117,6 +180,7 @@ function errorResponse(res: ServerResponse, status: number, message: string): vo
|
|
|
117
180
|
"surrogate-control": "no-store",
|
|
118
181
|
"vary": "authorization, cookie",
|
|
119
182
|
"access-control-allow-origin": "*",
|
|
183
|
+
...(diagnostic ? { "x-linkshell-tunnel-error": diagnostic } : {}),
|
|
120
184
|
});
|
|
121
185
|
res.end(message);
|
|
122
186
|
}
|
|
@@ -204,9 +268,10 @@ export async function handleTunnelRequest(
|
|
|
204
268
|
|
|
205
269
|
// Auth: device token OR Supabase JWT (userId owns session)
|
|
206
270
|
let token = preAuthToken || extractToken(req, url) || tokenFromTunnelCookie(req, sessionId, port);
|
|
207
|
-
let tokenOwns = Boolean(token && tokens.
|
|
271
|
+
let tokenOwns = Boolean(token && await tokens.ownsFresh(token, sessionId));
|
|
208
272
|
let authOwns = false;
|
|
209
273
|
let authJwt: string | null = null;
|
|
274
|
+
let authDiagnostic = token ? "token_not_bound" : "missing_token";
|
|
210
275
|
if (!tokenOwns && AUTH_REQUIRED) {
|
|
211
276
|
// Try preAuthToken as JWT first (from cookie fallback), then from request headers/params
|
|
212
277
|
const jwtCandidate = preAuthToken || url.searchParams.get("auth_token") || (() => {
|
|
@@ -229,10 +294,20 @@ export async function handleTunnelRequest(
|
|
|
229
294
|
if (user.id && session?.userId && user.id === session.userId) {
|
|
230
295
|
authOwns = true;
|
|
231
296
|
authJwt = jwtCandidate;
|
|
297
|
+
} else if (!session) {
|
|
298
|
+
authDiagnostic = "session_not_local";
|
|
299
|
+
} else if (!session.userId) {
|
|
300
|
+
authDiagnostic = "session_user_missing";
|
|
301
|
+
} else {
|
|
302
|
+
authDiagnostic = "session_user_mismatch";
|
|
232
303
|
}
|
|
304
|
+
} else {
|
|
305
|
+
authDiagnostic = "invalid_jwt";
|
|
233
306
|
}
|
|
234
307
|
}
|
|
235
|
-
} catch {
|
|
308
|
+
} catch {
|
|
309
|
+
authDiagnostic = "jwt_check_failed";
|
|
310
|
+
}
|
|
236
311
|
}
|
|
237
312
|
}
|
|
238
313
|
if (!tokenOwns && authOwns) {
|
|
@@ -243,7 +318,7 @@ export async function handleTunnelRequest(
|
|
|
243
318
|
}
|
|
244
319
|
}
|
|
245
320
|
if (!tokenOwns && !authOwns) {
|
|
246
|
-
errorResponse(res, 401, "Unauthorized");
|
|
321
|
+
errorResponse(res, 401, "Unauthorized", authDiagnostic);
|
|
247
322
|
return;
|
|
248
323
|
}
|
|
249
324
|
|
|
@@ -259,7 +334,7 @@ export async function handleTunnelRequest(
|
|
|
259
334
|
// Validate session & host
|
|
260
335
|
const session = sessions.get(sessionId);
|
|
261
336
|
if (!session || !session.host || session.host.socket.readyState !== session.host.socket.OPEN) {
|
|
262
|
-
errorResponse(res, 502, "Host not connected");
|
|
337
|
+
errorResponse(res, 502, "Host not connected", !session ? "session_not_local" : "host_not_connected");
|
|
263
338
|
return;
|
|
264
339
|
}
|
|
265
340
|
|
|
@@ -290,14 +365,19 @@ export async function handleTunnelRequest(
|
|
|
290
365
|
const skipHeaders = new Set(["host", "connection", "upgrade", "transfer-encoding", "keep-alive"]);
|
|
291
366
|
for (const [key, val] of Object.entries(req.headers)) {
|
|
292
367
|
if (!skipHeaders.has(key) && typeof val === "string") {
|
|
293
|
-
|
|
368
|
+
if (key === "cookie") {
|
|
369
|
+
const cookie = stripGatewayCookies(val);
|
|
370
|
+
if (cookie) headers[key] = cookie;
|
|
371
|
+
} else {
|
|
372
|
+
headers[key] = val;
|
|
373
|
+
}
|
|
294
374
|
}
|
|
295
375
|
}
|
|
296
376
|
|
|
297
377
|
// Reconstruct URL with query string. The gateway authenticates fallback
|
|
298
378
|
// subresources with lsh_tunnel; it must not mutate host-facing URLs with
|
|
299
379
|
// auth query params because that leaks credentials and poisons cache keys.
|
|
300
|
-
const fullUrl = path
|
|
380
|
+
const fullUrl = forwardedUrl(path, url);
|
|
301
381
|
|
|
302
382
|
// Register pending request
|
|
303
383
|
const pending: PendingTunnelRequest = {
|
|
@@ -309,6 +389,9 @@ export async function handleTunnelRequest(
|
|
|
309
389
|
errorResponse(res, 504, "Tunnel request timed out");
|
|
310
390
|
}, TUNNEL_TIMEOUT),
|
|
311
391
|
tunnelCookie,
|
|
392
|
+
sessionId,
|
|
393
|
+
port,
|
|
394
|
+
requestPath: path,
|
|
312
395
|
};
|
|
313
396
|
pendingRequests.set(requestId, pending);
|
|
314
397
|
trackRequest(sessionId, requestId);
|
|
@@ -352,7 +435,7 @@ export function handleTunnelResponse(payload: {
|
|
|
352
435
|
|
|
353
436
|
if (!pending.headersSent) {
|
|
354
437
|
const responseHeaders: Record<string, string | string[]> = {
|
|
355
|
-
...payload.headers,
|
|
438
|
+
...rewriteTunnelResponseHeaders(payload.headers, pending),
|
|
356
439
|
"cache-control": "no-store, private",
|
|
357
440
|
"cdn-cache-control": "no-store",
|
|
358
441
|
"surrogate-control": "no-store",
|
|
@@ -443,8 +526,9 @@ export async function handleTunnelWsUpgrade(
|
|
|
443
526
|
|
|
444
527
|
// Auth: device token OR Supabase JWT (userId owns session)
|
|
445
528
|
let token = preAuthToken || url.searchParams.get("token");
|
|
446
|
-
let tokenOwns = Boolean(token && tokens.
|
|
529
|
+
let tokenOwns = Boolean(token && await tokens.ownsFresh(token, sessionId));
|
|
447
530
|
let authOwns = false;
|
|
531
|
+
let authDiagnostic = token ? "token_not_bound" : "missing_token";
|
|
448
532
|
if (!tokenOwns && AUTH_REQUIRED) {
|
|
449
533
|
// Try auth_token param first, then fall back to token param (cookie fallback stores JWT there)
|
|
450
534
|
const authToken = url.searchParams.get("auth_token") || token;
|
|
@@ -462,10 +546,20 @@ export async function handleTunnelWsUpgrade(
|
|
|
462
546
|
const session = sessions.get(sessionId);
|
|
463
547
|
if (user.id && session?.userId && user.id === session.userId) {
|
|
464
548
|
authOwns = true;
|
|
549
|
+
} else if (!session) {
|
|
550
|
+
authDiagnostic = "session_not_local";
|
|
551
|
+
} else if (!session.userId) {
|
|
552
|
+
authDiagnostic = "session_user_missing";
|
|
553
|
+
} else {
|
|
554
|
+
authDiagnostic = "session_user_mismatch";
|
|
465
555
|
}
|
|
556
|
+
} else {
|
|
557
|
+
authDiagnostic = "invalid_jwt";
|
|
466
558
|
}
|
|
467
559
|
}
|
|
468
|
-
} catch {
|
|
560
|
+
} catch {
|
|
561
|
+
authDiagnostic = "jwt_check_failed";
|
|
562
|
+
}
|
|
469
563
|
}
|
|
470
564
|
}
|
|
471
565
|
if (!tokenOwns && authOwns) {
|
|
@@ -476,7 +570,7 @@ export async function handleTunnelWsUpgrade(
|
|
|
476
570
|
}
|
|
477
571
|
}
|
|
478
572
|
if (!tokenOwns && !authOwns) {
|
|
479
|
-
ws.close(4001,
|
|
573
|
+
ws.close(4001, `Unauthorized:${authDiagnostic}`);
|
|
480
574
|
return;
|
|
481
575
|
}
|
|
482
576
|
|
|
@@ -487,7 +581,7 @@ export async function handleTunnelWsUpgrade(
|
|
|
487
581
|
}
|
|
488
582
|
|
|
489
583
|
const requestId = randomUUID();
|
|
490
|
-
const fullUrl = path
|
|
584
|
+
const fullUrl = forwardedUrl(path, url);
|
|
491
585
|
|
|
492
586
|
// Register this WS so host responses route here
|
|
493
587
|
registerTunnelWs(requestId, ws);
|