From dcca7ea65de2021bf02ba6adf6abcb3e99017820 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=BB=84=E9=87=91=E5=9D=A4?= Date: Wed, 2 Sep 2026 11:28:00 +0800 Subject: [PATCH 1/4] Require secure transport for Zhihu upstream --- services/zhihu__open-api/config.schema.json | 13 +++--- .../zhihu__open-api/src/zhihu-open-api.js | 40 ++++++++++--------- .../test/zhihu-open-api.test.js | 17 ++++---- 3 files changed, 39 insertions(+), 31 deletions(-) diff --git a/services/zhihu__open-api/config.schema.json b/services/zhihu__open-api/config.schema.json index 5d9623b3..27ac92a5 100644 --- a/services/zhihu__open-api/config.schema.json +++ b/services/zhihu__open-api/config.schema.json @@ -6,6 +6,7 @@ "baseUrl": { "type": "string", "format": "uri", + "pattern": "^https://", "default": "https://developer.zhihu.com", "description": "Zhihu Open Platform root URL." }, @@ -28,19 +29,19 @@ "description": "Optional additional HTTP headers. Core auth, JSON, and trace headers are set by the service." }, "skipTlsVerify": { - "type": "boolean", + "const": false, "default": false, - "description": "Skip TLS certificate verification for private testing." + "description": "TLS certificate verification is mandatory." }, "tlsInsecureSkipVerify": { - "type": "boolean", + "const": false, "default": false, - "description": "Legacy alias for skipTlsVerify." + "description": "Legacy alias; TLS certificate verification is mandatory." }, "insecureSkipVerify": { - "type": "boolean", + "const": false, "default": false, - "description": "Legacy alias for skipTlsVerify." + "description": "Legacy alias; TLS certificate verification is mandatory." } } } diff --git a/services/zhihu__open-api/src/zhihu-open-api.js b/services/zhihu__open-api/src/zhihu-open-api.js index 8af3d58d..44040422 100644 --- a/services/zhihu__open-api/src/zhihu-open-api.js +++ b/services/zhihu__open-api/src/zhihu-open-api.js @@ -261,8 +261,8 @@ const normalizeBaseUrl = (value) => { } catch { throw errorWithCode('INVALID_ARGUMENT', 'baseUrl must be a valid URL'); } - if (url.protocol !== 'https:' && url.protocol !== 'http:') { - throw errorWithCode('INVALID_ARGUMENT', 'baseUrl must use http or https'); + if (url.protocol !== 'https:') { + throw errorWithCode('INVALID_ARGUMENT', 'baseUrl must use https'); } url.pathname = url.pathname.replace(/\/+$/, ''); url.search = ''; @@ -284,28 +284,32 @@ const resolveCallContext = (ctx = {}) => ({ req: ctx.req ?? ctx.request ?? {}, }); -const resolveSettings = (ctx = {}) => ({ - baseUrl: normalizeBaseUrl(ctx.config?.baseUrl ?? ctx.bindings?.baseUrl), - timeoutMs: normalizeTimeoutMs( - firstDefined(ctx.config?.timeoutMs, ctx.config?.timeout_ms, ctx.bindings?.timeoutMs, ctx.limits?.timeoutMs), - DEFAULT_TIMEOUT_MS, - ), - headers: (ctx.config?.headers ?? ctx.bindings?.headers) ?? {}, - dispatcher: firstDefined( +const resolveSettings = (ctx = {}) => { + const tlsInsecure = firstDefined( ctx.config?.skipTlsVerify, ctx.config?.tlsInsecureSkipVerify, ctx.config?.insecureSkipVerify, ctx.bindings?.skipTlsVerify, ctx.bindings?.tlsInsecureSkipVerify, ctx.bindings?.insecureSkipVerify, - ) === true - ? insecureTlsDispatcher - : undefined, - accessSecret: requiredString(ctx.secret?.accessSecret ?? ctx.secret?.access_secret, 'accessSecret'), - oauthToken: asString(ctx.secret?.oauthToken ?? ctx.secret?.oauth_token), - fetchImpl: ctx.fetchImpl ?? globalThis.fetch, - meta: ctx.meta ?? {}, -}); + ) === true; + if (tlsInsecure) { + throw errorWithCode('INVALID_ARGUMENT', 'TLS certificate verification cannot be disabled'); + } + return { + baseUrl: normalizeBaseUrl(ctx.config?.baseUrl ?? ctx.bindings?.baseUrl), + timeoutMs: normalizeTimeoutMs( + firstDefined(ctx.config?.timeoutMs, ctx.config?.timeout_ms, ctx.bindings?.timeoutMs, ctx.limits?.timeoutMs), + DEFAULT_TIMEOUT_MS, + ), + headers: (ctx.config?.headers ?? ctx.bindings?.headers) ?? {}, + dispatcher: undefined, + accessSecret: requiredString(ctx.secret?.accessSecret ?? ctx.secret?.access_secret, 'accessSecret'), + oauthToken: asString(ctx.secret?.oauthToken ?? ctx.secret?.oauth_token), + fetchImpl: ctx.fetchImpl ?? globalThis.fetch, + meta: ctx.meta ?? {}, + }; +}; const resolveOauthToken = (settings, request = {}) => ( asString(request.oauth_token ?? request.oauthToken) || settings.oauthToken diff --git a/services/zhihu__open-api/test/zhihu-open-api.test.js b/services/zhihu__open-api/test/zhihu-open-api.test.js index 574d234d..6ab2d2e8 100644 --- a/services/zhihu__open-api/test/zhihu-open-api.test.js +++ b/services/zhihu__open-api/test/zhihu-open-api.test.js @@ -53,8 +53,8 @@ test('requires an Access Secret before any request is issued', async () => { (error) => error.code === grpcStatus.INVALID_ARGUMENT && /accessSecret is required/.test(error.message), ); assert.throws( - () => _test.resolveSettings({ config: { baseUrl: 'ftp://example' }, secret: { accessSecret: 'a' } }), - /baseUrl must use http or https/, + () => _test.resolveSettings({ config: { baseUrl: 'http://example' }, secret: { accessSecret: 'a' } }), + /baseUrl must use https/, ); assert.throws( () => _test.normalizeBaseUrl('not a url'), @@ -492,7 +492,7 @@ test('parseResponse maps non-OK HTTP with and without a Message field', async () assert.deepEqual(ok, { data: { a: 1 } }); }); -test('respects config timeouts, custom headers, TLS flags, and legacy aliases', async () => { +test('respects config timeouts, custom headers, and legacy aliases', async () => { let captured; globalThis.fetch = async (url, init) => { captured = { url, init }; @@ -500,23 +500,26 @@ test('respects config timeouts, custom headers, TLS flags, and legacy aliases', }; const result = await handlers[METHODS.GET_HOT_LIST]({ config: { - baseUrl: 'http://localhost:18082', + baseUrl: 'https://localhost:18082', timeout_ms: 3100, headers: { 'X-Custom': 'value' }, - skipTlsVerify: true, }, secret: { access_secret: 'legacy-secret' }, meta: { instance_id: 'inst', request_id: 'req' }, request: { limit: 2 }, }); assert.deepEqual(result.data, {}); - assert.equal(captured.url, 'http://localhost:18082/api/v1/content/hot_list?Limit=2'); + assert.equal(captured.url, 'https://localhost:18082/api/v1/content/hot_list?Limit=2'); assert.equal(captured.init.headers['X-Custom'], 'value'); assert.equal(captured.init.headers['x-engine-instance'], 'inst'); assert.equal(captured.init.headers['x-request-id'], 'req'); assert.equal(captured.init.headers.Authorization, 'Bearer legacy-secret'); - assert.equal(captured.init.dispatcher, _test.insecureTlsDispatcher); + assert.equal(captured.init.dispatcher, undefined); assert.ok(captured.init.signal instanceof AbortSignal); + assert.throws( + () => _test.resolveSettings({ config: { baseUrl: 'https://example', skipTlsVerify: true }, secret: { accessSecret: 'a' } }), + /TLS certificate verification cannot be disabled/, + ); const settings = _test.resolveSettings({ config: { baseUrl: 'https://x', timeoutMs: 2000, headers: { a: 'b' } }, secret: { accessSecret: 's' }, From 814b4759a45e75b5a30f277778a33f11a2b71b8b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=BB=84=E9=87=91=E5=9D=A4?= Date: Wed, 2 Sep 2026 14:08:40 +0800 Subject: [PATCH 2/4] Allow loopback HTTP for local service smoke tests --- services/zhihu__open-api/config.schema.json | 2 +- services/zhihu__open-api/src/zhihu-open-api.js | 4 ++-- services/zhihu__open-api/test/zhihu-open-api.test.js | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/services/zhihu__open-api/config.schema.json b/services/zhihu__open-api/config.schema.json index 27ac92a5..c10c5583 100644 --- a/services/zhihu__open-api/config.schema.json +++ b/services/zhihu__open-api/config.schema.json @@ -6,7 +6,7 @@ "baseUrl": { "type": "string", "format": "uri", - "pattern": "^https://", + "pattern": "^(https://|http://(127\\.0\\.0\\.1|\\[::1\\])(?::|/))", "default": "https://developer.zhihu.com", "description": "Zhihu Open Platform root URL." }, diff --git a/services/zhihu__open-api/src/zhihu-open-api.js b/services/zhihu__open-api/src/zhihu-open-api.js index 44040422..2cb3f8cb 100644 --- a/services/zhihu__open-api/src/zhihu-open-api.js +++ b/services/zhihu__open-api/src/zhihu-open-api.js @@ -261,8 +261,8 @@ const normalizeBaseUrl = (value) => { } catch { throw errorWithCode('INVALID_ARGUMENT', 'baseUrl must be a valid URL'); } - if (url.protocol !== 'https:') { - throw errorWithCode('INVALID_ARGUMENT', 'baseUrl must use https'); + if (url.protocol !== 'https:' && !(url.protocol === 'http:' && (url.hostname === '127.0.0.1' || url.hostname === '[::1]'))) { + throw errorWithCode('INVALID_ARGUMENT', 'baseUrl must use https or loopback http'); } url.pathname = url.pathname.replace(/\/+$/, ''); url.search = ''; diff --git a/services/zhihu__open-api/test/zhihu-open-api.test.js b/services/zhihu__open-api/test/zhihu-open-api.test.js index 6ab2d2e8..705bbefd 100644 --- a/services/zhihu__open-api/test/zhihu-open-api.test.js +++ b/services/zhihu__open-api/test/zhihu-open-api.test.js @@ -54,7 +54,7 @@ test('requires an Access Secret before any request is issued', async () => { ); assert.throws( () => _test.resolveSettings({ config: { baseUrl: 'http://example' }, secret: { accessSecret: 'a' } }), - /baseUrl must use https/, + /baseUrl must use https or loopback http/, ); assert.throws( () => _test.normalizeBaseUrl('not a url'), From 480faf25bb83faf080fbbdff2ab5e336a18b62ea Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=BB=84=E9=87=91=E5=9D=A4?= Date: Thu, 3 Sep 2026 03:38:22 +0800 Subject: [PATCH 3/4] Keep Zhihu upstream transport strictly secure --- scripts/service-package-smoke.mjs | 26 ++++++++++++++----- services/zhihu__open-api/config.schema.json | 2 +- .../zhihu__open-api/src/zhihu-open-api.js | 12 ++++----- .../test/zhihu-open-api.test.js | 8 +++++- 4 files changed, 33 insertions(+), 15 deletions(-) diff --git a/scripts/service-package-smoke.mjs b/scripts/service-package-smoke.mjs index 3cd2b397..6a8d12e3 100755 --- a/scripts/service-package-smoke.mjs +++ b/scripts/service-package-smoke.mjs @@ -1,6 +1,6 @@ #!/usr/bin/env node -import { spawn } from "node:child_process"; -import http from "node:http"; +import { execFileSync, spawn } from "node:child_process"; +import https from "node:https"; import http2 from "node:http2"; import fs from "node:fs"; import net from "node:net"; @@ -31,7 +31,7 @@ const mock = await startMockUpstream(); const addr = `127.0.0.1:${await freePort()}`; const daemon = spawn(octobusBin, ["serve", "--addr", addr, "--data-dir", dataDir], { cwd: repoRoot, - env: { ...process.env, OCTOBUS_ADDR: addr, OCTOBUS_DATA_DIR: dataDir }, + env: { ...process.env, OCTOBUS_ADDR: addr, OCTOBUS_DATA_DIR: dataDir, NODE_TLS_REJECT_UNAUTHORIZED: "0" }, stdio: ["ignore", "pipe", "pipe"], }); @@ -280,7 +280,18 @@ function requiredValue(args, index, flag) { async function startMockUpstream() { let hitCount = 0; const requests = []; - const server = http.createServer((req, res) => { + const tlsDir = fs.mkdtempSync(path.join(os.tmpdir(), "octobus-smoke-tls.")); + const keyPath = path.join(tlsDir, "key.pem"); + const certPath = path.join(tlsDir, "cert.pem"); + execFileSync("openssl", [ + "req", "-x509", "-newkey", "rsa:2048", "-nodes", + "-keyout", keyPath, "-out", certPath, "-days", "1", + "-subj", "/CN=127.0.0.1", "-addext", "subjectAltName=IP:127.0.0.1", + ], { stdio: "ignore" }); + const server = https.createServer({ + key: fs.readFileSync(keyPath), + cert: fs.readFileSync(certPath), + }, (req, res) => { hitCount += 1; const chunks = []; req.on("data", (chunk) => chunks.push(chunk)); @@ -341,8 +352,11 @@ async function startMockUpstream() { get requests() { return requests; }, - baseURL: `http://127.0.0.1:${address.port}`, - close: () => new Promise((resolve) => server.close(resolve)), + baseURL: `https://127.0.0.1:${address.port}`, + close: () => new Promise((resolve) => server.close(() => { + fs.rmSync(tlsDir, { recursive: true, force: true }); + resolve(); + })), }; } diff --git a/services/zhihu__open-api/config.schema.json b/services/zhihu__open-api/config.schema.json index c10c5583..27ac92a5 100644 --- a/services/zhihu__open-api/config.schema.json +++ b/services/zhihu__open-api/config.schema.json @@ -6,7 +6,7 @@ "baseUrl": { "type": "string", "format": "uri", - "pattern": "^(https://|http://(127\\.0\\.0\\.1|\\[::1\\])(?::|/))", + "pattern": "^https://", "default": "https://developer.zhihu.com", "description": "Zhihu Open Platform root URL." }, diff --git a/services/zhihu__open-api/src/zhihu-open-api.js b/services/zhihu__open-api/src/zhihu-open-api.js index 2cb3f8cb..93def244 100644 --- a/services/zhihu__open-api/src/zhihu-open-api.js +++ b/services/zhihu__open-api/src/zhihu-open-api.js @@ -1,4 +1,4 @@ -import { GrpcError, createTlsDispatcher, grpcCodeFor, normalizeTimeoutMs } from '@chaitin-ai/octobus-sdk'; +import { GrpcError, grpcCodeFor, normalizeTimeoutMs } from '@chaitin-ai/octobus-sdk'; // --------------------------------------------------------------------------- // Method table @@ -26,7 +26,6 @@ export const METHODS = { // --------------------------------------------------------------------------- const DEFAULT_BASE_URL = 'https://developer.zhihu.com'; const DEFAULT_TIMEOUT_MS = 10_000; -const insecureTlsDispatcher = createTlsDispatcher(true); const SEARCH_DB_VALUES = ['all', 'realtime', 'static']; const SCOPE_VALUES = ['all', 'created', 'subscribed']; @@ -261,8 +260,8 @@ const normalizeBaseUrl = (value) => { } catch { throw errorWithCode('INVALID_ARGUMENT', 'baseUrl must be a valid URL'); } - if (url.protocol !== 'https:' && !(url.protocol === 'http:' && (url.hostname === '127.0.0.1' || url.hostname === '[::1]'))) { - throw errorWithCode('INVALID_ARGUMENT', 'baseUrl must use https or loopback http'); + if (url.protocol !== 'https:') { + throw errorWithCode('INVALID_ARGUMENT', 'baseUrl must use https'); } url.pathname = url.pathname.replace(/\/+$/, ''); url.search = ''; @@ -285,14 +284,14 @@ const resolveCallContext = (ctx = {}) => ({ }); const resolveSettings = (ctx = {}) => { - const tlsInsecure = firstDefined( + const tlsInsecure = [ ctx.config?.skipTlsVerify, ctx.config?.tlsInsecureSkipVerify, ctx.config?.insecureSkipVerify, ctx.bindings?.skipTlsVerify, ctx.bindings?.tlsInsecureSkipVerify, ctx.bindings?.insecureSkipVerify, - ) === true; + ].some((value) => value === true); if (tlsInsecure) { throw errorWithCode('INVALID_ARGUMENT', 'TLS certificate verification cannot be disabled'); } @@ -519,7 +518,6 @@ export const _test = { errorWithCode, firstDefined, hasOwn, - insecureTlsDispatcher, isTimeoutError, logInfo, mapErrorCode, diff --git a/services/zhihu__open-api/test/zhihu-open-api.test.js b/services/zhihu__open-api/test/zhihu-open-api.test.js index 705bbefd..827b8ee8 100644 --- a/services/zhihu__open-api/test/zhihu-open-api.test.js +++ b/services/zhihu__open-api/test/zhihu-open-api.test.js @@ -54,7 +54,7 @@ test('requires an Access Secret before any request is issued', async () => { ); assert.throws( () => _test.resolveSettings({ config: { baseUrl: 'http://example' }, secret: { accessSecret: 'a' } }), - /baseUrl must use https or loopback http/, + /baseUrl must use https/, ); assert.throws( () => _test.normalizeBaseUrl('not a url'), @@ -520,6 +520,12 @@ test('respects config timeouts, custom headers, and legacy aliases', async () => () => _test.resolveSettings({ config: { baseUrl: 'https://example', skipTlsVerify: true }, secret: { accessSecret: 'a' } }), /TLS certificate verification cannot be disabled/, ); + for (const alias of ['tlsInsecureSkipVerify', 'insecureSkipVerify']) { + assert.throws( + () => _test.resolveSettings({ config: { baseUrl: 'https://example', [alias]: true }, secret: { accessSecret: 'a' } }), + /TLS certificate verification cannot be disabled/, + ); + } const settings = _test.resolveSettings({ config: { baseUrl: 'https://x', timeoutMs: 2000, headers: { a: 'b' } }, secret: { accessSecret: 's' }, From 3a4fb5170171ac9951e47c56758c87f3756c9c3f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=BB=84=E9=87=91=E5=9D=A4?= Date: Thu, 3 Sep 2026 03:41:00 +0800 Subject: [PATCH 4/4] Trust generated smoke test certificate explicitly --- scripts/service-package-smoke.mjs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/scripts/service-package-smoke.mjs b/scripts/service-package-smoke.mjs index 6a8d12e3..7ae10f46 100755 --- a/scripts/service-package-smoke.mjs +++ b/scripts/service-package-smoke.mjs @@ -31,7 +31,7 @@ const mock = await startMockUpstream(); const addr = `127.0.0.1:${await freePort()}`; const daemon = spawn(octobusBin, ["serve", "--addr", addr, "--data-dir", dataDir], { cwd: repoRoot, - env: { ...process.env, OCTOBUS_ADDR: addr, OCTOBUS_DATA_DIR: dataDir, NODE_TLS_REJECT_UNAUTHORIZED: "0" }, + env: { ...process.env, OCTOBUS_ADDR: addr, OCTOBUS_DATA_DIR: dataDir, NODE_EXTRA_CA_CERTS: mock.caFile }, stdio: ["ignore", "pipe", "pipe"], }); @@ -353,6 +353,7 @@ async function startMockUpstream() { return requests; }, baseURL: `https://127.0.0.1:${address.port}`, + caFile: certPath, close: () => new Promise((resolve) => server.close(() => { fs.rmSync(tlsDir, { recursive: true, force: true }); resolve();