dsh-enhancement 1.1.2 → 1.2.0

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/README.en.md CHANGED
@@ -27,6 +27,7 @@ Set a password, open the gateway URL, and log in to DSH without carrying around
27
27
  - Choose which network interface to listen on.
28
28
  - Works with normal DSH chat, files, terminal, WebSocket, and live streams.
29
29
  - Configurable login session lifetime; changing the password invalidates all gateway sessions.
30
+ - The gateway session cookie name is scoped by protocol and port, so multiple profiles/gateways on the same host never overwrite each other; changing the port requires logging in again.
30
31
 
31
32
  ## HTTPS
32
33
 
@@ -105,7 +106,7 @@ Clicking a desktop notification opens the matching session. On Linux and Windows
105
106
  dsh plugin --profile web add dsh-enhancement
106
107
  ```
107
108
 
108
- Restart DSH Web after installation. The plugin brings in the mobile and portrait-layout dependencies automatically; do not install or enable them separately.
109
+ Restart DSH Web after installation. The plugin brings in the portrait-layout dependency automatically; do not install or enable it separately.
109
110
 
110
111
  ## Getting started
111
112
 
package/README.md CHANGED
@@ -27,6 +27,7 @@
27
27
  - 可以选择监听的网卡地址,默认只在你指定的地址上提供服务。
28
28
  - 支持 DSH 的正常聊天、文件、终端、WebSocket 和实时流,不只是静态页面。
29
29
  - 可以设置登录会话的有效期;修改密码后,所有已登录的网关会话都会失效。
30
+ - 网关会话 cookie 名按「协议 + 端口」区分,同机多 profile / 多网关互不覆盖;换端口需重新登录。
30
31
 
31
32
  ## HTTPS
32
33
 
@@ -106,7 +107,7 @@ DSH 只在直连官方 DeepSeek API 时发送 `x-deepseek-harness-session-id`;
106
107
  dsh plugin --profile web add dsh-enhancement
107
108
  ```
108
109
 
109
- 安装后重启 DSH Web。插件会自动带入手机布局和竖屏布局依赖,不需要再单独安装或启用它们。
110
+ 安装后重启 DSH Web。插件会自动带入竖屏布局依赖,不需要再单独安装或启用它。
110
111
 
111
112
  ## 开始使用
112
113
 
package/cordis.patch.yml CHANGED
@@ -1,10 +1,8 @@
1
- # dsh-enhancement bundle patch:把本插件依赖的两个外部组件连同插件一起插入。
1
+ # dsh-enhancement bundle patch:把本插件依赖的竖屏布局组件连同插件一起插入。
2
2
  # 状态保存在当前 profile 目录下 fengyifan-dsh-enhancement/(state.json 0600)。
3
3
  # 是否监听网关端口由设置页控制。
4
4
  - insert:
5
5
  - id: vertical-layout
6
6
  name: dsh-vertical-layout
7
- - id: dsh-web-mobile
8
- name: dsh-web-mobile
9
7
  - id: dsh-enhancement
10
8
  name: dsh-enhancement
