Skip to content

Commit dadd3b9

Browse files
DaYePythonclaude
andcommitted
fix(extension): recover the cookies chrome silently filtered out
chrome.cookies.getAll returned only PASSPORT: Chrome checks cookie access by building a URL from the cookie's Secure flag (secure -> https, plain -> http) and matching it against host_permissions, so lanhu's non-Secure user_token/session/SERVERID were dropped without an error. Declare the http scheme for lanhuapp.com as well, and document that it is load-bearing. Also merge the page's own document.cookie into the header (content script forwards it; ecosystem-core owns the merge, privileged source wins on a name clash), so a narrow privileged query can never make the extension emit less than the page itself would send. parseDocumentCookie moves from lanhu-monkey into ecosystem-core; both hosts now share it. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 9f39c77 commit dadd3b9

16 files changed

Lines changed: 151 additions & 20 deletions

File tree

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
'@lanhu-context/ecosystem-core': minor
3+
'@lanhu-context/lanhu-monkey': patch
4+
---
5+
6+
新增 `mergeCookies`,`collectCookieHeader` 接受可选的页面 Cookie 列表:特权 Cookie 源(`chrome.cookies` / `GM_cookie`)与页面 `document.cookie` 合并,同名以特权源为准。`parseDocumentCookie` 从 lanhu-monkey 迁入 core,两端共用同一实现。
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
'@lanhu-context/browser-extension': patch
3+
---
4+
5+
修复扩展只能复制到一条 Cookie(`PASSPORT`)导致 `lanhu auth test` 失败:`host_permissions` 补上 `http://*.lanhuapp.com/*`。Chrome 按 Cookie 的 `Secure` 标志拼 URL 再匹配 host_permissions(Secure→https,非 Secure→http),蓝湖的 `user_token`/`session`/`SERVERID` 均非 Secure,只声明 https 时会被静默过滤。
6+
7+
同时 content script 会把页面的 `document.cookie` 随消息交给 service worker,与 `chrome.cookies` 合并后再拼 Cookie 头,确保特权查询变窄时扩展也不会比页面本身能发出的 Cookie 更少。

‎ecosystem/browser-extension/CLAUDE.md‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,8 @@ pnpm vitest run ecosystem/browser-extension # 本包仅剩消息
3030
- **token 安全**(继承根 CLAUDE.md):整段 Cookie 等同账号凭据。测试与文档一律用 `sid=FAKE` 类占位符,绝不出现真实 Cookie;日志/toast 不回显 token 内容。
3131
- **crx 签名 key 同凭据级**:key 决定扩展 ID,换 key 等于换扩展。本地 `key.pem` 与一切 `*.pem`/`*.crx` 已被根 .gitignore 拦截,绝不入库;CI 从 repo secret `LANHU_EXT_CRX_KEY` 读取(缺失时 workflow 明确报错,不静默跳过)。manifest 版本号唯一事实源是 package.json(changesets 管理),`public/manifest.json` 里的 version 只是占位,构建时被 `scripts/build.ts` 覆写。
3232
- **manifest 权限最小化**:`cookies` + `clipboardWrite`,`host_permissions` 仅 `*.lanhuapp.com` 与 `127.0.0.1`;新增权限需先在 README「权限说明」给出理由。
33+
- **`host_permissions` 的 `http://*.lanhuapp.com/*` 不是冗余,删了会静默丢 Cookie**:Chrome 按 Cookie 的 `Secure` 标志拼 URL 再匹配 host_permissions(Secure→`https://`,非 Secure→`http://`)。蓝湖只有 `PASSPORT` 是 Secure,`user_token`/`session`/`SERVERID` 都不是——只留 https 时 `chrome.cookies.getAll` 只返回 `PASSPORT` 一条且不报错(2026-08 实测)。油猴无此问题是因为 Tampermonkey 自带全站权限。
34+
- **Cookie 采集是两个来源合并**:service worker 的 `chrome.cookies`(唯一能拿 HttpOnly 的来源)+ content script 随消息带上的 `document.cookie`(`BackgroundMessage.pageCookie`),在 core 的 `mergeCookies` 里同名以前者为准。保留页面这一路的意义:权限或分区导致特权查询变窄时,扩展至少不会比页面本身能发出的 Cookie 还少。
3335

