@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/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.owns(token, sessionId));
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
- headers[key] = val;
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 + (url.search || "");
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.owns(token, sessionId));
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, "Unauthorized");
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 + (url.search || "");
584
+ const fullUrl = forwardedUrl(path, url);
491
585
 
492
586
  // Register this WS so host responses route here
493
587
  registerTunnelWs(requestId, ws);