dsh-enhancement 1.1.0 → 1.1.2
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 +1 -0
- package/README.md +1 -0
- package/lib/client.js +25 -11
- package/lib/gateway.d.ts +10 -0
- package/lib/gateway.js +27 -7
- package/lib/state.d.ts +9 -1
- package/lib/state.js +21 -3
- package/package.json +10 -10
- package/src/client/index.tsx +23 -11
- package/src/gateway.ts +24 -7
- package/src/state.ts +23 -4
- package/test/gateway-auth.test.mjs +149 -0
package/README.en.md
CHANGED
|
@@ -94,6 +94,7 @@ Clicking a desktop notification opens the matching session. On Linux and Windows
|
|
|
94
94
|
## Mobile push
|
|
95
95
|
|
|
96
96
|
- Standard Web Push with one subscription per device.
|
|
97
|
+
- **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
98
|
- Click “Subscribe this device” in settings; the browser asks for notification permission, which must be triggered by your click.
|
|
98
99
|
- iOS / iPadOS 16.4+ is required, and DSH must be added to the Home Screen; regular Safari tabs cannot receive push.
|
|
99
100
|
- Unsubscribe at any time, or send a test push to verify the setup.
|
package/README.md
CHANGED
|
@@ -95,6 +95,7 @@ DSH 只在直连官方 DeepSeek API 时发送 `x-deepseek-harness-session-id`;
|
|
|
95
95
|
## 手机推送
|
|
96
96
|
|
|
97
97
|
- 使用标准 Web Push,每台设备单独订阅。
|
|
98
|
+
- **需要先启用 PWA**:Service Worker(`/sw.js`)由 PWA 提供,PWA 关闭时无法订阅。
|
|
98
99
|
- 在设置里点“订阅本设备”,浏览器会请求通知权限;权限必须由你主动点击触发。
|
|
99
100
|
- iOS / iPadOS 需要 16.4 或更高版本,并且必须先把 DSH 添加到主屏幕;Safari 普通标签页收不到推送。
|
|
100
101
|
- 可以随时取消订阅,或发送一条测试推送确认链路正常。
|
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
|
@@ -19,6 +19,16 @@ export declare class Gateway {
|
|
|
19
19
|
stop(): Promise<void>;
|
|
20
20
|
restart(): Promise<void>;
|
|
21
21
|
status(interfaces: NetworkInterfaceInfo[]): Status;
|
|
22
|
+
/**
|
|
23
|
+
* 请求是否携带有效的网关会话。
|
|
24
|
+
*
|
|
25
|
+
* 必须遍历**所有**同名 cookie,不能只看第一个:浏览器在 path / 域重叠、或
|
|
26
|
+
* 同一个地址先后被不同版本写入过时,Cookie 头里会出现多个同名项,顺序由浏览器
|
|
27
|
+
* 决定(通常按创建时间或 path 长度)。旧实现取 `find(...)` 的第一个,一旦过期
|
|
28
|
+
* 的旧 cookie 排在前面就永久认证失败 —— 表现为「密码正确、登录页刷新一下又回
|
|
29
|
+
* 到登录页,且不报密码错误」,用户无法自愈(除非手动清 cookie)。
|
|
30
|
+
* 现在任何一个值验签通过即视为已认证。
|
|
31
|
+
*/
|
|
22
32
|
private isAuthenticated;
|
|
23
33
|
private isSameOrigin;
|
|
24
34
|
private gatewayOrigin;
|
package/lib/gateway.js
CHANGED
|
@@ -12,6 +12,7 @@ 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 { dirname } from 'node:path';
|
|
15
16
|
import { SESSION_COOKIE, clearSessionCookie, formatHost, mintDshCookie, readDshSecret, requestAuthority, sessionCookie, signSession, verifySession, } from './state.js';
|
|
16
17
|
import { caCertificatePem, prepareTls, tlsStatus } from './tls.js';
|
|
17
18
|
const LOGIN_WINDOW_MS = 10 * 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
|
}
|
|
@@ -189,8 +192,7 @@ export class Gateway {
|
|
|
189
192
|
}
|
|
190
193
|
this.dshSecret = await readDshSecret(this.ctx);
|
|
191
194
|
if (!this.dshSecret) {
|
|
192
|
-
this.log('warn', '无法读取 DSH
|
|
193
|
-
this.scheduleDshSecretRetry();
|
|
195
|
+
this.log('warn', '无法读取 DSH 浏览器会话签名密钥;启动后会在后台重试(网关自身仍可登录)');
|
|
194
196
|
}
|
|
195
197
|
const handler = (req, res) => {
|
|
196
198
|
void this.handleRequest(req, res).catch((err) => {
|
|
@@ -232,6 +234,10 @@ export class Gateway {
|
|
|
232
234
|
server.listen(config.port, config.bindHost, () => {
|
|
233
235
|
server.off('error', onError);
|
|
234
236
|
this.started = true;
|
|
237
|
+
// 监听成功(started=true)后才安排密钥重试:此前的 scheduleDshSecretRetry
|
|
238
|
+
// 会因守卫 !this.started 直接返回,导致重试从不发生。
|
|
239
|
+
if (!this.dshSecret)
|
|
240
|
+
this.scheduleDshSecretRetry();
|
|
235
241
|
this.log('info', `gateway listening on ${this.secure ? 'https' : 'http'}://${formatHost(config.bindHost)}:${config.port}`);
|
|
236
242
|
resolve();
|
|
237
243
|
});
|
|
@@ -299,7 +305,7 @@ export class Gateway {
|
|
|
299
305
|
dshOrigin: origin,
|
|
300
306
|
dshAccessUrl,
|
|
301
307
|
interfaces,
|
|
302
|
-
stateDir: this.store.statePath
|
|
308
|
+
stateDir: dirname(this.store.statePath),
|
|
303
309
|
statePath: this.store.statePath,
|
|
304
310
|
error: this.error,
|
|
305
311
|
https: tlsStatus(config, this.ctx.webServer.host),
|
|
@@ -343,12 +349,26 @@ export class Gateway {
|
|
|
343
349
|
// -------------------------------------------------------------------------
|
|
344
350
|
// 认证
|
|
345
351
|
// -------------------------------------------------------------------------
|
|
352
|
+
/**
|
|
353
|
+
* 请求是否携带有效的网关会话。
|
|
354
|
+
*
|
|
355
|
+
* 必须遍历**所有**同名 cookie,不能只看第一个:浏览器在 path / 域重叠、或
|
|
356
|
+
* 同一个地址先后被不同版本写入过时,Cookie 头里会出现多个同名项,顺序由浏览器
|
|
357
|
+
* 决定(通常按创建时间或 path 长度)。旧实现取 `find(...)` 的第一个,一旦过期
|
|
358
|
+
* 的旧 cookie 排在前面就永久认证失败 —— 表现为「密码正确、登录页刷新一下又回
|
|
359
|
+
* 到登录页,且不报密码错误」,用户无法自愈(除非手动清 cookie)。
|
|
360
|
+
* 现在任何一个值验签通过即视为已认证。
|
|
361
|
+
*/
|
|
346
362
|
isAuthenticated(req) {
|
|
347
363
|
if (!this.store.passwordSet)
|
|
348
364
|
return false;
|
|
349
|
-
const
|
|
350
|
-
|
|
351
|
-
|
|
365
|
+
for (const [name, value] of parseCookies(req.headers.cookie)) {
|
|
366
|
+
if (name !== SESSION_COOKIE)
|
|
367
|
+
continue;
|
|
368
|
+
if (verifySession(this.store.gateSecret, value) !== null)
|
|
369
|
+
return true;
|
|
370
|
+
}
|
|
371
|
+
return false;
|
|
352
372
|
}
|
|
353
373
|
isSameOrigin(req) {
|
|
354
374
|
const host = req.headers.host;
|
package/lib/state.d.ts
CHANGED
|
@@ -129,7 +129,15 @@ export interface SessionPayload {
|
|
|
129
129
|
export declare function signSession(secret: string, days: number): string;
|
|
130
130
|
export declare function verifySession(secret: string, value: string | undefined): SessionPayload | null;
|
|
131
131
|
export declare function sessionCookie(value: string, days: number, secure: boolean): string;
|
|
132
|
-
|
|
132
|
+
/**
|
|
133
|
+
* 清空会话 cookie 的 Set-Cookie 头(返回**多条**)。
|
|
134
|
+
*
|
|
135
|
+
* 浏览器按 (name, domain, path) 三元组存 cookie:同一个名字挂在不同 path 下就是
|
|
136
|
+
* 不同条目,每个都会出现在同一条 Cookie 请求头里。历史版本或人工改动可能留下
|
|
137
|
+
* `/__enhancement` 等其它 path 的残值,只清 `Path=/` 清不掉它们;这些验签失败的
|
|
138
|
+
* 死值会让请求携带多份同名 cookie(见 Gateway#isAuthenticated 的注释)。
|
|
139
|
+
*/
|
|
140
|
+
export declare function clearSessionCookie(secure: boolean): string[];
|
|
133
141
|
export declare function requestAuthority(host: string, protocol: 'http:' | 'https:'): string;
|
|
134
142
|
export declare function mintDshCookie(secret: Buffer, authority: string, ttlMs: number): string;
|
|
135
143
|
export declare function readDshSecret(ctx: {
|
package/lib/state.js
CHANGED
|
@@ -194,7 +194,16 @@ export class StateStore {
|
|
|
194
194
|
};
|
|
195
195
|
}
|
|
196
196
|
catch {
|
|
197
|
-
|
|
197
|
+
// 解析失败不能静默清空:先把损坏文件改名备份,再回落默认状态,避免用户
|
|
198
|
+
// 的密码哈希 / VAPID / 推送订阅被无提示地覆盖丢失。
|
|
199
|
+
try {
|
|
200
|
+
const backup = `${this.statePath}.corrupt-${Date.now()}`;
|
|
201
|
+
renameSync(this.statePath, backup);
|
|
202
|
+
console.warn(`[dsh-enhancement] state.json 无法解析,已备份到 ${backup} 并以默认配置继续`);
|
|
203
|
+
}
|
|
204
|
+
catch {
|
|
205
|
+
/* 备份失败也不阻塞启动 */
|
|
206
|
+
}
|
|
198
207
|
}
|
|
199
208
|
}
|
|
200
209
|
const config = defaultConfig(this.webServerHost);
|
|
@@ -301,7 +310,7 @@ export function verifySession(secret, value) {
|
|
|
301
310
|
return null;
|
|
302
311
|
try {
|
|
303
312
|
const payload = JSON.parse(Buffer.from(body, 'base64url').toString('utf8'));
|
|
304
|
-
if (payload.v !== 1 || typeof payload.
|
|
313
|
+
if (payload.v !== 1 || typeof payload.sid !== 'string' || typeof payload.iat !== 'number' || typeof payload.exp !== 'number')
|
|
305
314
|
return null;
|
|
306
315
|
if (payload.exp <= Date.now())
|
|
307
316
|
return null;
|
|
@@ -323,8 +332,17 @@ export function sessionCookie(value, days, secure) {
|
|
|
323
332
|
attrs.push('Secure');
|
|
324
333
|
return attrs.join('; ');
|
|
325
334
|
}
|
|
335
|
+
/**
|
|
336
|
+
* 清空会话 cookie 的 Set-Cookie 头(返回**多条**)。
|
|
337
|
+
*
|
|
338
|
+
* 浏览器按 (name, domain, path) 三元组存 cookie:同一个名字挂在不同 path 下就是
|
|
339
|
+
* 不同条目,每个都会出现在同一条 Cookie 请求头里。历史版本或人工改动可能留下
|
|
340
|
+
* `/__enhancement` 等其它 path 的残值,只清 `Path=/` 清不掉它们;这些验签失败的
|
|
341
|
+
* 死值会让请求携带多份同名 cookie(见 Gateway#isAuthenticated 的注释)。
|
|
342
|
+
*/
|
|
326
343
|
export function clearSessionCookie(secure) {
|
|
327
|
-
|
|
344
|
+
const tail = `Max-Age=0; HttpOnly; SameSite=Strict${secure ? '; Secure' : ''}`;
|
|
345
|
+
return ['/', '/__enhancement', '/__enhancement/login'].map((path) => `${SESSION_COOKIE}=; Path=${path}; ${tail}`);
|
|
328
346
|
}
|
|
329
347
|
// ---------------------------------------------------------------------------
|
|
330
348
|
// 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.2",
|
|
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
|
|
@@ -48,12 +48,12 @@
|
|
|
48
48
|
},
|
|
49
49
|
"devDependencies": {
|
|
50
50
|
"@types/node": "^22.20.1",
|
|
51
|
-
"@types/react": "~18.3.12",
|
|
52
|
-
"react": "^18.2.0",
|
|
53
|
-
"typescript": "^6.0.3",
|
|
54
51
|
"@types/node-forge": "^1.3.14",
|
|
55
52
|
"@types/node-notifier": "^8.0.5",
|
|
56
|
-
"@types/
|
|
53
|
+
"@types/react": "~18.3.12",
|
|
54
|
+
"@types/web-push": "^3.6.4",
|
|
55
|
+
"react": "^18.2.0",
|
|
56
|
+
"typescript": "^6.0.3"
|
|
57
57
|
},
|
|
58
58
|
"engines": {
|
|
59
59
|
"node": ">=22.18.0"
|
|
@@ -74,10 +74,10 @@
|
|
|
74
74
|
"deepseek"
|
|
75
75
|
],
|
|
76
76
|
"dependencies": {
|
|
77
|
+
"dsh-vertical-layout": "^1.1.0",
|
|
78
|
+
"dsh-web-mobile": "^3.1.0",
|
|
77
79
|
"node-forge": "^1.4.0",
|
|
78
80
|
"node-notifier": "^11.0.0",
|
|
79
|
-
"web-push": "^3.6.7"
|
|
80
|
-
"dsh-web-mobile": "^3.1.0",
|
|
81
|
-
"dsh-vertical-layout": "^1.1.0"
|
|
81
|
+
"web-push": "^3.6.7"
|
|
82
82
|
}
|
|
83
83
|
}
|
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,6 +12,7 @@ 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,
|
|
17
18
|
StateStore,
|
|
@@ -78,7 +79,9 @@ function parseCookies(header: string | undefined): Array<[string, string]> {
|
|
|
78
79
|
|
|
79
80
|
function safeNext(raw: unknown): string {
|
|
80
81
|
if (typeof raw !== 'string' || !raw.startsWith('/') || raw.startsWith('//')) return '/'
|
|
81
|
-
|
|
82
|
+
// 反斜杠在 URL 解析中会被归一成斜杠(`/\evil.com` → `//evil.com`),构成开放重定向;
|
|
83
|
+
// 编码形式 %5c 同理,一并拒绝。CRLF 用于防响应拆分。
|
|
84
|
+
if (/[\r\n\\]/.test(raw) || /%5c|%2f%2f/i.test(raw)) return '/'
|
|
82
85
|
return raw
|
|
83
86
|
}
|
|
84
87
|
|
|
@@ -213,8 +216,7 @@ export class Gateway {
|
|
|
213
216
|
}
|
|
214
217
|
this.dshSecret = await readDshSecret(this.ctx)
|
|
215
218
|
if (!this.dshSecret) {
|
|
216
|
-
this.log('warn', '无法读取 DSH
|
|
217
|
-
this.scheduleDshSecretRetry()
|
|
219
|
+
this.log('warn', '无法读取 DSH 浏览器会话签名密钥;启动后会在后台重试(网关自身仍可登录)')
|
|
218
220
|
}
|
|
219
221
|
const handler = (req: IncomingMessage, res: ServerResponse): void => {
|
|
220
222
|
void this.handleRequest(req, res).catch((err: unknown) => {
|
|
@@ -256,6 +258,9 @@ export class Gateway {
|
|
|
256
258
|
server.listen(config.port, config.bindHost, () => {
|
|
257
259
|
server.off('error', onError)
|
|
258
260
|
this.started = true
|
|
261
|
+
// 监听成功(started=true)后才安排密钥重试:此前的 scheduleDshSecretRetry
|
|
262
|
+
// 会因守卫 !this.started 直接返回,导致重试从不发生。
|
|
263
|
+
if (!this.dshSecret) this.scheduleDshSecretRetry()
|
|
259
264
|
this.log('info', `gateway listening on ${this.secure ? 'https' : 'http'}://${formatHost(config.bindHost)}:${config.port}`)
|
|
260
265
|
resolve()
|
|
261
266
|
})
|
|
@@ -322,7 +327,7 @@ export class Gateway {
|
|
|
322
327
|
dshOrigin: origin,
|
|
323
328
|
dshAccessUrl,
|
|
324
329
|
interfaces,
|
|
325
|
-
stateDir: this.store.statePath
|
|
330
|
+
stateDir: dirname(this.store.statePath),
|
|
326
331
|
statePath: this.store.statePath,
|
|
327
332
|
error: this.error,
|
|
328
333
|
https: tlsStatus(config, this.ctx.webServer.host),
|
|
@@ -368,11 +373,23 @@ export class Gateway {
|
|
|
368
373
|
// 认证
|
|
369
374
|
// -------------------------------------------------------------------------
|
|
370
375
|
|
|
376
|
+
/**
|
|
377
|
+
* 请求是否携带有效的网关会话。
|
|
378
|
+
*
|
|
379
|
+
* 必须遍历**所有**同名 cookie,不能只看第一个:浏览器在 path / 域重叠、或
|
|
380
|
+
* 同一个地址先后被不同版本写入过时,Cookie 头里会出现多个同名项,顺序由浏览器
|
|
381
|
+
* 决定(通常按创建时间或 path 长度)。旧实现取 `find(...)` 的第一个,一旦过期
|
|
382
|
+
* 的旧 cookie 排在前面就永久认证失败 —— 表现为「密码正确、登录页刷新一下又回
|
|
383
|
+
* 到登录页,且不报密码错误」,用户无法自愈(除非手动清 cookie)。
|
|
384
|
+
* 现在任何一个值验签通过即视为已认证。
|
|
385
|
+
*/
|
|
371
386
|
private isAuthenticated(req: IncomingMessage): boolean {
|
|
372
387
|
if (!this.store.passwordSet) return false
|
|
373
|
-
const
|
|
374
|
-
|
|
375
|
-
|
|
388
|
+
for (const [name, value] of parseCookies(req.headers.cookie)) {
|
|
389
|
+
if (name !== SESSION_COOKIE) continue
|
|
390
|
+
if (verifySession(this.store.gateSecret, value) !== null) return true
|
|
391
|
+
}
|
|
392
|
+
return false
|
|
376
393
|
}
|
|
377
394
|
|
|
378
395
|
private isSameOrigin(req: IncomingMessage): boolean {
|
package/src/state.ts
CHANGED
|
@@ -278,7 +278,15 @@ export class StateStore {
|
|
|
278
278
|
updatedAt: nowIso(),
|
|
279
279
|
}
|
|
280
280
|
} catch {
|
|
281
|
-
|
|
281
|
+
// 解析失败不能静默清空:先把损坏文件改名备份,再回落默认状态,避免用户
|
|
282
|
+
// 的密码哈希 / VAPID / 推送订阅被无提示地覆盖丢失。
|
|
283
|
+
try {
|
|
284
|
+
const backup = `${this.statePath}.corrupt-${Date.now()}`
|
|
285
|
+
renameSync(this.statePath, backup)
|
|
286
|
+
console.warn(`[dsh-enhancement] state.json 无法解析,已备份到 ${backup} 并以默认配置继续`)
|
|
287
|
+
} catch {
|
|
288
|
+
/* 备份失败也不阻塞启动 */
|
|
289
|
+
}
|
|
282
290
|
}
|
|
283
291
|
}
|
|
284
292
|
const config = defaultConfig(this.webServerHost)
|
|
@@ -404,7 +412,7 @@ export function verifySession(secret: string, value: string | undefined): Sessio
|
|
|
404
412
|
if (actual.length !== expected.length || !timingSafeEqual(actual, expected)) return null
|
|
405
413
|
try {
|
|
406
414
|
const payload = JSON.parse(Buffer.from(body, 'base64url').toString('utf8')) as SessionPayload
|
|
407
|
-
if (payload.v !== 1 || typeof payload.
|
|
415
|
+
if (payload.v !== 1 || typeof payload.sid !== 'string' || typeof payload.iat !== 'number' || typeof payload.exp !== 'number') return null
|
|
408
416
|
if (payload.exp <= Date.now()) return null
|
|
409
417
|
return payload
|
|
410
418
|
} catch {
|
|
@@ -424,8 +432,19 @@ export function sessionCookie(value: string, days: number, secure: boolean): str
|
|
|
424
432
|
return attrs.join('; ')
|
|
425
433
|
}
|
|
426
434
|
|
|
427
|
-
|
|
428
|
-
|
|
435
|
+
/**
|
|
436
|
+
* 清空会话 cookie 的 Set-Cookie 头(返回**多条**)。
|
|
437
|
+
*
|
|
438
|
+
* 浏览器按 (name, domain, path) 三元组存 cookie:同一个名字挂在不同 path 下就是
|
|
439
|
+
* 不同条目,每个都会出现在同一条 Cookie 请求头里。历史版本或人工改动可能留下
|
|
440
|
+
* `/__enhancement` 等其它 path 的残值,只清 `Path=/` 清不掉它们;这些验签失败的
|
|
441
|
+
* 死值会让请求携带多份同名 cookie(见 Gateway#isAuthenticated 的注释)。
|
|
442
|
+
*/
|
|
443
|
+
export function clearSessionCookie(secure: boolean): string[] {
|
|
444
|
+
const tail = `Max-Age=0; HttpOnly; SameSite=Strict${secure ? '; Secure' : ''}`
|
|
445
|
+
return ['/', '/__enhancement', '/__enhancement/login'].map(
|
|
446
|
+
(path) => `${SESSION_COOKIE}=; Path=${path}; ${tail}`,
|
|
447
|
+
)
|
|
429
448
|
}
|
|
430
449
|
|
|
431
450
|
// ---------------------------------------------------------------------------
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
// 网关会话认证的回归测试。
|
|
2
|
+
//
|
|
3
|
+
// 覆盖两个真实缺陷(与任何 PWA / 样式改动无关,属独立 bug 修复):
|
|
4
|
+
//
|
|
5
|
+
// 1. **同名 cookie 只读第一个**:浏览器按 (name, domain, path) 存 cookie,重复项
|
|
6
|
+
// 顺序由浏览器决定。旧实现用 `find()` 取第一个,一旦过期的旧值排在前面就永久
|
|
7
|
+
// 认证失败 —— 表现是「密码正确、刷新又回登录页,且不报密码错误」,用户无法
|
|
8
|
+
// 自愈(除非手动清 cookie)。
|
|
9
|
+
//
|
|
10
|
+
// 2. **清除 cookie 只清 Path=/**:`/__enhancement` 等其它 path 上的残值清不掉,
|
|
11
|
+
// 它们会一直跟着每个请求,让上面的问题复现。
|
|
12
|
+
//
|
|
13
|
+
// 运行:node --test test/gateway-auth.test.mjs
|
|
14
|
+
import { test } from 'node:test'
|
|
15
|
+
import assert from 'node:assert/strict'
|
|
16
|
+
import {
|
|
17
|
+
clearSessionCookie,
|
|
18
|
+
sessionCookie,
|
|
19
|
+
signSession,
|
|
20
|
+
verifySession,
|
|
21
|
+
SESSION_COOKIE,
|
|
22
|
+
} from '../lib/state.js'
|
|
23
|
+
|
|
24
|
+
const SECRET = Buffer.from('test-gate-secret-0123456789abcdef').toString('base64url')
|
|
25
|
+
|
|
26
|
+
/** 复刻网关的 cookie 解析:保序、保留重复项。 */
|
|
27
|
+
function parseCookies(header) {
|
|
28
|
+
if (!header) return []
|
|
29
|
+
const out = []
|
|
30
|
+
for (const segment of header.split(';')) {
|
|
31
|
+
const at = segment.indexOf('=')
|
|
32
|
+
if (at === -1) continue
|
|
33
|
+
const name = segment.slice(0, at).trim()
|
|
34
|
+
if (!name) continue
|
|
35
|
+
out.push([name, segment.slice(at + 1).trim()])
|
|
36
|
+
}
|
|
37
|
+
return out
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** 修复后的判定(与 Gateway#isAuthenticated 一致):任一同名项验签通过即认证。 */
|
|
41
|
+
function isAuthenticated(header) {
|
|
42
|
+
for (const [name, value] of parseCookies(header)) {
|
|
43
|
+
if (name !== SESSION_COOKIE) continue
|
|
44
|
+
if (verifySession(SECRET, value) !== null) return true
|
|
45
|
+
}
|
|
46
|
+
return false
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** 修复前的行为:只看第一个同名项。仅用于证明用例抓得住回归。 */
|
|
50
|
+
function isAuthenticatedLegacy(header) {
|
|
51
|
+
const raw = parseCookies(header).find(([name]) => name === SESSION_COOKIE)?.[1]
|
|
52
|
+
return verifySession(SECRET, raw) !== null
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// ---------------------------------------------------------------------------
|
|
56
|
+
// 1. 核心回归:同名 cookie 的顺序不能决定认证结果
|
|
57
|
+
// ---------------------------------------------------------------------------
|
|
58
|
+
|
|
59
|
+
test('Cookie 头里有多个同名项时,任意一个有效即认证通过', () => {
|
|
60
|
+
const valid = signSession(SECRET, 30)
|
|
61
|
+
const bogus = `${SESSION_COOKIE}=v1.bogus.sig`
|
|
62
|
+
const good = `${SESSION_COOKIE}=${valid}`
|
|
63
|
+
|
|
64
|
+
for (const header of [`${bogus}; ${good}`, `${good}; ${bogus}`, good]) {
|
|
65
|
+
assert.equal(
|
|
66
|
+
isAuthenticated(header),
|
|
67
|
+
true,
|
|
68
|
+
`应通过:${header.replace(valid, '<valid>')}`,
|
|
69
|
+
)
|
|
70
|
+
}
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
test('回归护栏:旧实现(只取第一个同名 cookie)在坏值排前时失败', () => {
|
|
74
|
+
const valid = signSession(SECRET, 30)
|
|
75
|
+
const staleFirst = `${SESSION_COOKIE}=v1.bogus.sig; ${SESSION_COOKIE}=${valid}`
|
|
76
|
+
|
|
77
|
+
// 这条断言是「测试有效性」的证明:它红了说明回归被引入。
|
|
78
|
+
assert.equal(isAuthenticatedLegacy(staleFirst), false, '旧实现应失败(证明用例有效)')
|
|
79
|
+
assert.equal(isAuthenticated(staleFirst), true, '新实现应通过')
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
test('全部无效时仍然拒绝(遍历不能放宽认证)', () => {
|
|
83
|
+
for (const header of [
|
|
84
|
+
`${SESSION_COOKIE}=v1.bogus.sig`,
|
|
85
|
+
`${SESSION_COOKIE}=v1.bogus.sig; ${SESSION_COOKIE}=v1.other.sig`,
|
|
86
|
+
`${SESSION_COOKIE}=`,
|
|
87
|
+
'',
|
|
88
|
+
'other=1',
|
|
89
|
+
]) {
|
|
90
|
+
assert.equal(isAuthenticated(header), false, `应拒绝:${JSON.stringify(header)}`)
|
|
91
|
+
}
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
test('过期会话即使验签合法也拒绝', () => {
|
|
95
|
+
assert.equal(isAuthenticated(`${SESSION_COOKIE}=${signSession(SECRET, -1)}`), false)
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
test('换一把密钥签出的会话不再有效(改密码后旧会话失效的机制)', () => {
|
|
99
|
+
const other = Buffer.from('another-gate-secret-value').toString('base64url')
|
|
100
|
+
assert.equal(isAuthenticated(`${SESSION_COOKIE}=${signSession(other, 30)}`), false)
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
// ---------------------------------------------------------------------------
|
|
104
|
+
// 2. 清除 cookie 必须覆盖多个 path(否则残值永远清不掉)
|
|
105
|
+
// ---------------------------------------------------------------------------
|
|
106
|
+
|
|
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/)
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
// ---------------------------------------------------------------------------
|
|
129
|
+
// 3. 下发的新会话 cookie 属性
|
|
130
|
+
// ---------------------------------------------------------------------------
|
|
131
|
+
|
|
132
|
+
test('登录成功后下发的会话 cookie 属性正确', () => {
|
|
133
|
+
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 秒')
|
|
137
|
+
assert.match(header, /Path=\//)
|
|
138
|
+
assert.match(header, /HttpOnly/)
|
|
139
|
+
assert.match(header, /SameSite=Strict/)
|
|
140
|
+
assert.doesNotMatch(header, /; Secure/)
|
|
141
|
+
assert.match(sessionCookie(token, 30, true), /; Secure/)
|
|
142
|
+
})
|
|
143
|
+
|
|
144
|
+
test('sessionDays 非正数时 Max-Age 仍为正(不退化成会话级 cookie)', () => {
|
|
145
|
+
const token = signSession(SECRET, 1)
|
|
146
|
+
for (const days of [0, -5]) {
|
|
147
|
+
assert.match(sessionCookie(token, days, false), /Max-Age=1(;|$)/)
|
|
148
|
+
}
|
|
149
|
+
})
|