3436
## 权威文档
3537

‎ecosystem/browser-extension/README.md‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,11 @@ lanhu auth test "<设计稿URL>"
6969
## 权限说明
7070

7171
- `permissions`: `cookies`(读取 lanhuapp.com Cookie,含 HttpOnly——这正是做成扩展而非油猴脚本的原因)、`clipboardWrite`
72-
- `host_permissions`: 仅 `https://*.lanhuapp.com/*` 与 `http://127.0.0.1/*`,不触达其他站点
72+
- `host_permissions`: 仅 `lanhuapp.com`(**http 与 https 两条都要**)与 `http://127.0.0.1/*`,不触达其他站点
73+
74+
### 为什么 lanhuapp.com 需要 http 与 https 两条
75+
76+
Chrome 判断扩展能否读一条 Cookie 时,按该 Cookie 的 `Secure` 标志拼 URL 再匹配 `host_permissions`:Secure 的按 `https://`,非 Secure 的按 **`http://`**。蓝湖的会话 Cookie 里只有 `PASSPORT` 是 Secure,`user_token` / `session` / `SERVERID` 都不是——只声明 https 时,`chrome.cookies` 会**静默**只返回 `PASSPORT` 一条,复制出来的 Cookie 不足以通过 `lanhu auth test`。删掉 http 那条即复现此问题。
7377

7478
## 安全提示
7579

