dsh-enhancement 1.1.0 → 1.1.1
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/lib/gateway.d.ts +10 -0
- package/lib/gateway.js +17 -3
- package/lib/state.d.ts +9 -1
- package/lib/state.js +10 -1
- package/package.json +8 -8
- package/src/gateway.ts +15 -3
- package/src/state.ts +13 -2
- package/test/gateway-auth.test.mjs +149 -0
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
|
@@ -343,12 +343,26 @@ export class Gateway {
|
|
|
343
343
|
// -------------------------------------------------------------------------
|
|
344
344
|
// 认证
|
|
345
345
|
// -------------------------------------------------------------------------
|
|
346
|
+
/**
|
|
347
|
+
* 请求是否携带有效的网关会话。
|
|
348
|
+
*
|
|
349
|
+
* 必须遍历**所有**同名 cookie,不能只看第一个:浏览器在 path / 域重叠、或
|
|
350
|
+
* 同一个地址先后被不同版本写入过时,Cookie 头里会出现多个同名项,顺序由浏览器
|
|
351
|
+
* 决定(通常按创建时间或 path 长度)。旧实现取 `find(...)` 的第一个,一旦过期
|
|
352
|
+
* 的旧 cookie 排在前面就永久认证失败 —— 表现为「密码正确、登录页刷新一下又回
|
|
353
|
+
* 到登录页,且不报密码错误」,用户无法自愈(除非手动清 cookie)。
|
|
354
|
+
* 现在任何一个值验签通过即视为已认证。
|
|
355
|
+
*/
|
|
346
356
|
isAuthenticated(req) {
|
|
347
357
|
if (!this.store.passwordSet)
|
|
348
358
|
return false;
|
|
349
|
-
const
|
|
350
|
-
|
|
351
|
-
|
|
359
|
+
for (const [name, value] of parseCookies(req.headers.cookie)) {
|
|
360
|
+
if (name !== SESSION_COOKIE)
|
|
361
|
+
continue;
|
|
362
|
+
if (verifySession(this.store.gateSecret, value) !== null)
|
|
363
|
+
return true;
|
|
364
|
+
}
|
|
365
|
+
return false;
|
|
352
366
|
}
|
|
353
367
|
isSameOrigin(req) {
|
|
354
368
|
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
|
@@ -323,8 +323,17 @@ export function sessionCookie(value, days, secure) {
|
|
|
323
323
|
attrs.push('Secure');
|
|
324
324
|
return attrs.join('; ');
|
|
325
325
|
}
|
|
326
|
+
/**
|
|
327
|
+
* 清空会话 cookie 的 Set-Cookie 头(返回**多条**)。
|
|
328
|
+
*
|
|
329
|
+
* 浏览器按 (name, domain, path) 三元组存 cookie:同一个名字挂在不同 path 下就是
|
|
330
|
+
* 不同条目,每个都会出现在同一条 Cookie 请求头里。历史版本或人工改动可能留下
|
|
331
|
+
* `/__enhancement` 等其它 path 的残值,只清 `Path=/` 清不掉它们;这些验签失败的
|
|
332
|
+
* 死值会让请求携带多份同名 cookie(见 Gateway#isAuthenticated 的注释)。
|
|
333
|
+
*/
|
|
326
334
|
export function clearSessionCookie(secure) {
|
|
327
|
-
|
|
335
|
+
const tail = `Max-Age=0; HttpOnly; SameSite=Strict${secure ? '; Secure' : ''}`;
|
|
336
|
+
return ['/', '/__enhancement', '/__enhancement/login'].map((path) => `${SESSION_COOKIE}=; Path=${path}; ${tail}`);
|
|
328
337
|
}
|
|
329
338
|
// ---------------------------------------------------------------------------
|
|
330
339
|
// 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.1",
|
|
4
4
|
"description": "DSH 增强套件:远程安全访问网关(密码/HTTPS)、PWA 安装与图标、桌面通知与 Web Push、会话请求头、设置页。",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./lib/index.js",
|
|
@@ -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/gateway.ts
CHANGED
|
@@ -368,11 +368,23 @@ export class Gateway {
|
|
|
368
368
|
// 认证
|
|
369
369
|
// -------------------------------------------------------------------------
|
|
370
370
|
|
|
371
|
+
/**
|
|
372
|
+
* 请求是否携带有效的网关会话。
|
|
373
|
+
*
|
|
374
|
+
* 必须遍历**所有**同名 cookie,不能只看第一个:浏览器在 path / 域重叠、或
|
|
375
|
+
* 同一个地址先后被不同版本写入过时,Cookie 头里会出现多个同名项,顺序由浏览器
|
|
376
|
+
* 决定(通常按创建时间或 path 长度)。旧实现取 `find(...)` 的第一个,一旦过期
|
|
377
|
+
* 的旧 cookie 排在前面就永久认证失败 —— 表现为「密码正确、登录页刷新一下又回
|
|
378
|
+
* 到登录页,且不报密码错误」,用户无法自愈(除非手动清 cookie)。
|
|
379
|
+
* 现在任何一个值验签通过即视为已认证。
|
|
380
|
+
*/
|
|
371
381
|
private isAuthenticated(req: IncomingMessage): boolean {
|
|
372
382
|
if (!this.store.passwordSet) return false
|
|
373
|
-
const
|
|
374
|
-
|
|
375
|
-
|
|
383
|
+
for (const [name, value] of parseCookies(req.headers.cookie)) {
|
|
384
|
+
if (name !== SESSION_COOKIE) continue
|
|
385
|
+
if (verifySession(this.store.gateSecret, value) !== null) return true
|
|
386
|
+
}
|
|
387
|
+
return false
|
|
376
388
|
}
|
|
377
389
|
|
|
378
390
|
private isSameOrigin(req: IncomingMessage): boolean {
|
package/src/state.ts
CHANGED
|
@@ -424,8 +424,19 @@ export function sessionCookie(value: string, days: number, secure: boolean): str
|
|
|
424
424
|
return attrs.join('; ')
|
|
425
425
|
}
|
|
426
426
|
|
|
427
|
-
|
|
428
|
-
|
|
427
|
+
/**
|
|
428
|
+
* 清空会话 cookie 的 Set-Cookie 头(返回**多条**)。
|
|
429
|
+
*
|
|
430
|
+
* 浏览器按 (name, domain, path) 三元组存 cookie:同一个名字挂在不同 path 下就是
|
|
431
|
+
* 不同条目,每个都会出现在同一条 Cookie 请求头里。历史版本或人工改动可能留下
|
|
432
|
+
* `/__enhancement` 等其它 path 的残值,只清 `Path=/` 清不掉它们;这些验签失败的
|
|
433
|
+
* 死值会让请求携带多份同名 cookie(见 Gateway#isAuthenticated 的注释)。
|
|
434
|
+
*/
|
|
435
|
+
export function clearSessionCookie(secure: boolean): string[] {
|
|
436
|
+
const tail = `Max-Age=0; HttpOnly; SameSite=Strict${secure ? '; Secure' : ''}`
|
|
437
|
+
return ['/', '/__enhancement', '/__enhancement/login'].map(
|
|
438
|
+
(path) => `${SESSION_COOKIE}=; Path=${path}; ${tail}`,
|
|
439
|
+
)
|
|
429
440
|
}
|
|
430
441
|
|
|
431
442
|
// ---------------------------------------------------------------------------
|
|
@@ -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
|
+
})
|