dsh-enhancement 1.1.1 → 1.1.3
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 +2 -0
- package/README.md +2 -0
- package/lib/client.js +25 -11
- package/lib/gateway.d.ts +2 -0
- package/lib/gateway.js +23 -8
- package/lib/state.d.ts +17 -3
- package/lib/state.js +43 -16
- package/package.json +3 -3
- package/src/client/index.tsx +23 -11
- package/src/gateway.ts +25 -8
- package/src/state.ts +45 -17
- package/test/gateway-auth.test.mjs +135 -64
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
|
|
|
@@ -94,6 +95,7 @@ Clicking a desktop notification opens the matching session. On Linux and Windows
|
|
|
94
95
|
## Mobile push
|
|
95
96
|
|
|
96
97
|
- Standard Web Push with one subscription per device.
|
|
98
|
+
- **PWA must be enabled first**: the Service Worker (`/sw.js`) is served by the PWA feature, so push cannot be subscribed while PWA is off.
|
|
97
99
|
- Click “Subscribe this device” in settings; the browser asks for notification permission, which must be triggered by your click.
|
|
98
100
|
- iOS / iPadOS 16.4+ is required, and DSH must be added to the Home Screen; regular Safari tabs cannot receive push.
|
|
99
101
|
- Unsubscribe at any time, or send a test push to verify the setup.
|
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
|
|
|
@@ -95,6 +96,7 @@ DSH 只在直连官方 DeepSeek API 时发送 `x-deepseek-harness-session-id`;
|
|
|
95
96
|
## 手机推送
|
|
96
97
|
|
|
97
98
|
- 使用标准 Web Push,每台设备单独订阅。
|
|
99
|
+
- **需要先启用 PWA**:Service Worker(`/sw.js`)由 PWA 提供,PWA 关闭时无法订阅。
|
|
98
100
|
- 在设置里点“订阅本设备”,浏览器会请求通知权限;权限必须由你主动点击触发。
|
|
99
101
|
- iOS / iPadOS 需要 16.4 或更高版本,并且必须先把 DSH 添加到主屏幕;Safari 普通标签页收不到推送。
|
|
100
102
|
- 可以随时取消订阅,或发送一条测试推送确认链路正常。
|
package/lib/client.js
CHANGED
|
@@ -100,7 +100,7 @@ window.__ModuleLoader__.load({
|
|
|
100
100
|
muted: { color: 'var(--dsw-alias-label-secondary, #667085)', fontSize: 12, lineHeight: 1.6 },
|
|
101
101
|
card: { borderTop: '1px solid var(--dsw-alias-border-l4, rgba(0,0,0,.08))', padding: '16px 0 8px', display: 'flex', flexDirection: 'column', gap: 6, background: 'transparent' },
|
|
102
102
|
blockHead: { display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: 12 },
|
|
103
|
-
dirty: { flex: 'none', fontSize: 11, lineHeight: '18px', padding: '1px 8px', borderRadius: 999, color: 'var(--dsw-alias-state-
|
|
103
|
+
dirty: { flex: 'none', fontSize: 11, lineHeight: '18px', padding: '1px 8px', borderRadius: 999, color: 'var(--dsw-alias-state-warn-primary, #b54708)', background: 'color-mix(in srgb, var(--dsw-alias-state-warn-primary, #b54708) 12%, transparent)', border: '.5px solid color-mix(in srgb, var(--dsw-alias-state-warn-primary, #b54708) 30%, transparent)' },
|
|
104
104
|
subhead: { margin: '14px 0 2px', fontSize: 13, fontWeight: 600, color: 'var(--dsw-alias-label-primary, inherit)' },
|
|
105
105
|
row: { display: 'flex', flexDirection: 'column', gap: 6, padding: '10px 0' },
|
|
106
106
|
label: { color: 'var(--dsw-alias-label-primary, inherit)', fontSize: 13, fontWeight: 500 },
|
|
@@ -111,12 +111,12 @@ window.__ModuleLoader__.load({
|
|
|
111
111
|
actions: { display: 'flex', flexWrap: 'wrap', alignItems: 'center', gap: 8, paddingTop: 4 },
|
|
112
112
|
button: { height: 32, padding: '0 14px', borderRadius: 'var(--dsw-radius-md, 12px)', border: '.5px solid var(--dsw-alias-border-l4, rgba(0,0,0,.16))', background: 'transparent', color: 'var(--dsw-alias-label-primary, inherit)', cursor: 'pointer', fontSize: 13 },
|
|
113
113
|
primary: { background: 'var(--dsw-alias-state-business-primary, #4d8dff)', borderColor: 'transparent', color: '#fff', fontWeight: 600 },
|
|
114
|
-
danger: { color: 'var(--dsw-alias-state-
|
|
114
|
+
danger: { color: 'var(--dsw-alias-state-error-primary, #d92d20)' },
|
|
115
115
|
pill: { display: 'inline-flex', alignItems: 'center', gap: 6, padding: '3px 9px', borderRadius: 999, border: '.5px solid var(--dsw-alias-border-l4, rgba(0,0,0,.16))', fontSize: 11, color: 'var(--dsw-alias-label-secondary, #667085)' },
|
|
116
116
|
dot: { width: 7, height: 7, borderRadius: 999, background: '#54d18a', display: 'inline-block' },
|
|
117
117
|
dotOff: { width: 7, height: 7, borderRadius: 999, background: '#98a2b3', display: 'inline-block' },
|
|
118
118
|
code: { fontFamily: 'ui-monospace, SFMono-Regular, Menlo, monospace', fontSize: 11, wordBreak: 'break-all', background: 'var(--dsw-alias-bg-layer-3, rgba(0,0,0,.04))', color: 'var(--dsw-alias-label-secondary, #475467)', padding: '6px 8px', borderRadius: 8 },
|
|
119
|
-
error: { color: 'var(--dsw-alias-state-
|
|
119
|
+
error: { color: 'var(--dsw-alias-state-error-primary, #d92d20)', fontSize: 12, lineHeight: 1.5 },
|
|
120
120
|
success: { color: 'var(--dsw-alias-state-success-primary, #079455)', fontSize: 12, lineHeight: 1.5 },
|
|
121
121
|
divider: { height: 1, background: 'var(--dsw-alias-border-l4, rgba(0,0,0,.08))', margin: '6px 0' },
|
|
122
122
|
};
|
|
@@ -125,7 +125,7 @@ window.__ModuleLoader__.load({
|
|
|
125
125
|
}
|
|
126
126
|
const CARD_CSS = `
|
|
127
127
|
.enh-root input:not([type="checkbox"]):not([type="file"]), .enh-root select { transition:border-color .15s, box-shadow .15s; }
|
|
128
|
-
.enh-root input:not([type="checkbox"]):not([type="file"]):focus, .enh-root input:not([type="checkbox"]):not([type="file"]):focus-visible, .enh-root select:focus, .enh-root select:focus-visible { outline:none !important; border-color:var(--dsw-alias-state-business-primary, #4d8dff) !important; box-shadow:0 0 0 3px var(--dsw-alias-state-business-primary
|
|
128
|
+
.enh-root input:not([type="checkbox"]):not([type="file"]):focus, .enh-root input:not([type="checkbox"]):not([type="file"]):focus-visible, .enh-root select:focus, .enh-root select:focus-visible { outline:none !important; border-color:var(--dsw-alias-state-business-primary, #4d8dff) !important; box-shadow:0 0 0 3px color-mix(in srgb, var(--dsw-alias-state-business-primary, #4d8dff) 18%, transparent) !important; }
|
|
129
129
|
.enh-root select { appearance:none; background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12'%3E%3Cpath d='M3 4.5 6 7.5 9 4.5' fill='none' stroke='%23667085' stroke-width='1.4' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E"); background-repeat:no-repeat; background-position:right 12px center; padding-right:32px !important; }
|
|
130
130
|
.enh-root input[type="file"] { font-size:12px; color:var(--dsw-alias-label-secondary, #475467); max-width:100%; }
|
|
131
131
|
.enh-root input[type="file"]::file-selector-button { appearance:none; height:32px; margin-right:10px; padding:0 14px; border-radius:var(--dsw-radius-md, 12px); border:.5px solid var(--dsw-alias-border-l4, rgba(0,0,0,.16)); background:var(--dsw-alias-bg-layer-3, #fff); color:var(--dsw-alias-label-primary, inherit); font:inherit; font-size:13px; cursor:pointer; }
|
|
@@ -139,7 +139,7 @@ window.__ModuleLoader__.load({
|
|
|
139
139
|
.enh-root input[type="checkbox"]:focus-visible { outline:2px solid var(--dsw-alias-state-business-primary, #4d8dff); outline-offset:2px; }
|
|
140
140
|
|
|
141
141
|
.enh-root .enh-block-head { display:flex; align-items:flex-start; justify-content:space-between; gap:12px; }
|
|
142
|
-
.enh-root .enh-dirty { flex:none; font-size:11px; line-height:18px; padding:1px 8px; border-radius:999px; color:var(--dsw-alias-state-
|
|
142
|
+
.enh-root .enh-dirty { flex:none; font-size:11px; line-height:18px; padding:1px 8px; border-radius:999px; color:var(--dsw-alias-state-warn-primary, #b54708); background:color-mix(in srgb, var(--dsw-alias-state-warn-primary, #b54708) 12%, transparent); border:.5px solid color-mix(in srgb, var(--dsw-alias-state-warn-primary, #b54708) 30%, transparent); }
|
|
143
143
|
.enh-root .enh-subhead { margin:14px 0 2px; font-size:13px; font-weight:600; color:var(--dsw-alias-label-primary, inherit); }
|
|
144
144
|
.enh-root .enh-grid2 { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:10px 16px; }
|
|
145
145
|
.enh-root .enh-grid3 { display:grid; grid-template-columns:repeat(3,minmax(0,1fr)); gap:10px 16px; }
|
|
@@ -464,6 +464,10 @@ window.__ModuleLoader__.load({
|
|
|
464
464
|
throw new Error('Web Push 需要 HTTPS(或 localhost);请先启用 HTTPS');
|
|
465
465
|
if (!('serviceWorker' in navigator) || !('PushManager' in window))
|
|
466
466
|
throw new Error('当前浏览器不支持 Web Push');
|
|
467
|
+
// /sw.js 仅在启用 PWA 时注册;PWA 关闭时 Service Worker 不可用,Web Push 无从订阅。
|
|
468
|
+
const swProbe = await fetch('/sw.js', { method: 'HEAD', cache: 'no-store' }).catch(() => undefined);
|
|
469
|
+
if (!swProbe || !swProbe.ok)
|
|
470
|
+
throw new Error('Web Push 需要先启用 PWA(PWA 关闭时 Service Worker /sw.js 不可用)');
|
|
467
471
|
if (!pushPublicKey)
|
|
468
472
|
throw new Error('VAPID 公钥尚未生成,请刷新状态后重试');
|
|
469
473
|
let permission = Notification.permission;
|
|
@@ -698,21 +702,31 @@ window.__ModuleLoader__.load({
|
|
|
698
702
|
/* ignore */
|
|
699
703
|
}
|
|
700
704
|
};
|
|
705
|
+
const onMessage = (event) => {
|
|
706
|
+
const data = event.data;
|
|
707
|
+
if (data?.type === 'dsh-enhancement-open-session' && data.sessionId && SESSION_ID_PATTERN.test(data.sessionId))
|
|
708
|
+
openSession(ctx, data.sessionId);
|
|
709
|
+
};
|
|
701
710
|
read();
|
|
702
711
|
window.addEventListener('popstate', read);
|
|
703
712
|
try {
|
|
704
|
-
navigator.serviceWorker?.addEventListener('message',
|
|
705
|
-
const data = event.data;
|
|
706
|
-
if (data?.type === 'dsh-enhancement-open-session' && data.sessionId && SESSION_ID_PATTERN.test(data.sessionId))
|
|
707
|
-
openSession(ctx, data.sessionId);
|
|
708
|
-
});
|
|
713
|
+
navigator.serviceWorker?.addEventListener('message', onMessage);
|
|
709
714
|
}
|
|
710
715
|
catch {
|
|
711
716
|
/* service worker unavailable on HTTP */
|
|
712
717
|
}
|
|
718
|
+
return () => {
|
|
719
|
+
window.removeEventListener('popstate', read);
|
|
720
|
+
try {
|
|
721
|
+
navigator.serviceWorker?.removeEventListener('message', onMessage);
|
|
722
|
+
}
|
|
723
|
+
catch {
|
|
724
|
+
/* ignore */
|
|
725
|
+
}
|
|
726
|
+
};
|
|
713
727
|
}
|
|
714
728
|
function apply(ctx) {
|
|
715
|
-
openSessionFromQuery(ctx);
|
|
729
|
+
ctx.effect(() => openSessionFromQuery(ctx), 'dsh-enhancement: open session from query');
|
|
716
730
|
ctx.effect(() => connectFocusEvents(ctx), 'dsh-enhancement: focus existing page');
|
|
717
731
|
ctx.effect(() => {
|
|
718
732
|
return ctx.slots.inject('plugins.bundle.config', () => ctx.slots.register({
|
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
|
@@ -12,7 +12,8 @@ import { createServer, request as httpRequest } from 'node:http';
|
|
|
12
12
|
import { createServer as createHttpsServer } from 'node:https';
|
|
13
13
|
import { connect as netConnect } from 'node:net';
|
|
14
14
|
import { URL } from 'node:url';
|
|
15
|
-
import {
|
|
15
|
+
import { dirname } from 'node:path';
|
|
16
|
+
import { SESSION_COOKIE_ALT, SESSION_COOKIE_LEGACY, clearSessionCookie, formatHost, mintDshCookie, readDshSecret, requestAuthority, sessionCookie, sessionCookieName, signSession, verifySession, } from './state.js';
|
|
16
17
|
import { caCertificatePem, prepareTls, tlsStatus } from './tls.js';
|
|
17
18
|
const LOGIN_WINDOW_MS = 10 * 60 * 1000;
|
|
18
19
|
const LOGIN_LOCK_MS = 60 * 1000;
|
|
@@ -53,7 +54,9 @@ function parseCookies(header) {
|
|
|
53
54
|
function safeNext(raw) {
|
|
54
55
|
if (typeof raw !== 'string' || !raw.startsWith('/') || raw.startsWith('//'))
|
|
55
56
|
return '/';
|
|
56
|
-
|
|
57
|
+
// 反斜杠在 URL 解析中会被归一成斜杠(`/\evil.com` → `//evil.com`),构成开放重定向;
|
|
58
|
+
// 编码形式 %5c 同理,一并拒绝。CRLF 用于防响应拆分。
|
|
59
|
+
if (/[\r\n\\]/.test(raw) || /%5c|%2f%2f/i.test(raw))
|
|
57
60
|
return '/';
|
|
58
61
|
return raw;
|
|
59
62
|
}
|
|
@@ -128,6 +131,11 @@ export class Gateway {
|
|
|
128
131
|
dshSecretTimer = null;
|
|
129
132
|
secure = false;
|
|
130
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
|
+
}
|
|
131
139
|
constructor(ctx, store) {
|
|
132
140
|
this.ctx = ctx;
|
|
133
141
|
this.store = store;
|
|
@@ -189,8 +197,7 @@ export class Gateway {
|
|
|
189
197
|
}
|
|
190
198
|
this.dshSecret = await readDshSecret(this.ctx);
|
|
191
199
|
if (!this.dshSecret) {
|
|
192
|
-
this.log('warn', '无法读取 DSH
|
|
193
|
-
this.scheduleDshSecretRetry();
|
|
200
|
+
this.log('warn', '无法读取 DSH 浏览器会话签名密钥;启动后会在后台重试(网关自身仍可登录)');
|
|
194
201
|
}
|
|
195
202
|
const handler = (req, res) => {
|
|
196
203
|
void this.handleRequest(req, res).catch((err) => {
|
|
@@ -232,6 +239,10 @@ export class Gateway {
|
|
|
232
239
|
server.listen(config.port, config.bindHost, () => {
|
|
233
240
|
server.off('error', onError);
|
|
234
241
|
this.started = true;
|
|
242
|
+
// 监听成功(started=true)后才安排密钥重试:此前的 scheduleDshSecretRetry
|
|
243
|
+
// 会因守卫 !this.started 直接返回,导致重试从不发生。
|
|
244
|
+
if (!this.dshSecret)
|
|
245
|
+
this.scheduleDshSecretRetry();
|
|
235
246
|
this.log('info', `gateway listening on ${this.secure ? 'https' : 'http'}://${formatHost(config.bindHost)}:${config.port}`);
|
|
236
247
|
resolve();
|
|
237
248
|
});
|
|
@@ -299,7 +310,7 @@ export class Gateway {
|
|
|
299
310
|
dshOrigin: origin,
|
|
300
311
|
dshAccessUrl,
|
|
301
312
|
interfaces,
|
|
302
|
-
stateDir: this.store.statePath
|
|
313
|
+
stateDir: dirname(this.store.statePath),
|
|
303
314
|
statePath: this.store.statePath,
|
|
304
315
|
error: this.error,
|
|
305
316
|
https: tlsStatus(config, this.ctx.webServer.host),
|
|
@@ -356,8 +367,9 @@ export class Gateway {
|
|
|
356
367
|
isAuthenticated(req) {
|
|
357
368
|
if (!this.store.passwordSet)
|
|
358
369
|
return false;
|
|
370
|
+
const accepted = new Set([this.sessionName, SESSION_COOKIE_LEGACY, SESSION_COOKIE_ALT]);
|
|
359
371
|
for (const [name, value] of parseCookies(req.headers.cookie)) {
|
|
360
|
-
if (name
|
|
372
|
+
if (!accepted.has(name))
|
|
361
373
|
continue;
|
|
362
374
|
if (verifySession(this.store.gateSecret, value) !== null)
|
|
363
375
|
return true;
|
|
@@ -477,7 +489,7 @@ export class Gateway {
|
|
|
477
489
|
if (path === '/__enhancement/logout') {
|
|
478
490
|
res.writeHead(303, {
|
|
479
491
|
location: '/__enhancement/login',
|
|
480
|
-
'set-cookie': clearSessionCookie(this.secure),
|
|
492
|
+
'set-cookie': clearSessionCookie(this.sessionName, this.secure),
|
|
481
493
|
'cache-control': 'no-store',
|
|
482
494
|
});
|
|
483
495
|
res.end();
|
|
@@ -579,7 +591,10 @@ export class Gateway {
|
|
|
579
591
|
const token = signSession(this.store.gateSecret, this.store.config.sessionDays);
|
|
580
592
|
res.writeHead(303, {
|
|
581
593
|
location: next,
|
|
582
|
-
'set-cookie':
|
|
594
|
+
'set-cookie': [
|
|
595
|
+
...clearSessionCookie(this.sessionName, this.secure),
|
|
596
|
+
...sessionCookie(this.sessionName, token, this.store.config.sessionDays, this.secure),
|
|
597
|
+
],
|
|
583
598
|
'cache-control': 'no-store',
|
|
584
599
|
});
|
|
585
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
|
-
|
|
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
|
-
|
|
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() {
|
|
@@ -194,7 +212,16 @@ export class StateStore {
|
|
|
194
212
|
};
|
|
195
213
|
}
|
|
196
214
|
catch {
|
|
197
|
-
|
|
215
|
+
// 解析失败不能静默清空:先把损坏文件改名备份,再回落默认状态,避免用户
|
|
216
|
+
// 的密码哈希 / VAPID / 推送订阅被无提示地覆盖丢失。
|
|
217
|
+
try {
|
|
218
|
+
const backup = `${this.statePath}.corrupt-${Date.now()}`;
|
|
219
|
+
renameSync(this.statePath, backup);
|
|
220
|
+
console.warn(`[dsh-enhancement] state.json 无法解析,已备份到 ${backup} 并以默认配置继续`);
|
|
221
|
+
}
|
|
222
|
+
catch {
|
|
223
|
+
/* 备份失败也不阻塞启动 */
|
|
224
|
+
}
|
|
198
225
|
}
|
|
199
226
|
}
|
|
200
227
|
const config = defaultConfig(this.webServerHost);
|
|
@@ -301,7 +328,7 @@ export function verifySession(secret, value) {
|
|
|
301
328
|
return null;
|
|
302
329
|
try {
|
|
303
330
|
const payload = JSON.parse(Buffer.from(body, 'base64url').toString('utf8'));
|
|
304
|
-
if (payload.v !== 1 || typeof payload.
|
|
331
|
+
if (payload.v !== 1 || typeof payload.sid !== 'string' || typeof payload.iat !== 'number' || typeof payload.exp !== 'number')
|
|
305
332
|
return null;
|
|
306
333
|
if (payload.exp <= Date.now())
|
|
307
334
|
return null;
|
|
@@ -311,17 +338,10 @@ export function verifySession(secret, value) {
|
|
|
311
338
|
return null;
|
|
312
339
|
}
|
|
313
340
|
}
|
|
314
|
-
export function sessionCookie(value, days, secure) {
|
|
315
|
-
const
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
'Path=/',
|
|
319
|
-
'HttpOnly',
|
|
320
|
-
'SameSite=Strict',
|
|
321
|
-
];
|
|
322
|
-
if (secure)
|
|
323
|
-
attrs.push('Secure');
|
|
324
|
-
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}`];
|
|
325
345
|
}
|
|
326
346
|
/**
|
|
327
347
|
* 清空会话 cookie 的 Set-Cookie 头(返回**多条**)。
|
|
@@ -331,9 +351,16 @@ export function sessionCookie(value, days, secure) {
|
|
|
331
351
|
* `/__enhancement` 等其它 path 的残值,只清 `Path=/` 清不掉它们;这些验签失败的
|
|
332
352
|
* 死值会让请求携带多份同名 cookie(见 Gateway#isAuthenticated 的注释)。
|
|
333
353
|
*/
|
|
334
|
-
export function clearSessionCookie(secure) {
|
|
354
|
+
export function clearSessionCookie(currentName, secure) {
|
|
335
355
|
const tail = `Max-Age=0; HttpOnly; SameSite=Strict${secure ? '; Secure' : ''}`;
|
|
336
|
-
|
|
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;
|
|
337
364
|
}
|
|
338
365
|
// ---------------------------------------------------------------------------
|
|
339
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.
|
|
3
|
+
"version": "1.1.3",
|
|
4
4
|
"description": "DSH 增强套件:远程安全访问网关(密码/HTTPS)、PWA 安装与图标、桌面通知与 Web Push、会话请求头、设置页。",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./lib/index.js",
|
|
@@ -31,7 +31,8 @@
|
|
|
31
31
|
"build": "tsc -p tsconfig.json && tsc -p tsconfig.client.json && node scripts/build-client.mjs",
|
|
32
32
|
"verify": "tsc -p tsconfig.json --noEmit && tsc -p tsconfig.client.json --noEmit",
|
|
33
33
|
"prepare": "npm run build",
|
|
34
|
-
"
|
|
34
|
+
"pretest": "npm run build",
|
|
35
|
+
"test": "node --test test/session-header.test.mjs test/gateway-auth.test.mjs"
|
|
35
36
|
},
|
|
36
37
|
"dsh": {
|
|
37
38
|
"bundle": {
|
|
@@ -40,7 +41,6 @@
|
|
|
40
41
|
"client": {
|
|
41
42
|
"platform": "web",
|
|
42
43
|
"inject": [
|
|
43
|
-
"@deepseek-ai/dsh-client-ui-slots",
|
|
44
44
|
"@deepseek-ai/dsh-client-ui-workspace"
|
|
45
45
|
],
|
|
46
46
|
"immediately": true
|
package/src/client/index.tsx
CHANGED
|
@@ -159,7 +159,7 @@ const styles: Record<string, React.CSSProperties> = {
|
|
|
159
159
|
muted: { color: 'var(--dsw-alias-label-secondary, #667085)', fontSize: 12, lineHeight: 1.6 },
|
|
160
160
|
card: { borderTop: '1px solid var(--dsw-alias-border-l4, rgba(0,0,0,.08))', padding: '16px 0 8px', display: 'flex', flexDirection: 'column', gap: 6, background: 'transparent' },
|
|
161
161
|
blockHead: { display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: 12 },
|
|
162
|
-
dirty: { flex: 'none', fontSize: 11, lineHeight: '18px', padding: '1px 8px', borderRadius: 999, color: 'var(--dsw-alias-state-
|
|
162
|
+
dirty: { flex: 'none', fontSize: 11, lineHeight: '18px', padding: '1px 8px', borderRadius: 999, color: 'var(--dsw-alias-state-warn-primary, #b54708)', background: 'color-mix(in srgb, var(--dsw-alias-state-warn-primary, #b54708) 12%, transparent)', border: '.5px solid color-mix(in srgb, var(--dsw-alias-state-warn-primary, #b54708) 30%, transparent)' },
|
|
163
163
|
subhead: { margin: '14px 0 2px', fontSize: 13, fontWeight: 600, color: 'var(--dsw-alias-label-primary, inherit)' },
|
|
164
164
|
row: { display: 'flex', flexDirection: 'column', gap: 6, padding: '10px 0' },
|
|
165
165
|
label: { color: 'var(--dsw-alias-label-primary, inherit)', fontSize: 13, fontWeight: 500 },
|
|
@@ -170,12 +170,12 @@ const styles: Record<string, React.CSSProperties> = {
|
|
|
170
170
|
actions: { display: 'flex', flexWrap: 'wrap', alignItems: 'center', gap: 8, paddingTop: 4 },
|
|
171
171
|
button: { height: 32, padding: '0 14px', borderRadius: 'var(--dsw-radius-md, 12px)', border: '.5px solid var(--dsw-alias-border-l4, rgba(0,0,0,.16))', background: 'transparent', color: 'var(--dsw-alias-label-primary, inherit)', cursor: 'pointer', fontSize: 13 },
|
|
172
172
|
primary: { background: 'var(--dsw-alias-state-business-primary, #4d8dff)', borderColor: 'transparent', color: '#fff', fontWeight: 600 },
|
|
173
|
-
danger: { color: 'var(--dsw-alias-state-
|
|
173
|
+
danger: { color: 'var(--dsw-alias-state-error-primary, #d92d20)' },
|
|
174
174
|
pill: { display: 'inline-flex', alignItems: 'center', gap: 6, padding: '3px 9px', borderRadius: 999, border: '.5px solid var(--dsw-alias-border-l4, rgba(0,0,0,.16))', fontSize: 11, color: 'var(--dsw-alias-label-secondary, #667085)' },
|
|
175
175
|
dot: { width: 7, height: 7, borderRadius: 999, background: '#54d18a', display: 'inline-block' },
|
|
176
176
|
dotOff: { width: 7, height: 7, borderRadius: 999, background: '#98a2b3', display: 'inline-block' },
|
|
177
177
|
code: { fontFamily: 'ui-monospace, SFMono-Regular, Menlo, monospace', fontSize: 11, wordBreak: 'break-all', background: 'var(--dsw-alias-bg-layer-3, rgba(0,0,0,.04))', color: 'var(--dsw-alias-label-secondary, #475467)', padding: '6px 8px', borderRadius: 8 },
|
|
178
|
-
error: { color: 'var(--dsw-alias-state-
|
|
178
|
+
error: { color: 'var(--dsw-alias-state-error-primary, #d92d20)', fontSize: 12, lineHeight: 1.5 },
|
|
179
179
|
success: { color: 'var(--dsw-alias-state-success-primary, #079455)', fontSize: 12, lineHeight: 1.5 },
|
|
180
180
|
divider: { height: 1, background: 'var(--dsw-alias-border-l4, rgba(0,0,0,.08))', margin: '6px 0' },
|
|
181
181
|
}
|
|
@@ -192,7 +192,7 @@ function Row(props: { label: string; children: React.ReactNode }): React.ReactEl
|
|
|
192
192
|
|
|
193
193
|
const CARD_CSS = `
|
|
194
194
|
.enh-root input:not([type="checkbox"]):not([type="file"]), .enh-root select { transition:border-color .15s, box-shadow .15s; }
|
|
195
|
-
.enh-root input:not([type="checkbox"]):not([type="file"]):focus, .enh-root input:not([type="checkbox"]):not([type="file"]):focus-visible, .enh-root select:focus, .enh-root select:focus-visible { outline:none !important; border-color:var(--dsw-alias-state-business-primary, #4d8dff) !important; box-shadow:0 0 0 3px var(--dsw-alias-state-business-primary
|
|
195
|
+
.enh-root input:not([type="checkbox"]):not([type="file"]):focus, .enh-root input:not([type="checkbox"]):not([type="file"]):focus-visible, .enh-root select:focus, .enh-root select:focus-visible { outline:none !important; border-color:var(--dsw-alias-state-business-primary, #4d8dff) !important; box-shadow:0 0 0 3px color-mix(in srgb, var(--dsw-alias-state-business-primary, #4d8dff) 18%, transparent) !important; }
|
|
196
196
|
.enh-root select { appearance:none; background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12'%3E%3Cpath d='M3 4.5 6 7.5 9 4.5' fill='none' stroke='%23667085' stroke-width='1.4' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E"); background-repeat:no-repeat; background-position:right 12px center; padding-right:32px !important; }
|
|
197
197
|
.enh-root input[type="file"] { font-size:12px; color:var(--dsw-alias-label-secondary, #475467); max-width:100%; }
|
|
198
198
|
.enh-root input[type="file"]::file-selector-button { appearance:none; height:32px; margin-right:10px; padding:0 14px; border-radius:var(--dsw-radius-md, 12px); border:.5px solid var(--dsw-alias-border-l4, rgba(0,0,0,.16)); background:var(--dsw-alias-bg-layer-3, #fff); color:var(--dsw-alias-label-primary, inherit); font:inherit; font-size:13px; cursor:pointer; }
|
|
@@ -206,7 +206,7 @@ const CARD_CSS = `
|
|
|
206
206
|
.enh-root input[type="checkbox"]:focus-visible { outline:2px solid var(--dsw-alias-state-business-primary, #4d8dff); outline-offset:2px; }
|
|
207
207
|
|
|
208
208
|
.enh-root .enh-block-head { display:flex; align-items:flex-start; justify-content:space-between; gap:12px; }
|
|
209
|
-
.enh-root .enh-dirty { flex:none; font-size:11px; line-height:18px; padding:1px 8px; border-radius:999px; color:var(--dsw-alias-state-
|
|
209
|
+
.enh-root .enh-dirty { flex:none; font-size:11px; line-height:18px; padding:1px 8px; border-radius:999px; color:var(--dsw-alias-state-warn-primary, #b54708); background:color-mix(in srgb, var(--dsw-alias-state-warn-primary, #b54708) 12%, transparent); border:.5px solid color-mix(in srgb, var(--dsw-alias-state-warn-primary, #b54708) 30%, transparent); }
|
|
210
210
|
.enh-root .enh-subhead { margin:14px 0 2px; font-size:13px; font-weight:600; color:var(--dsw-alias-label-primary, inherit); }
|
|
211
211
|
.enh-root .enh-grid2 { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:10px 16px; }
|
|
212
212
|
.enh-root .enh-grid3 { display:grid; grid-template-columns:repeat(3,minmax(0,1fr)); gap:10px 16px; }
|
|
@@ -625,6 +625,9 @@ function Card(): React.ReactElement {
|
|
|
625
625
|
const enablePush = () => run(async () => {
|
|
626
626
|
if (!window.isSecureContext) throw new Error('Web Push 需要 HTTPS(或 localhost);请先启用 HTTPS')
|
|
627
627
|
if (!('serviceWorker' in navigator) || !('PushManager' in window)) throw new Error('当前浏览器不支持 Web Push')
|
|
628
|
+
// /sw.js 仅在启用 PWA 时注册;PWA 关闭时 Service Worker 不可用,Web Push 无从订阅。
|
|
629
|
+
const swProbe = await fetch('/sw.js', { method: 'HEAD', cache: 'no-store' }).catch(() => undefined)
|
|
630
|
+
if (!swProbe || !swProbe.ok) throw new Error('Web Push 需要先启用 PWA(PWA 关闭时 Service Worker /sw.js 不可用)')
|
|
628
631
|
if (!pushPublicKey) throw new Error('VAPID 公钥尚未生成,请刷新状态后重试')
|
|
629
632
|
let permission = Notification.permission
|
|
630
633
|
if (permission === 'default') permission = await Notification.requestPermission()
|
|
@@ -1210,7 +1213,7 @@ function connectFocusEvents(ctx: ClientContext): (() => void) | undefined {
|
|
|
1210
1213
|
}
|
|
1211
1214
|
}
|
|
1212
1215
|
|
|
1213
|
-
function openSessionFromQuery(ctx: ClientContext): void {
|
|
1216
|
+
function openSessionFromQuery(ctx: ClientContext): () => void {
|
|
1214
1217
|
const read = () => {
|
|
1215
1218
|
try {
|
|
1216
1219
|
const url = new URL(location.href)
|
|
@@ -1223,20 +1226,29 @@ function openSessionFromQuery(ctx: ClientContext): void {
|
|
|
1223
1226
|
/* ignore */
|
|
1224
1227
|
}
|
|
1225
1228
|
}
|
|
1229
|
+
const onMessage = (event: MessageEvent) => {
|
|
1230
|
+
const data = event.data as { type?: string; sessionId?: string } | undefined
|
|
1231
|
+
if (data?.type === 'dsh-enhancement-open-session' && data.sessionId && SESSION_ID_PATTERN.test(data.sessionId)) openSession(ctx, data.sessionId)
|
|
1232
|
+
}
|
|
1226
1233
|
read()
|
|
1227
1234
|
window.addEventListener('popstate', read)
|
|
1228
1235
|
try {
|
|
1229
|
-
navigator.serviceWorker?.addEventListener('message',
|
|
1230
|
-
const data = event.data as { type?: string; sessionId?: string } | undefined
|
|
1231
|
-
if (data?.type === 'dsh-enhancement-open-session' && data.sessionId && SESSION_ID_PATTERN.test(data.sessionId)) openSession(ctx, data.sessionId)
|
|
1232
|
-
})
|
|
1236
|
+
navigator.serviceWorker?.addEventListener('message', onMessage)
|
|
1233
1237
|
} catch {
|
|
1234
1238
|
/* service worker unavailable on HTTP */
|
|
1235
1239
|
}
|
|
1240
|
+
return () => {
|
|
1241
|
+
window.removeEventListener('popstate', read)
|
|
1242
|
+
try {
|
|
1243
|
+
navigator.serviceWorker?.removeEventListener('message', onMessage)
|
|
1244
|
+
} catch {
|
|
1245
|
+
/* ignore */
|
|
1246
|
+
}
|
|
1247
|
+
}
|
|
1236
1248
|
}
|
|
1237
1249
|
|
|
1238
1250
|
export function apply(ctx: ClientContext): void {
|
|
1239
|
-
openSessionFromQuery(ctx)
|
|
1251
|
+
ctx.effect(() => openSessionFromQuery(ctx), 'dsh-enhancement: open session from query')
|
|
1240
1252
|
ctx.effect(() => connectFocusEvents(ctx), 'dsh-enhancement: focus existing page')
|
|
1241
1253
|
ctx.effect(() => {
|
|
1242
1254
|
return ctx.slots.inject('plugins.bundle.config', () =>
|
package/src/gateway.ts
CHANGED
|
@@ -12,8 +12,10 @@ import { createServer, request as httpRequest, type IncomingMessage, type Server
|
|
|
12
12
|
import { createServer as createHttpsServer } from 'node:https'
|
|
13
13
|
import { connect as netConnect, type Socket } from 'node:net'
|
|
14
14
|
import { URL } from 'node:url'
|
|
15
|
+
import { dirname } from 'node:path'
|
|
15
16
|
import {
|
|
16
|
-
|
|
17
|
+
SESSION_COOKIE_ALT,
|
|
18
|
+
SESSION_COOKIE_LEGACY,
|
|
17
19
|
StateStore,
|
|
18
20
|
clearSessionCookie,
|
|
19
21
|
formatHost,
|
|
@@ -21,6 +23,7 @@ import {
|
|
|
21
23
|
readDshSecret,
|
|
22
24
|
requestAuthority,
|
|
23
25
|
sessionCookie,
|
|
26
|
+
sessionCookieName,
|
|
24
27
|
signSession,
|
|
25
28
|
verifySession,
|
|
26
29
|
} from './state.js'
|
|
@@ -78,7 +81,9 @@ function parseCookies(header: string | undefined): Array<[string, string]> {
|
|
|
78
81
|
|
|
79
82
|
function safeNext(raw: unknown): string {
|
|
80
83
|
if (typeof raw !== 'string' || !raw.startsWith('/') || raw.startsWith('//')) return '/'
|
|
81
|
-
|
|
84
|
+
// 反斜杠在 URL 解析中会被归一成斜杠(`/\evil.com` → `//evil.com`),构成开放重定向;
|
|
85
|
+
// 编码形式 %5c 同理,一并拒绝。CRLF 用于防响应拆分。
|
|
86
|
+
if (/[\r\n\\]/.test(raw) || /%5c|%2f%2f/i.test(raw)) return '/'
|
|
82
87
|
return raw
|
|
83
88
|
}
|
|
84
89
|
|
|
@@ -153,6 +158,12 @@ export class Gateway {
|
|
|
153
158
|
private secure = false
|
|
154
159
|
private failures = new Map<string, LoginFailure>()
|
|
155
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
|
+
|
|
156
167
|
constructor(
|
|
157
168
|
private readonly ctx: HostContextLike,
|
|
158
169
|
private readonly store: StateStore,
|
|
@@ -213,8 +224,7 @@ export class Gateway {
|
|
|
213
224
|
}
|
|
214
225
|
this.dshSecret = await readDshSecret(this.ctx)
|
|
215
226
|
if (!this.dshSecret) {
|
|
216
|
-
this.log('warn', '无法读取 DSH
|
|
217
|
-
this.scheduleDshSecretRetry()
|
|
227
|
+
this.log('warn', '无法读取 DSH 浏览器会话签名密钥;启动后会在后台重试(网关自身仍可登录)')
|
|
218
228
|
}
|
|
219
229
|
const handler = (req: IncomingMessage, res: ServerResponse): void => {
|
|
220
230
|
void this.handleRequest(req, res).catch((err: unknown) => {
|
|
@@ -256,6 +266,9 @@ export class Gateway {
|
|
|
256
266
|
server.listen(config.port, config.bindHost, () => {
|
|
257
267
|
server.off('error', onError)
|
|
258
268
|
this.started = true
|
|
269
|
+
// 监听成功(started=true)后才安排密钥重试:此前的 scheduleDshSecretRetry
|
|
270
|
+
// 会因守卫 !this.started 直接返回,导致重试从不发生。
|
|
271
|
+
if (!this.dshSecret) this.scheduleDshSecretRetry()
|
|
259
272
|
this.log('info', `gateway listening on ${this.secure ? 'https' : 'http'}://${formatHost(config.bindHost)}:${config.port}`)
|
|
260
273
|
resolve()
|
|
261
274
|
})
|
|
@@ -322,7 +335,7 @@ export class Gateway {
|
|
|
322
335
|
dshOrigin: origin,
|
|
323
336
|
dshAccessUrl,
|
|
324
337
|
interfaces,
|
|
325
|
-
stateDir: this.store.statePath
|
|
338
|
+
stateDir: dirname(this.store.statePath),
|
|
326
339
|
statePath: this.store.statePath,
|
|
327
340
|
error: this.error,
|
|
328
341
|
https: tlsStatus(config, this.ctx.webServer.host),
|
|
@@ -380,8 +393,9 @@ export class Gateway {
|
|
|
380
393
|
*/
|
|
381
394
|
private isAuthenticated(req: IncomingMessage): boolean {
|
|
382
395
|
if (!this.store.passwordSet) return false
|
|
396
|
+
const accepted = new Set([this.sessionName, SESSION_COOKIE_LEGACY, SESSION_COOKIE_ALT])
|
|
383
397
|
for (const [name, value] of parseCookies(req.headers.cookie)) {
|
|
384
|
-
if (name
|
|
398
|
+
if (!accepted.has(name)) continue
|
|
385
399
|
if (verifySession(this.store.gateSecret, value) !== null) return true
|
|
386
400
|
}
|
|
387
401
|
return false
|
|
@@ -493,7 +507,7 @@ export class Gateway {
|
|
|
493
507
|
if (path === '/__enhancement/logout') {
|
|
494
508
|
res.writeHead(303, {
|
|
495
509
|
location: '/__enhancement/login',
|
|
496
|
-
'set-cookie': clearSessionCookie(this.secure),
|
|
510
|
+
'set-cookie': clearSessionCookie(this.sessionName, this.secure),
|
|
497
511
|
'cache-control': 'no-store',
|
|
498
512
|
})
|
|
499
513
|
res.end()
|
|
@@ -601,7 +615,10 @@ export class Gateway {
|
|
|
601
615
|
const token = signSession(this.store.gateSecret, this.store.config.sessionDays)
|
|
602
616
|
res.writeHead(303, {
|
|
603
617
|
location: next,
|
|
604
|
-
'set-cookie':
|
|
618
|
+
'set-cookie': [
|
|
619
|
+
...clearSessionCookie(this.sessionName, this.secure),
|
|
620
|
+
...sessionCookie(this.sessionName, token, this.store.config.sessionDays, this.secure),
|
|
621
|
+
],
|
|
605
622
|
'cache-control': 'no-store',
|
|
606
623
|
})
|
|
607
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
|
-
|
|
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。 */
|
|
@@ -278,7 +299,15 @@ export class StateStore {
|
|
|
278
299
|
updatedAt: nowIso(),
|
|
279
300
|
}
|
|
280
301
|
} catch {
|
|
281
|
-
|
|
302
|
+
// 解析失败不能静默清空:先把损坏文件改名备份,再回落默认状态,避免用户
|
|
303
|
+
// 的密码哈希 / VAPID / 推送订阅被无提示地覆盖丢失。
|
|
304
|
+
try {
|
|
305
|
+
const backup = `${this.statePath}.corrupt-${Date.now()}`
|
|
306
|
+
renameSync(this.statePath, backup)
|
|
307
|
+
console.warn(`[dsh-enhancement] state.json 无法解析,已备份到 ${backup} 并以默认配置继续`)
|
|
308
|
+
} catch {
|
|
309
|
+
/* 备份失败也不阻塞启动 */
|
|
310
|
+
}
|
|
282
311
|
}
|
|
283
312
|
}
|
|
284
313
|
const config = defaultConfig(this.webServerHost)
|
|
@@ -404,7 +433,7 @@ export function verifySession(secret: string, value: string | undefined): Sessio
|
|
|
404
433
|
if (actual.length !== expected.length || !timingSafeEqual(actual, expected)) return null
|
|
405
434
|
try {
|
|
406
435
|
const payload = JSON.parse(Buffer.from(body, 'base64url').toString('utf8')) as SessionPayload
|
|
407
|
-
if (payload.v !== 1 || typeof payload.
|
|
436
|
+
if (payload.v !== 1 || typeof payload.sid !== 'string' || typeof payload.iat !== 'number' || typeof payload.exp !== 'number') return null
|
|
408
437
|
if (payload.exp <= Date.now()) return null
|
|
409
438
|
return payload
|
|
410
439
|
} catch {
|
|
@@ -412,16 +441,10 @@ export function verifySession(secret: string, value: string | undefined): Sessio
|
|
|
412
441
|
}
|
|
413
442
|
}
|
|
414
443
|
|
|
415
|
-
export function sessionCookie(value: string, days: number, secure: boolean): string {
|
|
416
|
-
const
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
'Path=/',
|
|
420
|
-
'HttpOnly',
|
|
421
|
-
'SameSite=Strict',
|
|
422
|
-
]
|
|
423
|
-
if (secure) attrs.push('Secure')
|
|
424
|
-
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}`]
|
|
425
448
|
}
|
|
426
449
|
|
|
427
450
|
/**
|
|
@@ -432,11 +455,16 @@ export function sessionCookie(value: string, days: number, secure: boolean): str
|
|
|
432
455
|
* `/__enhancement` 等其它 path 的残值,只清 `Path=/` 清不掉它们;这些验签失败的
|
|
433
456
|
* 死值会让请求携带多份同名 cookie(见 Gateway#isAuthenticated 的注释)。
|
|
434
457
|
*/
|
|
435
|
-
export function clearSessionCookie(secure: boolean): string[] {
|
|
458
|
+
export function clearSessionCookie(currentName: string, secure: boolean): string[] {
|
|
436
459
|
const tail = `Max-Age=0; HttpOnly; SameSite=Strict${secure ? '; Secure' : ''}`
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
)
|
|
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
|
|
440
468
|
}
|
|
441
469
|
|
|
442
470
|
// ---------------------------------------------------------------------------
|
|
@@ -1,14 +1,16 @@
|
|
|
1
|
-
//
|
|
1
|
+
// 网关会话 cookie 的回归测试。
|
|
2
2
|
//
|
|
3
|
-
//
|
|
3
|
+
// 覆盖 2026-10-24 真机报障:「密码正确、点进入 DSH 后停在登录页,且不提示密码错误」。
|
|
4
4
|
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
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
|
-
|
|
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
|
-
/**
|
|
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
|
|
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 ===
|
|
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.
|
|
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 = `${
|
|
62
|
-
const good = `${
|
|
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('
|
|
131
|
+
test('回归护栏:旧实现(固定名 + 只取第一个)在坏值排前时失败', () => {
|
|
74
132
|
const valid = signSession(SECRET, 30)
|
|
75
|
-
const staleFirst = `${
|
|
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
|
-
`${
|
|
85
|
-
`${
|
|
86
|
-
`${
|
|
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(`${
|
|
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(`${
|
|
156
|
+
assert.equal(isAuthenticated(`${NAME}=${signSession(other, 30)}`, NAME), false)
|
|
101
157
|
})
|
|
102
158
|
|
|
103
159
|
// ---------------------------------------------------------------------------
|
|
104
|
-
//
|
|
160
|
+
// 4. 升级兼容:旧固定名写入的会话仍被接受(不让老用户被迫重登)
|
|
105
161
|
// ---------------------------------------------------------------------------
|
|
106
162
|
|
|
107
|
-
test('
|
|
108
|
-
const
|
|
109
|
-
assert.
|
|
110
|
-
assert.
|
|
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
|
-
//
|
|
170
|
+
// 5. Set-Cookie 下发与清除
|
|
130
171
|
// ---------------------------------------------------------------------------
|
|
131
172
|
|
|
132
|
-
test('
|
|
173
|
+
test('登录成功时只写实例专属名(不再写固定名)', () => {
|
|
133
174
|
const token = signSession(SECRET, 30)
|
|
134
|
-
const
|
|
135
|
-
|
|
136
|
-
assert.
|
|
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
|
|
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
|
})
|