From de359461679e11f0f8b6c25dca3d5a0934b603fe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9D=8E=E5=86=A0=E8=BE=B0?= Date: Mon, 18 May 2026 11:52:37 +0800 Subject: [PATCH] fix(offload): enable TLS verification by default; add env opt-out + CA path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The offload backend client (src/offload/backend-client.ts) unconditionally set rejectUnauthorized: false on every HTTPS request, fully bypassing TLS certificate validation. Any MITM on the network path between the daemon and the configured https://... offload backend could read or tamper with: - L1/L1.5/L2/L4 tool-call summaries and full task history - the Authorization: Bearer header - the X-User-Id / X-Task-Id identity headers Backend responses (L1 summaries, L2 task graphs, L4 skills) are written to the user's data directory, so a tampered response is a memory-poisoning vector too. This change reverts the default to "secure" (full chain validation against the system trust store) and exposes two opt-ins via environment variables, both resolved once at BackendClient construction: - TDAI_OFFLOAD_INSECURE_TLS=1 Disable rejectUnauthorized. A loud warning is logged so the operator cannot accidentally ship this to production. Intended only for local development against a self-signed backend. - TDAI_OFFLOAD_CA_PEM_PATH= Load a custom CA certificate so a self-signed backend can be trusted *without* disabling validation. Mirrors the caPemPath option on src/core/store/tcvdb-client.ts (which already gets this right). A missing or unreadable CA file degrades gracefully to the system trust store with a warning, so a misconfigured path cannot brick a running daemon. Closes #8. Tests: new src/offload/__tests__/backend-client-tls.test.ts — 7 cases covering the secure default, INSECURE_TLS opt-in + warning, CA load success / failure paths, both-set composition, and construction-time resolution (subsequent env mutation does not retroactively affect the existing instance). Signed-off-by: 李冠辰 --- .../__tests__/backend-client-tls.test.ts | 151 ++++++++++++++++++ src/offload/backend-client.ts | 43 ++++- 2 files changed, 193 insertions(+), 1 deletion(-) create mode 100644 src/offload/__tests__/backend-client-tls.test.ts diff --git a/src/offload/__tests__/backend-client-tls.test.ts b/src/offload/__tests__/backend-client-tls.test.ts new file mode 100644 index 0000000..c2508cd --- /dev/null +++ b/src/offload/__tests__/backend-client-tls.test.ts @@ -0,0 +1,151 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import { BackendClient } from "../backend-client.js"; +import type { PluginLogger } from "../types.js"; + +function createSpyLogger(): { + logger: PluginLogger; + warnCalls: string[]; + infoCalls: string[]; + errorCalls: string[]; + debugCalls: string[]; +} { + const warnCalls: string[] = []; + const infoCalls: string[] = []; + const errorCalls: string[] = []; + const debugCalls: string[] = []; + const logger: PluginLogger = { + debug: (msg: string) => debugCalls.push(msg), + info: (msg: string) => infoCalls.push(msg), + warn: (msg: string) => warnCalls.push(msg), + error: (msg: string) => errorCalls.push(msg), + }; + return { logger, warnCalls, infoCalls, errorCalls, debugCalls }; +} + +// Reach into the private `tlsOptions` field for assertions. +function tlsOptionsOf(client: BackendClient): { rejectUnauthorized?: boolean; ca?: Buffer } { + return (client as unknown as { tlsOptions: { rejectUnauthorized?: boolean; ca?: Buffer } }) + .tlsOptions; +} + +describe("BackendClient TLS options", () => { + let tmpDir: string; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "tdai-tls-test-")); + }); + + afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + it("defaults to secure TLS (no opts injected) when neither env var is set", () => { + const { logger, warnCalls, infoCalls } = createSpyLogger(); + const client = new BackendClient("https://backend.example.com", logger, "test-key"); + const opts = tlsOptionsOf(client); + + // No overrides means we let node:https default to its system-trust-store + // behaviour (rejectUnauthorized: true). We explicitly do NOT set the field + // — that way a caller that *also* wanted to override gets the most natural + // composition. + expect(opts.rejectUnauthorized).toBeUndefined(); + expect(opts.ca).toBeUndefined(); + expect(warnCalls).toHaveLength(0); + // No info either — silent secure default. + expect(infoCalls.filter((m) => m.includes("CA"))).toHaveLength(0); + }); + + it("disables verification when TDAI_OFFLOAD_INSECURE_TLS=1 and emits a loud warning", () => { + vi.stubEnv("TDAI_OFFLOAD_INSECURE_TLS", "1"); + const { logger, warnCalls } = createSpyLogger(); + const client = new BackendClient("https://backend.example.com", logger, "test-key"); + const opts = tlsOptionsOf(client); + + expect(opts.rejectUnauthorized).toBe(false); + expect(opts.ca).toBeUndefined(); + expect(warnCalls).toHaveLength(1); + expect(warnCalls[0]).toMatch(/TDAI_OFFLOAD_INSECURE_TLS=1/); + expect(warnCalls[0]).toMatch(/DISABLED/); + // Steer the operator toward the safer alternative. + expect(warnCalls[0]).toMatch(/TDAI_OFFLOAD_CA_PEM_PATH/); + }); + + it("ignores TDAI_OFFLOAD_INSECURE_TLS values other than the literal '1'", () => { + for (const v of ["true", "yes", "0", "", "1 "]) { + vi.stubEnv("TDAI_OFFLOAD_INSECURE_TLS", v); + const { logger, warnCalls } = createSpyLogger(); + const client = new BackendClient("https://backend.example.com", logger, "test-key"); + const opts = tlsOptionsOf(client); + expect(opts.rejectUnauthorized, `value=${JSON.stringify(v)}`).toBeUndefined(); + expect(warnCalls, `value=${JSON.stringify(v)}`).toHaveLength(0); + } + }); + + it("loads CA bytes when TDAI_OFFLOAD_CA_PEM_PATH points at a readable file", () => { + const caPath = path.join(tmpDir, "ca.pem"); + const caBytes = Buffer.from( + "-----BEGIN CERTIFICATE-----\nFAKE_PEM_BYTES_FOR_TEST\n-----END CERTIFICATE-----\n", + ); + fs.writeFileSync(caPath, caBytes); + vi.stubEnv("TDAI_OFFLOAD_CA_PEM_PATH", caPath); + + const { logger, warnCalls, infoCalls } = createSpyLogger(); + const client = new BackendClient("https://backend.example.com", logger, "test-key"); + const opts = tlsOptionsOf(client); + + expect(opts.ca?.equals(caBytes)).toBe(true); + expect(opts.rejectUnauthorized).toBeUndefined(); // CA load alone does NOT disable verification + expect(warnCalls).toHaveLength(0); + expect(infoCalls.some((m) => m.includes(caPath))).toBe(true); + }); + + it("warns but does not throw when TDAI_OFFLOAD_CA_PEM_PATH points at a missing file", () => { + const caPath = path.join(tmpDir, "does-not-exist.pem"); + vi.stubEnv("TDAI_OFFLOAD_CA_PEM_PATH", caPath); + + const { logger, warnCalls } = createSpyLogger(); + // Construction must not throw — a misconfigured CA path should degrade + // gracefully to "use system trust store" rather than break the daemon. + expect(() => new BackendClient("https://backend.example.com", logger, "test-key")).not.toThrow(); + const client = new BackendClient("https://backend.example.com", logger, "test-key"); + const opts = tlsOptionsOf(client); + + expect(opts.ca).toBeUndefined(); + expect(warnCalls.some((m) => m.includes(caPath))).toBe(true); + expect(warnCalls.some((m) => m.includes("Falling back to system trust store"))).toBe(true); + }); + + it("combines INSECURE_TLS=1 and CA_PEM_PATH (insecure wins; CA still loaded for completeness)", () => { + const caPath = path.join(tmpDir, "ca.pem"); + const caBytes = Buffer.from("PEM"); + fs.writeFileSync(caPath, caBytes); + vi.stubEnv("TDAI_OFFLOAD_INSECURE_TLS", "1"); + vi.stubEnv("TDAI_OFFLOAD_CA_PEM_PATH", caPath); + + const { logger, warnCalls, infoCalls } = createSpyLogger(); + const client = new BackendClient("https://backend.example.com", logger, "test-key"); + const opts = tlsOptionsOf(client); + + expect(opts.rejectUnauthorized).toBe(false); + expect(opts.ca?.equals(caBytes)).toBe(true); + // Both signals surface to the operator. + expect(warnCalls.some((m) => m.includes("TDAI_OFFLOAD_INSECURE_TLS"))).toBe(true); + expect(infoCalls.some((m) => m.includes(caPath))).toBe(true); + }); + + it("resolves TLS options once at construction (no per-request env re-read)", () => { + // No env set at construction time. + const { logger } = createSpyLogger(); + const client = new BackendClient("https://backend.example.com", logger, "test-key"); + expect(tlsOptionsOf(client).rejectUnauthorized).toBeUndefined(); + + // Later changes to env must NOT retroactively affect the existing instance. + // (Construction-time resolution gives a stable, auditable security posture + // for the lifetime of the daemon.) + vi.stubEnv("TDAI_OFFLOAD_INSECURE_TLS", "1"); + expect(tlsOptionsOf(client).rejectUnauthorized).toBeUndefined(); + }); +}); diff --git a/src/offload/backend-client.ts b/src/offload/backend-client.ts index 30dc622..8aa8692 100644 --- a/src/offload/backend-client.ts +++ b/src/offload/backend-client.ts @@ -12,6 +12,7 @@ import type { OffloadEntry, ToolPair, TaskJudgment, PluginLogger } from "./types import { traceOffloadModelIo } from "./opik-tracer.js"; import * as https from "node:https"; import * as http from "node:http"; +import * as fs from "node:fs"; // ─── Request / Response Types ──────────────────────────────────────────────── @@ -114,6 +115,18 @@ export class BackendClient { private userIdFn: () => string | null; /** Resolves the value of the `X-Task-Id` header sent on every call (optional). */ private taskIdFn: () => string | null; + /** + * TLS options for HTTPS requests, resolved once at construction. Defaults + * are *secure* (full chain validation against the system trust store). + * Two env-driven overrides: + * - `TDAI_OFFLOAD_INSECURE_TLS=1` → set `rejectUnauthorized: false` + * (development / private self-signed backends only; a warning is logged + * on construction so the operator notices it in stderr). + * - `TDAI_OFFLOAD_CA_PEM_PATH=` → load a custom CA certificate so + * a self-signed backend can be trusted *without* disabling validation. + * Mirrors the `caPemPath` option on `src/core/store/tcvdb-client.ts`. + */ + private tlsOptions: { rejectUnauthorized?: boolean; ca?: Buffer }; constructor( baseUrl: string, @@ -130,6 +143,34 @@ export class BackendClient { this.sessionKeyFn = sessionKeyFn ?? (() => null); this.userIdFn = userIdFn ?? (() => null); this.taskIdFn = taskIdFn ?? (() => null); + this.tlsOptions = this.resolveTlsOptions(); + } + + private resolveTlsOptions(): { rejectUnauthorized?: boolean; ca?: Buffer } { + const opts: { rejectUnauthorized?: boolean; ca?: Buffer } = {}; + if (process.env.TDAI_OFFLOAD_INSECURE_TLS === "1") { + opts.rejectUnauthorized = false; + this.logger.warn( + `[context-offload] TDAI_OFFLOAD_INSECURE_TLS=1: TLS certificate validation is DISABLED. ` + + `This is only safe for development against private/self-signed backends — never use in production. ` + + `Prefer TDAI_OFFLOAD_CA_PEM_PATH= to trust a specific self-signed CA instead.`, + ); + } + const caPath = process.env.TDAI_OFFLOAD_CA_PEM_PATH; + if (caPath) { + try { + opts.ca = fs.readFileSync(caPath); + this.logger.info( + `[context-offload] loaded CA certificate from TDAI_OFFLOAD_CA_PEM_PATH=${caPath}`, + ); + } catch (err) { + this.logger.warn( + `[context-offload] failed to load CA from TDAI_OFFLOAD_CA_PEM_PATH=${caPath}: ` + + `${err instanceof Error ? err.message : String(err)}. Falling back to system trust store.`, + ); + } + } + return opts; } /** L1 Summarize — synchronous await (used by assemble flush + force trigger) */ @@ -309,7 +350,7 @@ export class BackendClient { path: parsed.pathname + parsed.search, method: "POST", headers: reqHeaders, - ...(isHttps ? { rejectUnauthorized: false } : {}), + ...(isHttps ? this.tlsOptions : {}), }, (res) => { let data = "";