package/lib/gateway.d.ts CHANGED
@@ -11,6 +11,8 @@ export declare class Gateway {
11
11
  private dshSecretTimer;
12
12
  private secure;
13
13
  private failures;
14
+ /** 本实例专属会话 cookie 名(协议 + 端口)。按当前实际监听状态推导:HTTPS 起失败回落 http 时名字也须是 http 变体。 */
15
+ private get sessionName();
14
16
  constructor(ctx: HostContextLike, store: StateStore);
15
17
  get running(): boolean;
16
18
  private log;
package/lib/gateway.js CHANGED
@@ -13,7 +13,7 @@ import { createServer as createHttpsServer } from 'node:https';
13
13
  import { connect as netConnect } from 'node:net';
14
14
  import { URL } from 'node:url';
15
15
  import { dirname } from 'node:path';
16
- import { SESSION_COOKIE, clearSessionCookie, formatHost, mintDshCookie, readDshSecret, requestAuthority, sessionCookie, signSession, verifySession, } from './state.js';
16
+ import { SESSION_COOKIE_ALT, SESSION_COOKIE_LEGACY, clearSessionCookie, formatHost, mintDshCookie, readDshSecret, requestAuthority, sessionCookie, sessionCookieName, signSession, verifySession, } from './state.js';
17
17
  import { caCertificatePem, prepareTls, tlsStatus } from './tls.js';
18
18
  const LOGIN_WINDOW_MS = 10 * 60 * 1000;
19
19
  const LOGIN_LOCK_MS = 60 * 1000;
@@ -131,6 +131,11 @@ export class Gateway {
131
131
  dshSecretTimer = null;
132
132
  secure = false;
133
133
  failures = new Map();
134
+ /** 本实例专属会话 cookie 名(协议 + 端口)。按当前实际监听状态推导:HTTPS 起失败回落 http 时名字也须是 http 变体。 */
135
+ get sessionName() {
136
+ const protocol = this.secure ? 'https:' : 'http:';
137
+ return sessionCookieName(protocol, this.store.config.port);
138
+ }
134
139
  constructor(ctx, store) {
135
140
  this.ctx = ctx;
136
141
  this.store = store;
@@ -362,8 +367,9 @@ export class Gateway {
362
367
  isAuthenticated(req) {
363
368
  if (!this.store.passwordSet)
364
369
  return false;
370
+ const accepted = new Set([this.sessionName, SESSION_COOKIE_LEGACY, SESSION_COOKIE_ALT]);
365
371
  for (const [name, value] of parseCookies(req.headers.cookie)) {
366
- if (name !== SESSION_COOKIE)
372
+ if (!accepted.has(name))
367
373
  continue;
368
374
  if (verifySession(this.store.gateSecret, value) !== null)
369
375
  return true;
@@ -483,7 +489,7 @@ export class Gateway {
483
489
  if (path === '/__enhancement/logout') {
484
490
  res.writeHead(303, {
485
491
  location: '/__enhancement/login',
486
- 'set-cookie': clearSessionCookie(this.secure),
492
+ 'set-cookie': clearSessionCookie(this.sessionName, this.secure),
487
493
  'cache-control': 'no-store',
488
494
  });
489
495
  res.end();
@@ -585,7 +591,10 @@ export class Gateway {
585
591
  const token = signSession(this.store.gateSecret, this.store.config.sessionDays);
586
592
  res.writeHead(303, {
587
593
  location: next,
588
- 'set-cookie': sessionCookie(token, this.store.config.sessionDays, this.secure),
594
+ 'set-cookie': [
595
+ ...clearSessionCookie(this.sessionName, this.secure),
596
+ ...sessionCookie(this.sessionName, token, this.store.config.sessionDays, this.secure),
597
+ ],
589
598
  'cache-control': 'no-store',
590
599
  });
591
600
  res.end();
package/lib/state.d.ts CHANGED
@@ -71,7 +71,21 @@ export interface CredentialRecordLike {
71
71
  export interface CredentialsLike {
72
72
  readRecord(key: string): Promise<CredentialRecordLike | undefined>;
73
73
  }
74
- export declare const SESSION_COOKIE = "dsh_enhancement_session";
74
+ /**
75
+ * 会话 cookie 名的前缀。实际名字会带「协议 + 端口」后缀,见 {@link sessionCookieName}。
76
+ * 浏览器 cookie 身份只有 (name, domain, path)、不含端口;固定名会让同机不同端口
77
+ * (DSH 主服务 / 本网关 / 另一个 profile 的网关)共用一份 cookie 存储而互相覆盖。
78
+ */
79
+ export declare const SESSION_COOKIE_PREFIX = "dsh_enhancement_session";
80
+ /**
81
+ * 组装实例专属会话 cookie 名:https→s、http→i,后缀接端口;只用 RFC6265 token 允许的
82
+ * [A-Za-z0-9_-]。非法端口退回 'd'。例:dsh_enhancement_session_s3088。
83
+ */
84
+ export declare function sessionCookieName(protocol: 'http:' | 'https:', port: number): string;
85
+ /** 升级前的固定名:仅用于「读取旧会话」和「清除残值」,新会话一律写实例专属名。 */
86
+ export declare const SESSION_COOKIE_LEGACY = "dsh_enhancement_session";
87
+ /** 历史调试期用过的备用固定名,一并接受与清理。 */
88
+ export declare const SESSION_COOKIE_ALT = "dsh_enhancement_sid";
75
89
  export declare const DSH_HOME: string;
76
90
  export declare const PROFILE_NAME: string;
77
91
  export declare const PROFILE_DIR: string;
@@ -128,7 +142,7 @@ export interface SessionPayload {
128
142
  }
129
143
  export declare function signSession(secret: string, days: number): string;
130
144
  export declare function verifySession(secret: string, value: string | undefined): SessionPayload | null;
131
- export declare function sessionCookie(value: string, days: number, secure: boolean): string;
145
+ export declare function sessionCookie(name: string, value: string, days: number, secure: boolean): string[];
132
146
  /**
133
147
  * 清空会话 cookie 的 Set-Cookie 头(返回**多条**)。
134
148
  *
@@ -137,7 +151,7 @@ export declare function sessionCookie(value: string, days: number, secure: boole
137
151
  * `/__enhancement` 等其它 path 的残值,只清 `Path=/` 清不掉它们;这些验签失败的
138
152
  * 死值会让请求携带多份同名 cookie(见 Gateway#isAuthenticated 的注释)。
139
153
  */
140
- export declare function clearSessionCookie(secure: boolean): string[];
154
+ export declare function clearSessionCookie(currentName: string, secure: boolean): string[];
141
155
  export declare function requestAuthority(host: string, protocol: 'http:' | 'https:'): string;
142
156
  export declare function mintDshCookie(secret: Buffer, authority: string, ttlMs: number): string;
143
157
  export declare function readDshSecret(ctx: {
package/lib/state.js CHANGED
@@ -16,7 +16,25 @@ const SCRYPT_R = 8;
16
16
  const SCRYPT_P = 1;
17
17
  const KEY_BYTES = 64;
18
18
  const SECRET_BYTES = 32;
19
- export const SESSION_COOKIE = 'dsh_enhancement_session';
19
+ /**
20
+ * 会话 cookie 名的前缀。实际名字会带「协议 + 端口」后缀,见 {@link sessionCookieName}。
21
+ * 浏览器 cookie 身份只有 (name, domain, path)、不含端口;固定名会让同机不同端口
22
+ * (DSH 主服务 / 本网关 / 另一个 profile 的网关)共用一份 cookie 存储而互相覆盖。
23
+ */
24
+ export const SESSION_COOKIE_PREFIX = 'dsh_enhancement_session';
25
+ /**
26
+ * 组装实例专属会话 cookie 名:https→s、http→i,后缀接端口;只用 RFC6265 token 允许的
27
+ * [A-Za-z0-9_-]。非法端口退回 'd'。例:dsh_enhancement_session_s3088。
28
+ */
29
+ export function sessionCookieName(protocol, port) {
30
+ const scheme = protocol === 'https:' ? 's' : 'i';
31
+ const safePort = Number.isInteger(port) && port > 0 && port <= 65535 ? port : 'd';
32
+ return `${SESSION_COOKIE_PREFIX}_${scheme}${safePort}`;
33
+ }
34
+ /** 升级前的固定名:仅用于「读取旧会话」和「清除残值」,新会话一律写实例专属名。 */
35
+ export const SESSION_COOKIE_LEGACY = SESSION_COOKIE_PREFIX;
36
+ /** 历史调试期用过的备用固定名,一并接受与清理。 */
37
+ export const SESSION_COOKIE_ALT = 'dsh_enhancement_sid';
20
38
  export const DSH_HOME = process.env.DSH_HOME || join(homedir(), '.dsh');
21
39
  /** 从 CLI 参数解析当前 profile;systemd 默认 web。 */
22
40
  function activeProfileName() {
@@ -320,17 +338,10 @@ export function verifySession(secret, value) {
320
338
  return null;
321
339
  }
322
340
  }
323
- export function sessionCookie(value, days, secure) {
324
- const attrs = [
325
- `${SESSION_COOKIE}=${value}`,
326
- `Max-Age=${Math.max(1, Math.floor(days * 86400))}`,
327
- 'Path=/',
328
- 'HttpOnly',
329
- 'SameSite=Strict',
330
- ];
331
- if (secure)
332
- attrs.push('Secure');
333
- return attrs.join('; ');
341
+ export function sessionCookie(name, value, days, secure) {
342
+ const maxAge = Math.max(1, Math.floor(days * 86400));
343
+ const tail = `Max-Age=${maxAge}; Path=/; HttpOnly; SameSite=Strict${secure ? '; Secure' : ''}`;
344
+ return [`${name}=${value}; ${tail}`];
334
345
  }
335
346
  /**
336
347
  * 清空会话 cookie 的 Set-Cookie 头(返回**多条**)。
@@ -340,9 +351,16 @@ export function sessionCookie(value, days, secure) {
340
351
  * `/__enhancement` 等其它 path 的残值,只清 `Path=/` 清不掉它们;这些验签失败的
341
352
  * 死值会让请求携带多份同名 cookie(见 Gateway#isAuthenticated 的注释)。
342
353
  */
343
- export function clearSessionCookie(secure) {
354
+ export function clearSessionCookie(currentName, secure) {
344
355
  const tail = `Max-Age=0; HttpOnly; SameSite=Strict${secure ? '; Secure' : ''}`;
345
- return ['/', '/__enhancement', '/__enhancement/login'].map((path) => `${SESSION_COOKIE}=; Path=${path}; ${tail}`);
356
+ const names = [...new Set([currentName, SESSION_COOKIE_LEGACY, SESSION_COOKIE_ALT])];
357
+ const out = [];
358
+ for (const name of names) {
359
+ for (const path of ['/', '/__enhancement', '/__enhancement/login']) {
360
+ out.push(`${name}=; Path=${path}; ${tail}`);
361
+ }
362
+ }
363
+ return out;
346
364
  }
347
365
  // ---------------------------------------------------------------------------
348
366
  // DSH 会话 cookie 签发(与 @deepseek-ai/dsh-client-connection 的 v1 格式一致)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-enhancement",
3
- "version": "1.1.2",
3
+ "version": "1.2.0",
4
4
  "description": "DSH 增强套件:远程安全访问网关(密码/HTTPS)、PWA 安装与图标、桌面通知与 Web Push、会话请求头、设置页。",
5
5
  "type": "module",
6
6
  "main": "./lib/index.js",
@@ -75,7 +75,6 @@
75
75
  ],
76
76
  "dependencies": {
77
77
  "dsh-vertical-layout": "^1.1.0",
78
- "dsh-web-mobile": "^3.1.0",
79
78
  "node-forge": "^1.4.0",
80
79
  "node-notifier": "^11.0.0",
81
80
  "web-push": "^3.6.7"
package/src/gateway.ts CHANGED
@@ -14,7 +14,8 @@ import { connect as netConnect, type Socket } from 'node:net'
14
14
  import { URL } from 'node:url'
15
15
  import { dirname } from 'node:path'
16
16
  import {
17
- SESSION_COOKIE,
17
+ SESSION_COOKIE_ALT,
18
+ SESSION_COOKIE_LEGACY,
18
19
  StateStore,
19
20
  clearSessionCookie,
20
21
  formatHost,
@@ -22,6 +23,7 @@ import {
22
23
  readDshSecret,
23
24
  requestAuthority,
24
25
  sessionCookie,
26
+ sessionCookieName,
25
27
  signSession,
26
28
  verifySession,
27
29
  } from './state.js'
@@ -156,6 +158,12 @@ export class Gateway {
156
158
  private secure = false
157
159
  private failures = new Map<string, LoginFailure>()
158
160
 
161
+ /** 本实例专属会话 cookie 名(协议 + 端口)。按当前实际监听状态推导:HTTPS 起失败回落 http 时名字也须是 http 变体。 */
162
+ private get sessionName(): string {
163
+ const protocol = this.secure ? 'https:' : 'http:'
164
+ return sessionCookieName(protocol, this.store.config.port)
165
+ }
166
+
159
167
  constructor(
160
168
  private readonly ctx: HostContextLike,
161
169
  private readonly store: StateStore,
@@ -385,8 +393,9 @@ export class Gateway {
385
393
  */
386
394
  private isAuthenticated(req: IncomingMessage): boolean {
387
395
  if (!this.store.passwordSet) return false
396
+ const accepted = new Set([this.sessionName, SESSION_COOKIE_LEGACY, SESSION_COOKIE_ALT])
388
397
  for (const [name, value] of parseCookies(req.headers.cookie)) {
389
- if (name !== SESSION_COOKIE) continue
398
+ if (!accepted.has(name)) continue
390
399
  if (verifySession(this.store.gateSecret, value) !== null) return true
391
400
  }
392
401
  return false
@@ -498,7 +507,7 @@ export class Gateway {
498
507
  if (path === '/__enhancement/logout') {
499
508
  res.writeHead(303, {
500
509
  location: '/__enhancement/login',
501
- 'set-cookie': clearSessionCookie(this.secure),
510
+ 'set-cookie': clearSessionCookie(this.sessionName, this.secure),
502
511
  'cache-control': 'no-store',
503
512
  })
504
513
  res.end()
@@ -606,7 +615,10 @@ export class Gateway {
606
615
  const token = signSession(this.store.gateSecret, this.store.config.sessionDays)
607
616
  res.writeHead(303, {
608
617
  location: next,
609
- 'set-cookie': sessionCookie(token, this.store.config.sessionDays, this.secure),
618
+ 'set-cookie': [
619
+ ...clearSessionCookie(this.sessionName, this.secure),
620
+ ...sessionCookie(this.sessionName, token, this.store.config.sessionDays, this.secure),
621
+ ],
610
622
  'cache-control': 'no-store',
611
623
  })
612
624
  res.end()
package/src/state.ts CHANGED
@@ -94,7 +94,28 @@ const SCRYPT_P = 1
94
94
  const KEY_BYTES = 64
95
95
  const SECRET_BYTES = 32
96
96
 
97
- export const SESSION_COOKIE = 'dsh_enhancement_session'
97
+ /**
98
+ * 会话 cookie 名的前缀。实际名字会带「协议 + 端口」后缀,见 {@link sessionCookieName}。
99
+ * 浏览器 cookie 身份只有 (name, domain, path)、不含端口;固定名会让同机不同端口
100
+ * (DSH 主服务 / 本网关 / 另一个 profile 的网关)共用一份 cookie 存储而互相覆盖。
101
+ */
102
+ export const SESSION_COOKIE_PREFIX = 'dsh_enhancement_session'
103
+
104
+ /**
105
+ * 组装实例专属会话 cookie 名:https→s、http→i,后缀接端口;只用 RFC6265 token 允许的
106
+ * [A-Za-z0-9_-]。非法端口退回 'd'。例:dsh_enhancement_session_s3088。
107
+ */
108
+ export function sessionCookieName(protocol: 'http:' | 'https:', port: number): string {
109
+ const scheme = protocol === 'https:' ? 's' : 'i'
110
+ const safePort = Number.isInteger(port) && port > 0 && port <= 65535 ? port : 'd'
111
+ return `${SESSION_COOKIE_PREFIX}_${scheme}${safePort}`
112
+ }
113
+
114
+ /** 升级前的固定名:仅用于「读取旧会话」和「清除残值」,新会话一律写实例专属名。 */
115
+ export const SESSION_COOKIE_LEGACY = SESSION_COOKIE_PREFIX
116
+
117
+ /** 历史调试期用过的备用固定名,一并接受与清理。 */
118
+ export const SESSION_COOKIE_ALT = 'dsh_enhancement_sid'
98
119
  export const DSH_HOME = process.env.DSH_HOME || join(homedir(), '.dsh')
99
120
 
100
121
  /** 从 CLI 参数解析当前 profile;systemd 默认 web。 */
@@ -420,16 +441,10 @@ export function verifySession(secret: string, value: string | undefined): Sessio
420
441
  }
421
442
  }
422
443
 
423
- export function sessionCookie(value: string, days: number, secure: boolean): string {
424
- const attrs = [
425
- `${SESSION_COOKIE}=${value}`,
426
- `Max-Age=${Math.max(1, Math.floor(days * 86400))}`,
427
- 'Path=/',
428
- 'HttpOnly',
429
- 'SameSite=Strict',
430
- ]
431
- if (secure) attrs.push('Secure')
432
- return attrs.join('; ')
444
+ export function sessionCookie(name: string, value: string, days: number, secure: boolean): string[] {
445
+ const maxAge = Math.max(1, Math.floor(days * 86400))
446
+ const tail = `Max-Age=${maxAge}; Path=/; HttpOnly; SameSite=Strict${secure ? '; Secure' : ''}`
447
+ return [`${name}=${value}; ${tail}`]
433
448
  }
434
449
 
435
450
  /**
@@ -440,11 +455,16 @@ export function sessionCookie(value: string, days: number, secure: boolean): str
440
455
  * `/__enhancement` 等其它 path 的残值,只清 `Path=/` 清不掉它们;这些验签失败的
441
456
  * 死值会让请求携带多份同名 cookie(见 Gateway#isAuthenticated 的注释)。
442
457
  */
443
- export function clearSessionCookie(secure: boolean): string[] {
458
+ export function clearSessionCookie(currentName: string, secure: boolean): string[] {
444
459
  const tail = `Max-Age=0; HttpOnly; SameSite=Strict${secure ? '; Secure' : ''}`
445
- return ['/', '/__enhancement', '/__enhancement/login'].map(
446
- (path) => `${SESSION_COOKIE}=; Path=${path}; ${tail}`,
447
- )
460
+ const names = [...new Set([currentName, SESSION_COOKIE_LEGACY, SESSION_COOKIE_ALT])]
461
+ const out: string[] = []
462
+ for (const name of names) {
463
+ for (const path of ['/', '/__enhancement', '/__enhancement/login']) {
464
+ out.push(`${name}=; Path=${path}; ${tail}`)
465
+ }
466
+ }
467
+ return out
448
468
  }
449
469
 
450
470
  // ---------------------------------------------------------------------------
@@ -1,14 +1,16 @@
1
- // 网关会话认证的回归测试。
1
+ // 网关会话 cookie 的回归测试。
2
2
  //
3
- // 覆盖两个真实缺陷(与任何 PWA / 样式改动无关,属独立 bug 修复):
3
+ // 覆盖 2026-10-24 真机报障:「密码正确、点进入 DSH 后停在登录页,且不提示密码错误」。
4
4
  //
5
- // 1. **同名 cookie 只读第一个**:浏览器按 (name, domain, path) 存 cookie,重复项
6
- // 顺序由浏览器决定。旧实现用 `find()` 取第一个,一旦过期的旧值排在前面就永久
7
- // 认证失败 —— 表现是「密码正确、刷新又回登录页,且不报密码错误」,用户无法
8
- // 自愈(除非手动清 cookie)。
9
- //
10
- // 2. **清除 cookie 只清 Path=/**:`/__enhancement` 等其它 path 上的残值清不掉,
11
- // 它们会一直跟着每个请求,让上面的问题复现。
5
+ // 两代根因,都在这里钉住:
6
+ // 1. 认证只看**第一个**同名 cookie。浏览器按 (name, domain, path) 存 cookie,重复项
7
+ // 顺序由浏览器决定,坏值排前面就永久认证失败。
8
+ // 2. 会话 cookie 名是**固定**的。HTTP cookie 身份不含端口,于是同一 IP 上的不同
9
+ // 端口(DSH 主服务 / 本网关 / 另一个 profile 的网关)共用一份存储:
10
+ // - 换端口后旧会话仍被带上(本该重新登录却没有);
11
+ // - 混用协议时更糟:https 端口留下的 Secure cookie 在 http 端口既不发送、也无法
12
+ // 被非 Secure 的 Set-Cookie 覆盖 → 新会话永远存不住 → 反复退回登录页。
13
+ // 现在名字带「协议 + 端口」后缀,每实例独立槽位。
12
14
  //
13
15
  // 运行:node --test test/gateway-auth.test.mjs
14
16
  import { test } from 'node:test'
@@ -16,9 +18,12 @@ import assert from 'node:assert/strict'
16
18
  import {
17
19
  clearSessionCookie,
18
20
  sessionCookie,
21
+ sessionCookieName,
19
22
  signSession,
20
23
  verifySession,
21
- SESSION_COOKIE,
24
+ SESSION_COOKIE_ALT,
25
+ SESSION_COOKIE_LEGACY,
26
+ SESSION_COOKIE_PREFIX,
22
27
  } from '../lib/state.js'
23
28
 
24
29
  const SECRET = Buffer.from('test-gate-secret-0123456789abcdef').toString('base64url')
@@ -37,113 +42,179 @@ function parseCookies(header) {
37
42
  return out
38
43
  }
39
44
 
40
- /** 修复后的判定(与 Gateway#isAuthenticated 一致):任一同名项验签通过即认证。 */
41
- function isAuthenticated(header) {
45
+ /** 复刻修复后的判定:接受实例名与两个历史名,任一同名项验签通过即认证。 */
46
+ function isAuthenticated(header, cookieName) {
47
+ const accepted = new Set([cookieName, SESSION_COOKIE_LEGACY, SESSION_COOKIE_ALT])
42
48
  for (const [name, value] of parseCookies(header)) {
43
- if (name !== SESSION_COOKIE) continue
49
+ if (!accepted.has(name)) continue
44
50
  if (verifySession(SECRET, value) !== null) return true
45
51
  }
46
52
  return false
47
53
  }
48
54
 
49
- /** 修复前的行为:只看第一个同名项。仅用于证明用例抓得住回归。 */
55
+ /** 修复前:固定名 + 只看第一个。仅用于证明用例抓得住回归。 */
50
56
  function isAuthenticatedLegacy(header) {
51
- const raw = parseCookies(header).find(([name]) => name === SESSION_COOKIE)?.[1]
57
+ const raw = parseCookies(header).find(([name]) => name === SESSION_COOKIE_PREFIX)?.[1]
52
58
  return verifySession(SECRET, raw) !== null
53
59
  }
54
60
 
61
+ const NAME = sessionCookieName('http:', 3089)
62
+
55
63
  // ---------------------------------------------------------------------------
56
- // 1. 核心回归:同名 cookie 的顺序不能决定认证结果
64
+ // 1. 实例专属名:协议 + 端口进入名字
65
+ // ---------------------------------------------------------------------------
66
+
67
+ test('cookie 名带协议与端口后缀,不同实例互不冲突', () => {
68
+ const a = sessionCookieName('http:', 3089)
69
+ const b = sessionCookieName('http:', 3090)
70
+ const c = sessionCookieName('https:', 3089)
71
+
72
+ assert.notEqual(a, b, '不同端口必须是不同 cookie 名')
73
+ assert.notEqual(a, c, '不同协议必须是不同 cookie 名')
74
+ assert.ok(a.startsWith(SESSION_COOKIE_PREFIX), '必须保留可识别的前缀')
75
+ assert.ok(a.includes('3089'), '名字里要能看出端口')
76
+ })
77
+
78
+ test('cookie 名只用 RFC 6265 token 允许的字符(Safari 比 Chromium 严格)', () => {
79
+ for (const [proto, port] of [
80
+ ['http:', 80], ['http:', 3089], ['https:', 443], ['https:', 3088],
81
+ ]) {
82
+ const name = sessionCookieName(proto, port)
83
+ // token = 1*<any CHAR except CTLs or separators>;这里收紧到最常见的安全子集。
84
+ assert.match(name, /^[A-Za-z0-9_-]+$/, `${name} 含 RFC 6265 separator 字符`)
85
+ }
86
+ })
87
+
88
+ test('非法端口退回占位值而不是生成坏名字', () => {
89
+ for (const port of [0, -1, 70000, Number.NaN, 3.14]) {
90
+ const name = sessionCookieName('http:', port)
91
+ assert.match(name, /^[A-Za-z0-9_-]+$/, `${port} 生成了非法名 ${name}`)
92
+ }
93
+ })
94
+
95
+ // ---------------------------------------------------------------------------
96
+ // 2. 端口隔离语义:换端口即换槽位(登录态自然失效)
97
+ // ---------------------------------------------------------------------------
98
+
99
+ test('在 3089 登录后,3089 的请求带着它,3090 不认', () => {
100
+ const token = signSession(SECRET, 30)
101
+ const name3089 = sessionCookieName('http:', 3089)
102
+ const cookieHeader = `${name3089}=${token}`
103
+
104
+ assert.equal(isAuthenticated(cookieHeader, name3089), true, '3089 自己的会话应通过')
105
+ assert.equal(
106
+ isAuthenticated(cookieHeader, sessionCookieName('http:', 3090)),
107
+ false,
108
+ '3090 不应接受 3089 的会话(换端口必须重新登录)',
109
+ )
110
+ })
111
+
112
+ test('同端口不同协议也不互通(http 的会话在 https 上无效)', () => {
113
+ const token = signSession(SECRET, 30)
114
+ const httpCookie = `${sessionCookieName('http:', 3089)}=${token}`
115
+ assert.equal(isAuthenticated(httpCookie, sessionCookieName('https:', 3089)), false)
116
+ })
117
+
118
+ // ---------------------------------------------------------------------------
119
+ // 3. 同名重复项的顺序不能决定认证结果(第一代根因)
57
120
  // ---------------------------------------------------------------------------
58
121
 
59
122
  test('Cookie 头里有多个同名项时,任意一个有效即认证通过', () => {
60
123
  const valid = signSession(SECRET, 30)
61
- const bogus = `${SESSION_COOKIE}=v1.bogus.sig`
62
- const good = `${SESSION_COOKIE}=${valid}`
63
-
124
+ const bogus = `${NAME}=v1.bogus.sig`
125
+ const good = `${NAME}=${valid}`
64
126
  for (const header of [`${bogus}; ${good}`, `${good}; ${bogus}`, good]) {
65
- assert.equal(
66
- isAuthenticated(header),
67
- true,
68
- `应通过:${header.replace(valid, '<valid>')}`,
69
- )
127
+ assert.equal(isAuthenticated(header, NAME), true)
70
128
  }
71
129
  })
72
130
 
73
- test('回归护栏:旧实现(只取第一个同名 cookie)在坏值排前时失败', () => {
131
+ test('回归护栏:旧实现(固定名 + 只取第一个)在坏值排前时失败', () => {
74
132
  const valid = signSession(SECRET, 30)
75
- const staleFirst = `${SESSION_COOKIE}=v1.bogus.sig; ${SESSION_COOKIE}=${valid}`
76
-
77
- // 这条断言是「测试有效性」的证明:它红了说明回归被引入。
133
+ const staleFirst = `${SESSION_COOKIE_PREFIX}=v1.bogus.sig; ${SESSION_COOKIE_PREFIX}=${valid}`
78
134
  assert.equal(isAuthenticatedLegacy(staleFirst), false, '旧实现应失败(证明用例有效)')
79
- assert.equal(isAuthenticated(staleFirst), true, '新实现应通过')
80
135
  })
81
136
 
82
- test('全部无效时仍然拒绝(遍历不能放宽认证)', () => {
137
+ test('全部无效时仍然拒绝,且不认无关名字', () => {
83
138
  for (const header of [
84
- `${SESSION_COOKIE}=v1.bogus.sig`,
85
- `${SESSION_COOKIE}=v1.bogus.sig; ${SESSION_COOKIE}=v1.other.sig`,
86
- `${SESSION_COOKIE}=`,
139
+ `${NAME}=v1.bogus.sig`,
140
+ `${NAME}=v1.a.sig; ${NAME}=v1.b.sig`,
141
+ `${NAME}=`,
87
142
  '',
88
143
  'other=1',
144
+ `not_ours_3089=${signSession(SECRET, 30)}`,
89
145
  ]) {
90
- assert.equal(isAuthenticated(header), false, `应拒绝:${JSON.stringify(header)}`)
146
+ assert.equal(isAuthenticated(header, NAME), false, `应拒绝:${JSON.stringify(header)}`)
91
147
  }
92
148
  })
93
149
 
94
150
  test('过期会话即使验签合法也拒绝', () => {
95
- assert.equal(isAuthenticated(`${SESSION_COOKIE}=${signSession(SECRET, -1)}`), false)
151
+ assert.equal(isAuthenticated(`${NAME}=${signSession(SECRET, -1)}`, NAME), false)
96
152
  })
97
153
 
98
154
  test('换一把密钥签出的会话不再有效(改密码后旧会话失效的机制)', () => {
99
155
  const other = Buffer.from('another-gate-secret-value').toString('base64url')
100
- assert.equal(isAuthenticated(`${SESSION_COOKIE}=${signSession(other, 30)}`), false)
156
+ assert.equal(isAuthenticated(`${NAME}=${signSession(other, 30)}`, NAME), false)
101
157
  })
102
158
 
103
159
  // ---------------------------------------------------------------------------
104
- // 2. 清除 cookie 必须覆盖多个 path(否则残值永远清不掉)
160
+ // 4. 升级兼容:旧固定名写入的会话仍被接受(不让老用户被迫重登)
105
161
  // ---------------------------------------------------------------------------
106
162
 
107
- test('clearSessionCookie 覆盖多个 path,且都是立即过期的空值', () => {
108
- const headers = clearSessionCookie(false)
109
- assert.ok(Array.isArray(headers), '应返回多条 Set-Cookie')
110
- assert.ok(headers.length >= 2, `应覆盖至少 2 个 path,实际 ${headers.length}`)
111
-
112
- const paths = headers.map((h) => (/Path=([^;]+)/.exec(h) ?? [])[1])
113
- assert.ok(paths.includes('/'), '必须包含 Path=/')
114
- assert.ok(paths.includes('/__enhancement'), '必须包含 /__enhancement(登录页残值)')
115
-
116
- for (const header of headers) {
117
- assert.match(header, new RegExp(`^${SESSION_COOKIE}=;`), '清除头必须是空值')
118
- assert.match(header, /Max-Age=0/, '清除头必须立即过期')
119
- assert.match(header, /HttpOnly/, '清除头必须保持 HttpOnly')
120
- }
121
- })
122
-
123
- test('HTTPS 下的清除头必须带 Secure,否则浏览器拒绝覆盖', () => {
124
- for (const header of clearSessionCookie(true)) assert.match(header, /; Secure/)
125
- for (const header of clearSessionCookie(false)) assert.doesNotMatch(header, /; Secure/)
163
+ test('升级前用旧固定名写入的会话仍然有效', () => {
164
+ const token = signSession(SECRET, 30)
165
+ assert.equal(isAuthenticated(`${SESSION_COOKIE_LEGACY}=${token}`, NAME), true)
166
+ assert.equal(isAuthenticated(`${SESSION_COOKIE_ALT}=${token}`, NAME), true)
126
167
  })
127
168
 
128
169
  // ---------------------------------------------------------------------------
129
- // 3. 下发的新会话 cookie 属性
170
+ // 5. Set-Cookie 下发与清除
130
171
  // ---------------------------------------------------------------------------
131
172
 
132
- test('登录成功后下发的会话 cookie 属性正确', () => {
173
+ test('登录成功时只写实例专属名(不再写固定名)', () => {
133
174
  const token = signSession(SECRET, 30)
134
- const header = sessionCookie(token, 30, false)
135
- assert.match(header, new RegExp(`^${SESSION_COOKIE}=`))
136
- assert.match(header, /Max-Age=2592000/, '30 天对应 2592000 秒')
175
+ const headers = sessionCookie(NAME, token, 30, false)
176
+
177
+ assert.equal(headers.length, 1, '只下发一条,避免在别的端口留下可读槽位')
178
+ const header = headers[0]
179
+ assert.ok(header.startsWith(`${NAME}=`), `应写实例名,实际:${header}`)
180
+ assert.ok(header.includes(token))
181
+ assert.match(header, /Max-Age=2592000/)
137
182
  assert.match(header, /Path=\//)
138
183
  assert.match(header, /HttpOnly/)
139
184
  assert.match(header, /SameSite=Strict/)
140
185
  assert.doesNotMatch(header, /; Secure/)
141
- assert.match(sessionCookie(token, 30, true), /; Secure/)
186
+ assert.match(sessionCookie(NAME, token, 30, true)[0], /; Secure/)
187
+ })
188
+
189
+ test('清除时覆盖实例名 + 两个历史名 × 多个 path', () => {
190
+ const headers = clearSessionCookie(NAME, false)
191
+ assert.ok(Array.isArray(headers))
192
+
193
+ const combos = headers.map((h) => ({
194
+ name: h.slice(0, h.indexOf('=')),
195
+ path: (/Path=([^;]+)/.exec(h) ?? [])[1],
196
+ }))
197
+
198
+ for (const name of [NAME, SESSION_COOKIE_LEGACY, SESSION_COOKIE_ALT]) {
199
+ const paths = combos.filter((c) => c.name === name).map((c) => c.path)
200
+ assert.ok(paths.includes('/'), `${name} 必须包含 Path=/`)
201
+ assert.ok(paths.includes('/__enhancement'), `${name} 必须包含 /__enhancement(登录页残值)`)
202
+ }
203
+ for (const header of headers) {
204
+ assert.match(header, /=\s*;/, '清除头必须是空值')
205
+ assert.match(header, /Max-Age=0/)
206
+ assert.match(header, /HttpOnly/)
207
+ }
208
+ })
209
+
210
+ test('HTTPS 下的清除头必须带 Secure,否则浏览器拒绝覆盖', () => {
211
+ for (const header of clearSessionCookie(NAME, true)) assert.match(header, /; Secure/)
212
+ for (const header of clearSessionCookie(NAME, false)) assert.doesNotMatch(header, /; Secure/)
142
213
  })
143
214
 
144
- test('sessionDays 非正数时 Max-Age 仍为正(不退化成会话级 cookie)', () => {
215
+ test('sessionDays 非正数时 Max-Age 仍为正', () => {
145
216
  const token = signSession(SECRET, 1)
146
217
  for (const days of [0, -5]) {
147
- assert.match(sessionCookie(token, days, false), /Max-Age=1(;|$)/)
218
+ assert.match(sessionCookie(NAME, token, days, false)[0], /Max-Age=1(;|$)/)
148
219
  }
149
220
  })