fix(weixin): distinguish loopback Harness denial

This commit is contained in:
xmanrui 2026-08-22 22:59:24 +08:00
parent 85aaecf1f2
commit dc10cb22c8
7 changed files with 126 additions and 103 deletions

File diff suppressed because one or more lines are too long

View file

@ -46,13 +46,23 @@ async function smallResponseText(response) {
return new TextDecoder().decode(bytes); return new TextDecoder().decode(bytes);
} }
async function harnessHttpErrorCode(response) { function isLoopbackHarnessHostname(hostname) {
if (hostname === 'localhost' || hostname === '[::1]') return true;
const parts = hostname.split('.');
return parts.length === 4
&& parts[0] === '127'
&& parts.every((part) => /^\d{1,3}$/.test(part) && Number(part) <= 255);
}
async function harnessHttpErrorCode(response, hostname) {
if (response.status === 401) return 'harness-auth-required'; if (response.status === 401) return 'harness-auth-required';
if (response.status === 407) return 'harness-proxy-auth-required';
if (response.status === 403) { if (response.status === 403) {
const body = await smallResponseText(response); const body = await smallResponseText(response);
return body?.trim() === 'forbidden' if (body?.trim() !== 'forbidden') return 'harness-request-forbidden';
? 'harness-host-untrusted' return isLoopbackHarnessHostname(hostname)
: 'harness-request-forbidden'; ? 'harness-loopback-forbidden'
: 'harness-host-untrusted';
} }
if (response.status === 404) return 'harness-api-not-found'; if (response.status === 404) return 'harness-api-not-found';
return 'harness-http-failed'; return 'harness-http-failed';
@ -543,7 +553,7 @@ export class HarnessClient {
); );
} }
if (!response.ok) { if (!response.ok) {
const code = await harnessHttpErrorCode(response); const code = await harnessHttpErrorCode(response, this.#baseUrl.hostname);
throw new HarnessTransportError(code, method, { status: response.status }); throw new HarnessTransportError(code, method, { status: response.status });
} }
let body; let body;

View file

@ -28,7 +28,9 @@ const ACTIVATION_ERROR_MESSAGES = Object.freeze({
'harness-connect-failed': '微信已授权,但插件无法连接本机 Harness。请检查 dsh web 地址和端口。', 'harness-connect-failed': '微信已授权,但插件无法连接本机 Harness。请检查 dsh web 地址和端口。',
'harness-timeout': '微信已授权,但 Harness 健康检查超时。请确认 dsh web 未阻塞。', 'harness-timeout': '微信已授权,但 Harness 健康检查超时。请确认 dsh web 未阻塞。',
'harness-auth-required': '微信已授权,但 Harness 健康检查需要身份认证。请检查代理、网关或自定义鉴权配置。', 'harness-auth-required': '微信已授权,但 Harness 健康检查需要身份认证。请检查代理、网关或自定义鉴权配置。',
'harness-host-untrusted': '微信已授权,但 Harness 的 Host 信任检查拒绝了本机请求。请检查 harnessBaseUrl 与 trustedHosts 配置。', 'harness-proxy-auth-required': '微信已授权,但本机 Harness 请求被代理要求认证。请让回环地址绕过代理,并检查 NO_PROXY 配置。',
'harness-loopback-forbidden': '微信已授权,但 Harness 异常拒绝了回环地址的健康检查。请检查 HTTP 代理、Harness 源码版本和构建产物。',
'harness-host-untrusted': '微信已授权,但 Harness 的 Host 信任检查拒绝了非回环地址请求。请检查 harnessBaseUrl 与 trustedHosts 配置。',
'harness-request-forbidden': '微信已授权,但健康检查收到了非 Harness 标准的 403 拒绝响应。请检查代理或网关配置。', 'harness-request-forbidden': '微信已授权,但健康检查收到了非 Harness 标准的 403 拒绝响应。请检查代理或网关配置。',
'harness-api-not-found': '微信已授权,但找不到 Harness 健康检查接口。请确认 Harness 与插件版本兼容。', 'harness-api-not-found': '微信已授权,但找不到 Harness 健康检查接口。请确认 Harness 与插件版本兼容。',
'harness-http-failed': '微信已授权,但 Harness 健康检查返回服务错误。请查看 dsh web 日志。', 'harness-http-failed': '微信已授权,但 Harness 健康检查返回服务错误。请查看 dsh web 日志。',

View file

@ -10,6 +10,8 @@ const HARNESS_HEALTH_ERROR_CODES = new Set([
'harness-connect-failed', 'harness-connect-failed',
'harness-timeout', 'harness-timeout',
'harness-auth-required', 'harness-auth-required',
'harness-proxy-auth-required',
'harness-loopback-forbidden',
'harness-host-untrusted', 'harness-host-untrusted',
'harness-request-forbidden', 'harness-request-forbidden',
'harness-api-not-found', 'harness-api-not-found',

View file

@ -50,8 +50,8 @@ test('all legacy channel clients now use the shared Harness RPC transport', asyn
}); });
test('shared Harness health checks expose precise safe availability codes', async () => { test('shared Harness health checks expose precise safe availability codes', async () => {
const clientWithFetch = (fetchImpl) => new HarnessClient({ const clientWithFetch = (fetchImpl, baseUrl = 'http://127.0.0.1:3080') => new HarnessClient({
baseUrl: 'http://127.0.0.1:3080', baseUrl,
workspace: '/tmp/default-workspace', workspace: '/tmp/default-workspace',
fetchImpl, fetchImpl,
}); });
@ -79,15 +79,20 @@ test('shared Harness health checks expose precise safe availability codes', asyn
return true; return true;
}); });
for (const [status, responseBody, expectedCode] of [ for (const [baseUrl, status, responseBody, expectedCode] of [
[401, 'authentication required', 'harness-auth-required'], ['http://127.0.0.1:3080', 401, 'authentication required', 'harness-auth-required'],
[403, 'forbidden', 'harness-host-untrusted'], ['http://127.0.0.1:3080', 407, 'proxy authentication required', 'harness-proxy-auth-required'],
[403, 'proxy policy rejected: private detail', 'harness-request-forbidden'], ['http://127.0.0.1:3080', 403, 'forbidden', 'harness-loopback-forbidden'],
[404, 'not found', 'harness-api-not-found'], ['http://127.9.8.7:3080', 403, 'forbidden\n', 'harness-loopback-forbidden'],
[500, 'service unavailable', 'harness-http-failed'], ['http://localhost:3080', 403, 'forbidden', 'harness-loopback-forbidden'],
['http://[::1]:3080', 403, 'forbidden', 'harness-loopback-forbidden'],
['http://harness.internal:3080', 403, 'forbidden', 'harness-host-untrusted'],
['http://127.0.0.1:3080', 403, 'proxy policy rejected: private detail', 'harness-request-forbidden'],
['http://127.0.0.1:3080', 404, 'not found', 'harness-api-not-found'],
['http://127.0.0.1:3080', 500, 'service unavailable', 'harness-http-failed'],
]) { ]) {
await assert.rejects( await assert.rejects(
clientWithFetch(async () => new Response(responseBody, { status })).health(), clientWithFetch(async () => new Response(responseBody, { status }), baseUrl).health(),
(error) => { (error) => {
assert.ok(error instanceof HarnessTransportError); assert.ok(error instanceof HarnessTransportError);
assert.equal(error.code, expectedCode); assert.equal(error.code, expectedCode);

View file

@ -349,6 +349,8 @@ test('account config write and runtime preparation failures have distinct safe c
test('known runtime activation codes cross the provisioning boundary unchanged', async () => { test('known runtime activation codes cross the provisioning boundary unchanged', async () => {
for (const scenario of [ for (const scenario of [
['harness-auth-required', /需要身份认证/], ['harness-auth-required', /需要身份认证/],
['harness-proxy-auth-required', /NO_PROXY/],
['harness-loopback-forbidden', /回环地址/],
['harness-host-untrusted', /Host 信任检查/], ['harness-host-untrusted', /Host 信任检查/],
['harness-request-forbidden', /代理或网关配置/], ['harness-request-forbidden', /代理或网关配置/],
['harness-api-not-found', /找不到 Harness 健康检查接口/], ['harness-api-not-found', /找不到 Harness 健康检查接口/],

View file

@ -338,6 +338,8 @@ test('runtime preserves classified Harness health codes without exposing their c
'harness-connect-failed', 'harness-connect-failed',
'harness-timeout', 'harness-timeout',
'harness-auth-required', 'harness-auth-required',
'harness-proxy-auth-required',
'harness-loopback-forbidden',
'harness-host-untrusted', 'harness-host-untrusted',
'harness-request-forbidden', 'harness-request-forbidden',
'harness-api-not-found', 'harness-api-not-found',