‎ecosystem/browser-extension/public/manifest.json‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,11 @@
1111
"128": "icons/icon128.png"
1212
},
1313
"permissions": ["cookies", "clipboardWrite"],
14-
"host_permissions": ["https://*.lanhuapp.com/*", "http://127.0.0.1/*"],
14+
"host_permissions": [
15+
"https://*.lanhuapp.com/*",
16+
"http://*.lanhuapp.com/*",
17+
"http://127.0.0.1/*"
18+
],
1519
"background": {
1620
"service_worker": "background.js",
1721
"type": "module"

‎ecosystem/browser-extension/src/background/index.ts‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,17 @@
11
import {
22
collectCookieHeader,
33
DEFAULT_BRIDGE_PORT,
4+
parseDocumentCookie,
45
sendCookieHeader
56
} from '@lanhu-context/ecosystem-core';
67
import type { BackgroundMessage, BackgroundReply } from '../shared/protocol';
78

89
async function handle(message: BackgroundMessage): Promise<BackgroundReply> {
910
try {
10-
const token = await collectCookieHeader(chrome.cookies);
11+
const token = await collectCookieHeader(
12+
chrome.cookies,
13+
parseDocumentCookie(message.pageCookie ?? '')
14+
);
1115
if (message.type === 'copy-cookies') return { ok: true, token };
1216

1317
const result = await sendCookieHeader(fetch, DEFAULT_BRIDGE_PORT, token);

‎ecosystem/browser-extension/src/content/index.ts‎

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,17 +8,24 @@ import { ask } from './messaging';
88
// All user-facing behaviour (menu items, wording, toasts) lives in
99
// ecosystem-core; this file only adapts the extension's message channel. The
1010
// cookie work itself happens in the service worker — chrome.cookies is not
11-
// available to content scripts.
11+
// available to content scripts — but document.cookie is only readable here, so
12+
// it rides along and the worker merges both sources.
1213
const platform: MenuPlatform = {
1314
copyText,
1415
async readCookieHeader() {
15-
const reply = await ask({ type: 'copy-cookies' });
16+
const reply = await ask({
17+
type: 'copy-cookies',
18+
pageCookie: document.cookie
19+
});
1620
if (!reply.ok) return { ok: false, error: reply.error };
1721
if (!reply.token) return { ok: false, error: '返回为空' };
1822
return { ok: true, token: reply.token };
1923
},
2024
async sendCookieHeader() {
21-
const reply = await ask({ type: 'send-cookies' });
25+
const reply = await ask({
26+
type: 'send-cookies',
27+
pageCookie: document.cookie
28+
});
2229
return reply.ok ? { ok: true } : { ok: false, error: reply.error };
2330
}
2431
};

‎ecosystem/browser-extension/src/shared/protocol.ts‎

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,12 @@
1+
/**
2+
* `pageCookie` carries the content script's own `document.cookie`. The service
3+
* worker cannot read it (no DOM), and chrome.cookies has been observed coming
4+
* back narrower than the page's own jar, so the two are merged before the
5+
* header is built.
6+
*/
17
export type BackgroundMessage =
2-
| { type: 'copy-cookies' }
3-
| { type: 'send-cookies' };
8+
| { type: 'copy-cookies'; pageCookie?: string }
9+
| { type: 'send-cookies'; pageCookie?: string };
410

511
export type BackgroundReply =
612
| { ok: true; token?: string }

‎ecosystem/ecosystem-core/src/__tests__/bridge.spec.ts‎

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,27 @@ describe('collectCookieHeader', () => {
1717
const getAll = vi.fn().mockResolvedValue([]);
1818
await expect(collectCookieHeader({ getAll })).rejects.toThrow('NO_COOKIES');
1919
});
20+
21+
it('merges in page cookies the privileged query missed', async () => {
22+
// Observed in the wild: chrome.cookies returned only PASSPORT while the
23+
// page's own jar held the rest of the session.
24+
const getAll = vi
25+
.fn()
26+
.mockResolvedValue([{ name: 'PASSPORT', value: 'FAKE1', path: '/' }]);
27+
await expect(
28+
collectCookieHeader({ getAll }, [
29+
{ name: 'sid', value: 'FAKE2' },
30+
{ name: 'uid', value: 'FAKE3' }
31+
])
32+
).resolves.toBe('PASSPORT=FAKE1; sid=FAKE2; uid=FAKE3');
33+
});
34+
35+
it('still yields a header when only the page has cookies', async () => {
36+
const getAll = vi.fn().mockResolvedValue([]);
37+
await expect(
38+
collectCookieHeader({ getAll }, [{ name: 'sid', value: 'FAKE' }])
39+
).resolves.toBe('sid=FAKE');
40+
});
2041
});
2142

2243
describe('sendCookieHeader', () => {

‎ecosystem/ecosystem-core/src/__tests__/cookies.spec.ts‎

Lines changed: 40 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { describe, expect, it } from 'vitest';
2-
import { formatCookieHeader, sortCookies } from '../cookies';
2+
import { formatCookieHeader, mergeCookies, sortCookies } from '../cookies';
33

44
describe('formatCookieHeader', () => {
55
it('joins name=value pairs with "; "', () => {
@@ -68,3 +68,42 @@ describe('sortCookies', () => {
6868
expect(input[0]?.name).toBe('root');
6969
});
7070
});
71+
72+
describe('mergeCookies', () => {
73+
it('adds names only the page knows about', () => {
74+
// The regression this guards: a privileged query that comes back with one
75+
// entry must not shrink the header below what the page itself would send.
76+
const merged = mergeCookies(
77+
[{ name: 'PASSPORT', value: 'FAKE1', path: '/' }],
78+
[
79+
{ name: 'PASSPORT', value: 'STALE' },
80+
{ name: 'sid', value: 'FAKE2' }
81+
]
82+
);
83+
expect(merged).toEqual([
84+
{ name: 'PASSPORT', value: 'FAKE1', path: '/' },
85+
{ name: 'sid', value: 'FAKE2' }
86+
]);
87+
});
88+
89+
it('keeps the privileged value on a name clash', () => {
90+
// chrome.cookies/GM_cookie carry path and HttpOnly entries; the page copy
91+
// is the weaker record.
92+
const merged = mergeCookies(
93+
[{ name: 'sid', value: 'PRIVILEGED' }],
94+
[{ name: 'sid', value: 'FROM_PAGE' }]
95+
);
96+
expect(merged).toEqual([{ name: 'sid', value: 'PRIVILEGED' }]);
97+
});
98+
99+
it('falls back to the page list when the privileged one is empty', () => {
100+
expect(mergeCookies([], [{ name: 'sid', value: 'FAKE' }])).toEqual([
101+
{ name: 'sid', value: 'FAKE' }
102+
]);
103+
});
104+
105+
it('is a no-op without page cookies', () => {
106+
const privileged = [{ name: 'sid', value: 'FAKE', path: '/' }];
107+
expect(mergeCookies(privileged, [])).toEqual(privileged);
108+
});
109+
});

0 commit comments

Comments
 (0)