Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions lib/proxy.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -841,6 +841,15 @@ export function createPocketProxy({ port = 3081, host = '0.0.0.0', upstream = DE
}
}
const headers = loopbackAuthority({ ...req.headers }, upstream);
// HTML 导航请求向上游要**未压缩**文档(issue #150)。
// 浏览器导航必然带 Accept-Encoding,dsh web 于是 gzip 压缩 HTML,下面「只给未压缩
// HTML 注入」的分支因此永不命中 —— data-dsh-pocket-polyfill(randomUUID /
// AbortSignal.any)与 data-dsh-pocket-transport-shim 对真实浏览器全是死代码。
// 后果:缺 AbortSignal.any 的内核(Android WebView / 厂商浏览器 < Chrome 116)
// 在连接层抛 TypeError,/api/remote.mux 建链即断,页面永久停在「重新连接中」。
// 上游若忽略 identity,isCompressed 分支照旧原样透传压缩流,无损坏风险;
// JS/CSS 等子资源的压缩透传不受影响(只对 HTML 导航生效)。
if (injectHtml && isHtmlRequest(req)) headers['accept-encoding'] = 'identity';
// dsh web 浏览器会话 token(issue #77):首屏根路径补一次,换回绑定 authority 的 cookie
const launchTok = (typeof launchToken === 'function' ? launchToken() : '') || '';
// 先清掉历史遗留的 dsh-desktop-* 参数(issue #75),再补 launch token
Expand Down
93 changes: 93 additions & 0 deletions test/proxy.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -393,9 +393,13 @@ test('会话指纹已移除(2.10.0):页面与登录页不再注入指纹/a
});

test('压缩 HTML(gzip)不注入 polyfill——防止损坏压缩流', async () => {
// 注意(issue #150):代理现在对 HTML 导航请求带 accept-encoding: identity,
// 正常 dsh web 会因此回未压缩文档。本用例覆盖的是**上游忽略 identity** 的兜底路径:
// 上游照旧回 gzip 时,代理必须原样透传、绝不注入,否则会损坏压缩流。
const zlib = await import('node:zlib');
const http = await import('node:http');
const up = createServer((req, res) => {
// 无条件 gzip:模拟不认 identity 的上游
res.writeHead(200, { 'content-type': 'text/html; charset=utf-8', 'content-encoding': 'gzip' });
res.end(zlib.gzipSync('<!doctype html><head></head><body>compressed-page</body>'));
});
Expand All @@ -422,6 +426,95 @@ test('压缩 HTML(gzip)不注入 polyfill——防止损坏压缩流', async
}
});

test('issue #150:HTML 导航请求向上游要 identity,注入对真实浏览器生效', async () => {
// 真实浏览器导航必然带 Accept-Encoding,dsh web 于是 gzip 压缩 HTML。
// 本用例的假上游**尊重 Accept-Encoding**(像 dsh web 一样),用于证明代理现在
// 会主动要 identity,从而拿到未压缩文档并完成注入。
const zlib = await import('node:zlib');
const http = await import('node:http');
const seenAcceptEncoding = [];
const up = createServer((req, res) => {
seenAcceptEncoding.push(req.headers['accept-encoding'] ?? '(none)');
const ae = String(req.headers['accept-encoding'] ?? '');
const html = Buffer.from('<!doctype html><head><title>x</title></head><body>app</body>');
if (/\bgzip\b/.test(ae)) {
res.writeHead(200, { 'content-type': 'text/html; charset=utf-8', 'content-encoding': 'gzip' });
res.end(zlib.gzipSync(html));
} else {
res.writeHead(200, { 'content-type': 'text/html; charset=utf-8', 'content-length': String(html.length) });
res.end(html);
}
});
await new Promise((r) => up.listen(0, '127.0.0.1', r));
const proxy = await createPocketProxy({ port: 0, host: '127.0.0.1', upstream: { host: '127.0.0.1', port: up.address().port } });
try {
// 原始 http.request:模拟真实浏览器导航(带 Accept-Encoding + 移动端 UA),
// 用原始字节断言,避免 undici 自动解压干扰。
const raw = await new Promise((resolve, reject) => {
const req = http.request({
host: '127.0.0.1', port: proxy.port, path: '/',
headers: {
accept: 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
'accept-encoding': 'gzip, deflate, br',
'user-agent': 'Mozilla/5.0 (Linux; Android 13) AppleWebKit/537.36 Chrome/115 Mobile Safari/537.36',
},
}, (res) => {
const chunks = [];
res.on('data', (c) => chunks.push(c));
res.on('end', () => resolve({ headers: res.headers, body: Buffer.concat(chunks) }));
});
req.on('error', reject);
req.end();
});

assert.equal(seenAcceptEncoding.at(-1), 'identity', 'HTML 导航向上游要 identity');
assert.equal(raw.headers['content-encoding'], undefined, '拿到未压缩文档,无 content-encoding');
const html = raw.body.toString('utf8');
assert.ok(html.includes('data-dsh-pocket-polyfill="1"'), 'polyfill 已注入真实浏览器导航');
assert.ok(html.includes('self.AbortSignal.any'), 'AbortSignal.any 兼容代码随文档下发');
assert.ok(html.includes('data-dsh-pocket-transport-shim="1"'), 'transport shim 已注入');
assert.ok(html.includes('app'), '原始页面内容完整');
} finally {
await proxy.close();
await new Promise((r) => up.close(r));
}
});

test('issue #150:上游忽略 identity 时,压缩流仍原样透传(不损坏)', async () => {
// 与上一条互补:上游不认 identity(照旧回 gzip)时,代理必须退回原有
// 「压缩流原样透传、不注入」的行为,绝不能破坏压缩字节。
const zlib = await import('node:zlib');
const http = await import('node:http');
const up = createServer((req, res) => {
// 无论 Accept-Encoding 是什么,一律返回 gzip(模拟忽略 identity 的上游)
res.writeHead(200, { 'content-type': 'text/html; charset=utf-8', 'content-encoding': 'gzip' });
res.end(zlib.gzipSync('<!doctype html><head></head><body>always-gzip</body>'));
});
await new Promise((r) => up.listen(0, '127.0.0.1', r));
const proxy = await createPocketProxy({ port: 0, host: '127.0.0.1', upstream: { host: '127.0.0.1', port: up.address().port } });
try {
const raw = await new Promise((resolve, reject) => {
const req = http.request({
host: '127.0.0.1', port: proxy.port, path: '/',
headers: { accept: 'text/html', 'accept-encoding': 'gzip, deflate, br' },
}, (res) => {
const chunks = [];
res.on('data', (c) => chunks.push(c));
res.on('end', () => resolve({ headers: res.headers, body: Buffer.concat(chunks) }));
});
req.on('error', reject);
req.end();
});
assert.equal(raw.headers['content-encoding'], 'gzip', '压缩头原样透传');
assert.ok(raw.body[0] === 0x1f && raw.body[1] === 0x8b, 'gzip 魔数未被破坏');
assert.ok(!raw.body.toString('utf8').includes('randomUUID'), '未对压缩流做文本注入');
assert.ok(zlib.gunzipSync(raw.body).toString('utf8').includes('always-gzip'), '解压后内容完整');
} finally {
await proxy.close();
await new Promise((r) => up.close(r));
}
});

test('活动 WS 连接存在时 close 不挂起(closeAllConnections)', async () => {
const up = await fakeUpstream();
const proxy = await createPocketProxy({ port: 0, host: '127.0.0.1', upstream: { host: '127.0.0.1', port: up.port } });
Expand Down