From 6b532cfe46a9b73a1aabbe0668b4fab23a7b9c38 Mon Sep 17 00:00:00 2001 From: Arne Bahlo Date: Fri, 11 Sep 2026 10:59:39 +0200 Subject: [PATCH 1/6] feat(server): Allow setting T3CODE_OTLP_HEADERS Adds the option to set `T3CODE_OTLP_HEADERS` for a more flexible O11y setup. Some vendors require headers, e.g. `authorization`--this makes it possible to use these vendors without any workarounds. It's parsed like the `OTEL_EXPORTER_OTLP_HEADERS` as comma-separated `key=value` pairs with value being url-encoded. ```sh export T3CODE_OTLP_HEADERS="authorization=Basic%20abc%3D%3D,x-tenant=t3" ``` ```json { "authorization": "Basic abc==", "x-tenant": "t3" } ``` --- apps/server/src/bin.test.ts | 1 + apps/server/src/cli/config.test.ts | 44 +++++++++++++++++++ apps/server/src/cli/config.ts | 5 +++ apps/server/src/cli/pair.ts | 1 + apps/server/src/config.ts | 2 + .../src/environment/ServerEnvironment.test.ts | 1 + apps/server/src/http.ts | 2 + .../src/observability/Layers/Observability.ts | 2 + apps/server/src/server.test.ts | 1 + docs/operations/observability.md | 1 + 10 files changed, 60 insertions(+) diff --git a/apps/server/src/bin.test.ts b/apps/server/src/bin.test.ts index e1a13d4ce8e7..f1e1e3d8d73c 100644 --- a/apps/server/src/bin.test.ts +++ b/apps/server/src/bin.test.ts @@ -101,6 +101,7 @@ const makeCliTestServerConfig = (baseDir: string) => otlpMetricsUrl: undefined, otlpExportIntervalMs: 10_000, otlpServiceName: "t3-server", + otlpHeaders: undefined, mode: "web", port: 0, host: "127.0.0.1", diff --git a/apps/server/src/cli/config.test.ts b/apps/server/src/cli/config.test.ts index 0c28bce28ae5..4dd988955bc5 100644 --- a/apps/server/src/cli/config.test.ts +++ b/apps/server/src/cli/config.test.ts @@ -52,6 +52,7 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { otlpMetricsUrl: undefined, otlpExportIntervalMs: 10_000, otlpServiceName: "t3-server", + otlpHeaders: undefined, devAllowedOrigins: [], } as const; @@ -719,4 +720,47 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { }); }), ); + + it.effect("decodes percent-encoded OTLP headers from env", () => + Effect.gen(function* () { + const { join } = yield* Path.Path; + const baseDir = join(NodeOS.tmpdir(), "t3-cli-config-otlp-headers-base"); + + const resolved = yield* resolveServerConfig( + { + mode: Option.some("web"), + port: Option.some(3773), + host: Option.none(), + baseDir: Option.some(baseDir), + cwd: Option.none(), + devUrl: Option.none(), + noBrowser: Option.none(), + bootstrapFd: Option.none(), + autoBootstrapProjectFromCwd: Option.none(), + logWebSocketEvents: Option.none(), + tailscaleServeEnabled: Option.none(), + tailscaleServePort: Option.none(), + }, + Option.none(), + ).pipe( + Effect.provide( + Layer.mergeAll( + ConfigProvider.layer( + ConfigProvider.fromEnv({ + env: { + T3CODE_OTLP_HEADERS: "authorization=Basic%20abc%3D%3D,x-tenant=t3", + }, + }), + ), + NetService.layer, + ), + ), + ); + + expect(resolved.otlpHeaders).toEqual({ + authorization: "Basic abc==", + "x-tenant": "t3", + }); + }), + ); }); diff --git a/apps/server/src/cli/config.ts b/apps/server/src/cli/config.ts index 1759b4b03830..10a290d9bcdd 100644 --- a/apps/server/src/cli/config.ts +++ b/apps/server/src/cli/config.ts @@ -99,6 +99,10 @@ const EnvServerConfig = Config.all({ Config.withDefault(10_000), ), otlpServiceName: Config.string("T3CODE_OTLP_SERVICE_NAME").pipe(Config.withDefault("t3-server")), + otlpHeaders: Config.schema( + Config.Record(Schema.String, Schema.StringFromUriComponent), + "T3CODE_OTLP_HEADERS", + ).pipe(Config.option, Config.map(Option.getOrUndefined)), mode: Config.schema(ServerConfig.RuntimeMode, "T3CODE_MODE").pipe( Config.option, Config.map(Option.getOrUndefined), @@ -387,6 +391,7 @@ export const resolveServerConfig = ( persistedObservabilitySettings.otlpMetricsUrl, otlpExportIntervalMs: env.otlpExportIntervalMs, otlpServiceName: env.otlpServiceName, + otlpHeaders: env.otlpHeaders, mode, port, cwd, diff --git a/apps/server/src/cli/pair.ts b/apps/server/src/cli/pair.ts index 7fd376c6f881..353fc9ad598a 100644 --- a/apps/server/src/cli/pair.ts +++ b/apps/server/src/cli/pair.ts @@ -322,6 +322,7 @@ const makePairServerConfig = Effect.fn(function* (input: { otlpMetricsUrl: undefined, otlpExportIntervalMs: 10_000, otlpServiceName: "t3-server", + otlpHeaders: undefined, mode: "web", port: state.port, host: state.host, diff --git a/apps/server/src/config.ts b/apps/server/src/config.ts index b0544ef30aeb..2f6c522d9627 100644 --- a/apps/server/src/config.ts +++ b/apps/server/src/config.ts @@ -73,6 +73,7 @@ export class ServerConfig extends Context.Service< readonly otlpMetricsUrl: string | undefined; readonly otlpExportIntervalMs: number; readonly otlpServiceName: string; + readonly otlpHeaders: Readonly> | undefined; readonly mode: RuntimeMode; readonly port: number; readonly host: string | undefined; @@ -197,6 +198,7 @@ const makeTest = Effect.fn("ServerConfig.makeTest")(function* ( otlpMetricsUrl: undefined, otlpExportIntervalMs: 10_000, otlpServiceName: "t3-server", + otlpHeaders: undefined, cwd, baseDir, ...derivedPaths, diff --git a/apps/server/src/environment/ServerEnvironment.test.ts b/apps/server/src/environment/ServerEnvironment.test.ts index a12a8242b0d2..2f39e7832e42 100644 --- a/apps/server/src/environment/ServerEnvironment.test.ts +++ b/apps/server/src/environment/ServerEnvironment.test.ts @@ -54,6 +54,7 @@ const makeServerConfig = Effect.fn(function* (baseDir: string) { otlpMetricsUrl: undefined, otlpExportIntervalMs: 10_000, otlpServiceName: "t3-server", + otlpHeaders: undefined, cwd: process.cwd(), baseDir, mode: "web", diff --git a/apps/server/src/http.ts b/apps/server/src/http.ts index 4d2dac735425..37494cb60ada 100644 --- a/apps/server/src/http.ts +++ b/apps/server/src/http.ts @@ -319,6 +319,7 @@ export const otlpTracesProxyRouteLayer = HttpRouter.add( const request = yield* HttpServerRequest.HttpServerRequest; const config = yield* ServerConfig.ServerConfig; const otlpTracesUrl = config.otlpTracesUrl; + const otlpHeaders = config.otlpHeaders; const browserTraceCollector = yield* BrowserTraceCollector.BrowserTraceCollector; const httpClient = yield* HttpClient.HttpClient; const bodyJson = cast(yield* request.json); @@ -343,6 +344,7 @@ export const otlpTracesProxyRouteLayer = HttpRouter.add( return yield* httpClient .post(otlpTracesUrl, { body: HttpBody.jsonUnsafe(bodyJson), + headers: otlpHeaders, }) .pipe( Effect.flatMap(HttpClientResponse.filterStatusOk), diff --git a/apps/server/src/observability/Layers/Observability.ts b/apps/server/src/observability/Layers/Observability.ts index 8aac0927534b..9250d231dfb9 100644 --- a/apps/server/src/observability/Layers/Observability.ts +++ b/apps/server/src/observability/Layers/Observability.ts @@ -49,6 +49,7 @@ export const ObservabilityLive = Layer.unwrap( : yield* OtlpTracer.make({ url: config.otlpTracesUrl, exportInterval: `${config.otlpExportIntervalMs} millis`, + headers: config.otlpHeaders, resource: { serviceName: config.otlpServiceName, attributes: { @@ -80,6 +81,7 @@ export const ObservabilityLive = Layer.unwrap( : OtlpMetrics.layer({ url: config.otlpMetricsUrl, exportInterval: `${config.otlpExportIntervalMs} millis`, + headers: config.otlpHeaders, resource: { serviceName: config.otlpServiceName, attributes: { diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index 3eb40a1b9906..d842cd3d4d21 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -576,6 +576,7 @@ const buildAppUnderTest = (options?: { otlpMetricsUrl: undefined, otlpExportIntervalMs: 10_000, otlpServiceName: "t3-server", + otlpHeaders: undefined, mode: "desktop", port: 0, host: "127.0.0.1", diff --git a/docs/operations/observability.md b/docs/operations/observability.md index 966eab112c6b..f0614339b384 100644 --- a/docs/operations/observability.md +++ b/docs/operations/observability.md @@ -528,6 +528,7 @@ OTLP export: - `T3CODE_OTLP_METRICS_URL`: OTLP metric endpoint - `T3CODE_OTLP_EXPORT_INTERVAL_MS`: export interval, default `10000` - `T3CODE_OTLP_SERVICE_NAME`: service name, default `t3-server` +- `T3CODE_OTLP_HEADERS`: extra headers for both exporters, same format as `OTEL_EXPORTER_OTLP_HEADERS`: comma-separated `key=value` pairs with percent-encoded values If the OTLP URLs are unset, local tracing still works and metrics stay in-process only. From fc2ca038f64eaf9f1aa3c04c46480940c5d0391a Mon Sep 17 00:00:00 2001 From: Arne Bahlo Date: Fri, 11 Sep 2026 11:00:08 +0200 Subject: [PATCH 2/6] feat(desktop): Allow setting T3CODE_OTLP_HEADERS The desktop app runs its own OTLP tracer, so it also needs the `T3CODE_OTLP_HEADERS` env. It also forwards the env through the dev launcher allowlist and across the WSL boundary. --- apps/desktop/scripts/electron-launcher.mjs | 1 + apps/desktop/src/app/DesktopConfig.ts | 5 +++++ apps/desktop/src/app/DesktopEnvironment.test.ts | 8 ++++++++ apps/desktop/src/app/DesktopEnvironment.ts | 2 ++ apps/desktop/src/app/DesktopObservability.ts | 1 + .../src/backend/DesktopBackendConfiguration.test.ts | 5 ++++- apps/desktop/src/backend/DesktopBackendConfiguration.ts | 6 +++++- 7 files changed, 26 insertions(+), 2 deletions(-) diff --git a/apps/desktop/scripts/electron-launcher.mjs b/apps/desktop/scripts/electron-launcher.mjs index eedfff9744f8..f1c1f57822a9 100644 --- a/apps/desktop/scripts/electron-launcher.mjs +++ b/apps/desktop/scripts/electron-launcher.mjs @@ -116,6 +116,7 @@ export function makeDevelopmentEnvironmentScript(environment) { ["T3CODE_COMMIT_HASH", environment.T3CODE_COMMIT_HASH], ["T3CODE_OTLP_TRACES_URL", environment.T3CODE_OTLP_TRACES_URL], ["T3CODE_OTLP_EXPORT_INTERVAL_MS", environment.T3CODE_OTLP_EXPORT_INTERVAL_MS], + ["T3CODE_OTLP_HEADERS", environment.T3CODE_OTLP_HEADERS], ["T3CODE_DESKTOP_APP_USER_MODEL_ID", APP_BUNDLE_ID], ].filter((entry) => typeof entry[1] === "string" && entry[1].trim().length > 0); return [ diff --git a/apps/desktop/src/app/DesktopConfig.ts b/apps/desktop/src/app/DesktopConfig.ts index d157a4c6ba44..5b5d927c231f 100644 --- a/apps/desktop/src/app/DesktopConfig.ts +++ b/apps/desktop/src/app/DesktopConfig.ts @@ -1,6 +1,7 @@ import * as Config from "effect/Config"; import * as ConfigProvider from "effect/ConfigProvider"; import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; const trimNonEmptyOption = (value: string): Option.Option => { const trimmed = value.trim(); @@ -48,6 +49,10 @@ export const DesktopConfig = Config.all({ otlpExportIntervalMs: Config.int("T3CODE_OTLP_EXPORT_INTERVAL_MS").pipe( Config.withDefault(10_000), ), + otlpHeaders: Config.schema( + Config.Record(Schema.String, Schema.StringFromUriComponent), + "T3CODE_OTLP_HEADERS", + ).pipe(Config.option), appImagePath: trimmedString("APPIMAGE"), disableAutoUpdate: optionalBoolean("T3CODE_DISABLE_AUTO_UPDATE"), mockUpdates: optionalBoolean("T3CODE_DESKTOP_MOCK_UPDATES"), diff --git a/apps/desktop/src/app/DesktopEnvironment.test.ts b/apps/desktop/src/app/DesktopEnvironment.test.ts index 1ebd5dae56c2..e34ae0b00a1c 100644 --- a/apps/desktop/src/app/DesktopEnvironment.test.ts +++ b/apps/desktop/src/app/DesktopEnvironment.test.ts @@ -52,6 +52,7 @@ describe("DesktopEnvironment", () => { T3CODE_DEV_REMOTE_T3_SERVER_ENTRY_PATH: " /remote/server.mjs ", T3CODE_OTLP_TRACES_URL: " http://127.0.0.1:4318/v1/traces ", T3CODE_OTLP_EXPORT_INTERVAL_MS: "2500", + T3CODE_OTLP_HEADERS: "authorization=Basic%20abc%3D%3D,x-tenant=t3", }, ); @@ -85,6 +86,13 @@ describe("DesktopEnvironment", () => { assert.deepEqual(environment.commitHashOverride, Option.some("0123456789abcdef")); assert.deepEqual(environment.otlpTracesUrl, Option.some("http://127.0.0.1:4318/v1/traces")); assert.equal(environment.otlpExportIntervalMs, 2500); + assert.deepEqual( + environment.otlpHeaders, + Option.some({ + authorization: "Basic abc==", + "x-tenant": "t3", + }), + ); }), ); diff --git a/apps/desktop/src/app/DesktopEnvironment.ts b/apps/desktop/src/app/DesktopEnvironment.ts index e604cb767f3f..f5cfbc0e7cb6 100644 --- a/apps/desktop/src/app/DesktopEnvironment.ts +++ b/apps/desktop/src/app/DesktopEnvironment.ts @@ -72,6 +72,7 @@ export class DesktopEnvironment extends Context.Service< readonly commitHashOverride: Option.Option; readonly otlpTracesUrl: Option.Option; readonly otlpExportIntervalMs: number; + readonly otlpHeaders: Option.Option>; readonly branding: DesktopAppBranding; readonly displayName: string; readonly appUserModelId: string; @@ -225,6 +226,7 @@ const make = Effect.fn("desktop.environment.make")(function* ( commitHashOverride: config.commitHashOverride, otlpTracesUrl: config.otlpTracesUrl, otlpExportIntervalMs: config.otlpExportIntervalMs, + otlpHeaders: config.otlpHeaders, branding, displayName, appUserModelId: Option.getOrElse(config.appUserModelIdOverride, () => diff --git a/apps/desktop/src/app/DesktopObservability.ts b/apps/desktop/src/app/DesktopObservability.ts index d2ff0b4e2ad5..4f9b18d73963 100644 --- a/apps/desktop/src/app/DesktopObservability.ts +++ b/apps/desktop/src/app/DesktopObservability.ts @@ -584,6 +584,7 @@ const tracerLayer = Layer.unwrap( : yield* OtlpTracer.make({ url: otlpTracesUrl.value, exportInterval: `${environment.otlpExportIntervalMs} millis`, + headers: Option.getOrUndefined(environment.otlpHeaders), resource: { serviceName: "desktop", attributes: { diff --git a/apps/desktop/src/backend/DesktopBackendConfiguration.test.ts b/apps/desktop/src/backend/DesktopBackendConfiguration.test.ts index edc719734912..2994c9e2d313 100644 --- a/apps/desktop/src/backend/DesktopBackendConfiguration.test.ts +++ b/apps/desktop/src/backend/DesktopBackendConfiguration.test.ts @@ -853,10 +853,12 @@ describe("DesktopBackendConfiguration", () => { const previousWslEnv = process.env.WSLENV; const previousOpenAiKey = process.env.OPENAI_API_KEY; const previousAnthropicKey = process.env.ANTHROPIC_API_KEY; + const previousOtlpHeaders = process.env.T3CODE_OTLP_HEADERS; try { process.env.WSLENV = "GOPATH/p:OPENAI_API_KEY/u:EMPTY::AZURE_DEVOPS_EXT_PAT/u"; process.env.OPENAI_API_KEY = "openai-key"; process.env.ANTHROPIC_API_KEY = "anthropic-key"; + process.env.T3CODE_OTLP_HEADERS = 'authorization="Bearer%20my-token"'; yield* Effect.gen(function* () { const configuration = yield* DesktopBackendConfiguration.DesktopBackendConfiguration; @@ -882,7 +884,7 @@ describe("DesktopBackendConfiguration", () => { // already declared, so it isn't forwarded twice. assert.equal( config.env.WSLENV, - "GOPATH/p:OPENAI_API_KEY/u:EMPTY::AZURE_DEVOPS_EXT_PAT/u:ANTHROPIC_API_KEY", + "GOPATH/p:OPENAI_API_KEY/u:EMPTY::AZURE_DEVOPS_EXT_PAT/u:ANTHROPIC_API_KEY:T3CODE_OTLP_HEADERS", ); }).pipe( Effect.provide( @@ -905,6 +907,7 @@ describe("DesktopBackendConfiguration", () => { restoreEnv("WSLENV", previousWslEnv); restoreEnv("OPENAI_API_KEY", previousOpenAiKey); restoreEnv("ANTHROPIC_API_KEY", previousAnthropicKey); + restoreEnv("T3CODE_OTLP_HEADERS", previousOtlpHeaders); } }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), ); diff --git a/apps/desktop/src/backend/DesktopBackendConfiguration.ts b/apps/desktop/src/backend/DesktopBackendConfiguration.ts index 09014add865c..531557d1dac2 100644 --- a/apps/desktop/src/backend/DesktopBackendConfiguration.ts +++ b/apps/desktop/src/backend/DesktopBackendConfiguration.ts @@ -91,7 +91,11 @@ const DESKTOP_BACKEND_ENV_NAMES = [ // forward across the wsl.exe boundary without WSLENV. The dev-server URL is // handled separately via a `--dev-url` CLI flag because WSLENV translation of // URL-shaped values (colons / slashes) is unreliable. -const WSL_FORWARDED_ENV_NAMES = ["OPENAI_API_KEY", "ANTHROPIC_API_KEY"] as const; +const WSL_FORWARDED_ENV_NAMES = [ + "OPENAI_API_KEY", + "ANTHROPIC_API_KEY", + "T3CODE_OTLP_HEADERS", +] as const; const WSL_SERVER_SYSTEM_PATH = "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"; From 921cc910524aebd24f77fecf2c1638c3992fedea Mon Sep 17 00:00:00 2001 From: Arne Bahlo Date: Fri, 11 Sep 2026 13:49:44 +0200 Subject: [PATCH 3/6] fix(o11y): Refuse OTLP headers on plaintext non-loopback endpoints --- apps/desktop/src/app/DesktopObservability.ts | 15 +++++- apps/server/src/cli/config.test.ts | 49 ++++++++++++++++++++ apps/server/src/cli/config.ts | 9 ++++ docs/operations/observability.md | 4 +- packages/shared/src/observability.test.ts | 37 ++++++++++++++- packages/shared/src/observability.ts | 31 +++++++++++++ 6 files changed, 142 insertions(+), 3 deletions(-) diff --git a/apps/desktop/src/app/DesktopObservability.ts b/apps/desktop/src/app/DesktopObservability.ts index 4f9b18d73963..08b34b6db08c 100644 --- a/apps/desktop/src/app/DesktopObservability.ts +++ b/apps/desktop/src/app/DesktopObservability.ts @@ -1,5 +1,9 @@ import { PRIMARY_LOCAL_ENVIRONMENT_ID } from "@t3tools/contracts"; -import { makeLocalFileTracer, makeTraceSink } from "@t3tools/shared/observability"; +import { + makeLocalFileTracer, + makeTraceSink, + otlpHeadersTransportIssue, +} from "@t3tools/shared/observability"; import { parsePersistedServerObservabilitySettings } from "@t3tools/shared/serverSettings"; import * as Context from "effect/Context"; import * as DateTime from "effect/DateTime"; @@ -572,6 +576,15 @@ const tracerLayer = Layer.unwrap( Effect.gen(function* () { const environment = yield* DesktopEnvironment.DesktopEnvironment; const otlpTracesUrl = yield* resolveOtlpTracesUrl; + + const transportIssue = otlpHeadersTransportIssue( + Option.getOrUndefined(environment.otlpHeaders), + [Option.getOrUndefined(otlpTracesUrl)], + ); + if (transportIssue) { + return yield* Effect.die(new Error(transportIssue)); + } + const tracePath = environment.path.join(environment.logDir, "desktop.trace.ndjson"); const sink = yield* makeTraceSink({ filePath: tracePath, diff --git a/apps/server/src/cli/config.test.ts b/apps/server/src/cli/config.test.ts index 4dd988955bc5..2a011d714553 100644 --- a/apps/server/src/cli/config.test.ts +++ b/apps/server/src/cli/config.test.ts @@ -3,8 +3,10 @@ import * as NodeFS from "node:fs"; import * as NodeOS from "node:os"; import { assert, expect, it } from "@effect/vitest"; +import * as Cause from "effect/Cause"; import * as ConfigProvider from "effect/ConfigProvider"; import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; @@ -763,4 +765,51 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { }); }), ); + + it.effect("forbids sending otlp headers to http remotes", () => + Effect.gen(function* () { + const { join } = yield* Path.Path; + const baseDir = join(NodeOS.tmpdir(), "t3-cli-config-otlp-headers-plaintext-base"); + + const exit = yield* resolveServerConfig( + { + mode: Option.some("web"), + port: Option.some(3773), + host: Option.none(), + baseDir: Option.some(baseDir), + cwd: Option.none(), + devUrl: Option.none(), + noBrowser: Option.none(), + bootstrapFd: Option.none(), + autoBootstrapProjectFromCwd: Option.none(), + logWebSocketEvents: Option.none(), + tailscaleServeEnabled: Option.none(), + tailscaleServePort: Option.none(), + }, + Option.none(), + ).pipe( + Effect.provide( + Layer.mergeAll( + ConfigProvider.layer( + ConfigProvider.fromEnv({ + env: { + T3CODE_OTLP_HEADERS: "authorization=Basic%20abc%3D%3D,x-tenant=t3", + T3CODE_OTLP_TRACES_URL: "http://collector.internal:4318", + }, + }), + ), + NetService.layer, + ), + ), + Effect.exit, + ); + + assert.isTrue(Exit.isFailure(exit)); + if (Exit.isFailure(exit)) { + const error = Cause.squash(exit.cause); + assert.instanceOf(error, Error); + expect((error as Error).message).toContain("http://collector.internal:4318"); + } + }), + ); }); diff --git a/apps/server/src/cli/config.ts b/apps/server/src/cli/config.ts index 10a290d9bcdd..6e4bd6c9610b 100644 --- a/apps/server/src/cli/config.ts +++ b/apps/server/src/cli/config.ts @@ -17,6 +17,7 @@ import { Argument, Flag } from "effect/unstable/cli"; import { readBootstrapEnvelope } from "../bootstrap.ts"; import * as ServerConfig from "../config.ts"; import { expandHomePath, resolveBaseDir } from "../os-jank.ts"; +import { otlpHeadersTransportIssue } from "@t3tools/shared/observability"; const modeFlag = Flag.choice("mode", ServerConfig.RuntimeMode.literals).pipe( Flag.withDescription("Runtime mode. `desktop` keeps loopback defaults unless overridden."), @@ -415,6 +416,14 @@ export const resolveServerConfig = ( tailscaleServePort, }; + const transportIssue = otlpHeadersTransportIssue(config.otlpHeaders, [ + config.otlpTracesUrl, + config.otlpMetricsUrl, + ]); + if (transportIssue) { + return yield* Effect.die(new Error(transportIssue)); + } + return config; }); diff --git a/docs/operations/observability.md b/docs/operations/observability.md index f0614339b384..d35177756391 100644 --- a/docs/operations/observability.md +++ b/docs/operations/observability.md @@ -528,7 +528,9 @@ OTLP export: - `T3CODE_OTLP_METRICS_URL`: OTLP metric endpoint - `T3CODE_OTLP_EXPORT_INTERVAL_MS`: export interval, default `10000` - `T3CODE_OTLP_SERVICE_NAME`: service name, default `t3-server` -- `T3CODE_OTLP_HEADERS`: extra headers for both exporters, same format as `OTEL_EXPORTER_OTLP_HEADERS`: comma-separated `key=value` pairs with percent-encoded values +- `T3CODE_OTLP_HEADERS`: extra headers for both exporters, same format as + `OTEL_EXPORTER_OTLP_HEADERS`: comma-separated `key=value` pairs with percent-encoded values. + Refused on non-loopback http:// endpoints. If the OTLP URLs are unset, local tracing still works and metrics stay in-process only. diff --git a/packages/shared/src/observability.test.ts b/packages/shared/src/observability.test.ts index c58395393d37..c235ee715f13 100644 --- a/packages/shared/src/observability.test.ts +++ b/packages/shared/src/observability.test.ts @@ -1,4 +1,4 @@ -import { assert, describe, it } from "@effect/vitest"; +import { assert, describe, expect, it } from "@effect/vitest"; import * as NodeServices from "@effect/platform-node/NodeServices"; import * as Arr from "effect/Array"; import * as Cause from "effect/Cause"; @@ -22,6 +22,7 @@ import { type TraceRecord, type TraceSinkFlushStats, truncateTraceAttributes, + otlpHeadersTransportIssue, } from "./observability.ts"; describe("errorTag", () => { @@ -459,3 +460,37 @@ describe("observability", () => { ); }); }); + +describe("otlpHeadersTransportIssue", () => { + const headers = { authorization: "Bearer my-token" }; + + it("allows https endpoints", () => { + expect( + otlpHeadersTransportIssue(headers, ["https://api.example.com/v1/traces", undefined]), + ).toBeUndefined(); + }); + + it("allows loopback http endpoints", () => { + expect( + otlpHeadersTransportIssue(headers, [ + "http://localhost:4318/v1/traces", + "http://[::1]:4318/v1/metrics", + ]), + ).toBeUndefined(); + }); + + it("refuses a plaintext non-loopback endpoint even when another is https", () => { + expect( + otlpHeadersTransportIssue(headers, [ + "https://api.example.com/v1/traces", + "http://collector.internal:4318/v1/metrics", + ]), + ).toContain("http://collector.internal:4318"); + }); + + it("ignores endpoints when no headers are configured", () => { + expect( + otlpHeadersTransportIssue(undefined, ["http://collector.internal:4318/v1/traces"]), + ).toBeUndefined(); + }); +}); diff --git a/packages/shared/src/observability.ts b/packages/shared/src/observability.ts index 9692a05f7592..242fcee2952f 100644 --- a/packages/shared/src/observability.ts +++ b/packages/shared/src/observability.ts @@ -683,3 +683,34 @@ function parseBigInt(input: string): bigint { return 0n; } } + +const isLoopbackHost = (hostname: string) => { + return ( + hostname === "localhost" || + hostname === "127.0.0.1" || + hostname === "::1" || + hostname === "[::1]" + ); +}; + +export const otlpHeadersTransportIssue = ( + headers: Readonly> | undefined, + urls: ReadonlyArray, +): string | undefined => { + if (!headers) { + return undefined; + } + + for (const rawUrl of urls) { + if (!rawUrl) { + continue; + } + + const url = new URL(rawUrl); + if (url.protocol === "http:" && !isLoopbackHost(url.hostname)) { + return `T3CODE_OTLP_HEADERS would be sent in plaintext to ${url.origin}. Use an https:// or a loopback http:// endpoint.`; + } + } + + return undefined; +}; From 713f346714092e8473a5323604ed0bd32cfff707 Mon Sep 17 00:00:00 2001 From: Arne Bahlo Date: Fri, 11 Sep 2026 14:03:30 +0200 Subject: [PATCH 4/6] fix(shared): Classify 127/8 as loopback instead of only 127.0.0.1 --- packages/shared/src/observability.test.ts | 3 +++ packages/shared/src/observability.ts | 7 +------ 2 files changed, 4 insertions(+), 6 deletions(-) diff --git a/packages/shared/src/observability.test.ts b/packages/shared/src/observability.test.ts index c235ee715f13..6870c8133cdb 100644 --- a/packages/shared/src/observability.test.ts +++ b/packages/shared/src/observability.test.ts @@ -474,6 +474,9 @@ describe("otlpHeadersTransportIssue", () => { expect( otlpHeadersTransportIssue(headers, [ "http://localhost:4318/v1/traces", + "http://127.0.0.1:4318/v1/traces", + "http://127.0.0.2:4318/v1/traces", + "http://127.255.255.255:4318/v1/traces", "http://[::1]:4318/v1/metrics", ]), ).toBeUndefined(); diff --git a/packages/shared/src/observability.ts b/packages/shared/src/observability.ts index 242fcee2952f..9ea357f62e93 100644 --- a/packages/shared/src/observability.ts +++ b/packages/shared/src/observability.ts @@ -685,12 +685,7 @@ function parseBigInt(input: string): bigint { } const isLoopbackHost = (hostname: string) => { - return ( - hostname === "localhost" || - hostname === "127.0.0.1" || - hostname === "::1" || - hostname === "[::1]" - ); + return hostname === "localhost" || hostname.startsWith("127.") || hostname === "[::1]"; }; export const otlpHeadersTransportIssue = ( From 076e9cddcd37cf8d6d27e5144fa72852845aab75 Mon Sep 17 00:00:00 2001 From: Arne Bahlo Date: Fri, 11 Sep 2026 14:20:09 +0200 Subject: [PATCH 5/6] fix(shared): Only allow 127.0.0.0/8, not everything that starts with 127. --- packages/shared/src/observability.test.ts | 6 ++++++ packages/shared/src/observability.ts | 8 +++++++- 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/packages/shared/src/observability.test.ts b/packages/shared/src/observability.test.ts index 6870c8133cdb..42297e76c5e1 100644 --- a/packages/shared/src/observability.test.ts +++ b/packages/shared/src/observability.test.ts @@ -496,4 +496,10 @@ describe("otlpHeadersTransportIssue", () => { otlpHeadersTransportIssue(undefined, ["http://collector.internal:4318/v1/traces"]), ).toBeUndefined(); }); + + it("refuses a DNS name that merely starts with 127.", () => { + expect( + otlpHeadersTransportIssue(headers, ["http://127.attacker.example:4318/v1/traces"]), + ).toContain("http://127.attacker.example:4318"); + }); }); diff --git a/packages/shared/src/observability.ts b/packages/shared/src/observability.ts index 9ea357f62e93..3cd1e17e8f3d 100644 --- a/packages/shared/src/observability.ts +++ b/packages/shared/src/observability.ts @@ -1,3 +1,4 @@ +import * as NodeNet from "node:net"; import * as Cause from "effect/Cause"; import * as Effect from "effect/Effect"; import type * as Exit from "effect/Exit"; @@ -685,7 +686,12 @@ function parseBigInt(input: string): bigint { } const isLoopbackHost = (hostname: string) => { - return hostname === "localhost" || hostname.startsWith("127.") || hostname === "[::1]"; + if (hostname === "localhost" || hostname === "[::1]") { + return true; + } + + // match only 127.0.0.0/8, not any host that starts with 127. + return NodeNet.isIPv4(hostname) && hostname.startsWith("127."); }; export const otlpHeadersTransportIssue = ( From b4ce3f152b9dc75e9e9770a73a09cf26d69b3dc2 Mon Sep 17 00:00:00 2001 From: Julius Marminge <51714798+juliusmarminge@users.noreply.github.com> Date: Mon, 14 Sep 2026 10:31:12 -0700 Subject: [PATCH 6/6] fix(shared): parse OTLP headers at the first equals sign and drop the plaintext guard Effect's Config.Record splits every pair on "=", so a value such as "Bearer abc==" lost its padding, and whitespace after the comma leaked into the next header name. Parse the OTEL_EXPORTER_OTLP_HEADERS format with one shared schema that splits at the first "=" and trims around separators. The http:// refusal also blocked harmless headers to internal or Tailscale collectors, so operators keep the choice of transport. Co-Authored-By: Claude Fable 5 --- apps/desktop/src/app/DesktopConfig.ts | 7 +- apps/desktop/src/app/DesktopObservability.ts | 15 +--- apps/server/src/cli/config.test.ts | 22 +++--- apps/server/src/cli/config.ts | 18 ++--- docs/operations/observability.md | 1 - packages/shared/src/observability.test.ts | 72 ++++++++--------- packages/shared/src/observability.ts | 81 ++++++++++++-------- 7 files changed, 100 insertions(+), 116 deletions(-) diff --git a/apps/desktop/src/app/DesktopConfig.ts b/apps/desktop/src/app/DesktopConfig.ts index 5b5d927c231f..21062d3c0fab 100644 --- a/apps/desktop/src/app/DesktopConfig.ts +++ b/apps/desktop/src/app/DesktopConfig.ts @@ -1,7 +1,7 @@ +import { OtlpHeadersFromString } from "@t3tools/shared/observability"; import * as Config from "effect/Config"; import * as ConfigProvider from "effect/ConfigProvider"; import * as Option from "effect/Option"; -import * as Schema from "effect/Schema"; const trimNonEmptyOption = (value: string): Option.Option => { const trimmed = value.trim(); @@ -49,10 +49,7 @@ export const DesktopConfig = Config.all({ otlpExportIntervalMs: Config.int("T3CODE_OTLP_EXPORT_INTERVAL_MS").pipe( Config.withDefault(10_000), ), - otlpHeaders: Config.schema( - Config.Record(Schema.String, Schema.StringFromUriComponent), - "T3CODE_OTLP_HEADERS", - ).pipe(Config.option), + otlpHeaders: Config.schema(OtlpHeadersFromString, "T3CODE_OTLP_HEADERS").pipe(Config.option), appImagePath: trimmedString("APPIMAGE"), disableAutoUpdate: optionalBoolean("T3CODE_DISABLE_AUTO_UPDATE"), mockUpdates: optionalBoolean("T3CODE_DESKTOP_MOCK_UPDATES"), diff --git a/apps/desktop/src/app/DesktopObservability.ts b/apps/desktop/src/app/DesktopObservability.ts index 08b34b6db08c..4f9b18d73963 100644 --- a/apps/desktop/src/app/DesktopObservability.ts +++ b/apps/desktop/src/app/DesktopObservability.ts @@ -1,9 +1,5 @@ import { PRIMARY_LOCAL_ENVIRONMENT_ID } from "@t3tools/contracts"; -import { - makeLocalFileTracer, - makeTraceSink, - otlpHeadersTransportIssue, -} from "@t3tools/shared/observability"; +import { makeLocalFileTracer, makeTraceSink } from "@t3tools/shared/observability"; import { parsePersistedServerObservabilitySettings } from "@t3tools/shared/serverSettings"; import * as Context from "effect/Context"; import * as DateTime from "effect/DateTime"; @@ -576,15 +572,6 @@ const tracerLayer = Layer.unwrap( Effect.gen(function* () { const environment = yield* DesktopEnvironment.DesktopEnvironment; const otlpTracesUrl = yield* resolveOtlpTracesUrl; - - const transportIssue = otlpHeadersTransportIssue( - Option.getOrUndefined(environment.otlpHeaders), - [Option.getOrUndefined(otlpTracesUrl)], - ); - if (transportIssue) { - return yield* Effect.die(new Error(transportIssue)); - } - const tracePath = environment.path.join(environment.logDir, "desktop.trace.ndjson"); const sink = yield* makeTraceSink({ filePath: tracePath, diff --git a/apps/server/src/cli/config.test.ts b/apps/server/src/cli/config.test.ts index 2a011d714553..f68142168bc9 100644 --- a/apps/server/src/cli/config.test.ts +++ b/apps/server/src/cli/config.test.ts @@ -3,10 +3,8 @@ import * as NodeFS from "node:fs"; import * as NodeOS from "node:os"; import { assert, expect, it } from "@effect/vitest"; -import * as Cause from "effect/Cause"; import * as ConfigProvider from "effect/ConfigProvider"; import * as Effect from "effect/Effect"; -import * as Exit from "effect/Exit"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; @@ -766,12 +764,12 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { }), ); - it.effect("forbids sending otlp headers to http remotes", () => + it.effect("keeps whitespace-separated pairs and literal equals signs in OTLP headers", () => Effect.gen(function* () { const { join } = yield* Path.Path; - const baseDir = join(NodeOS.tmpdir(), "t3-cli-config-otlp-headers-plaintext-base"); + const baseDir = join(NodeOS.tmpdir(), "t3-cli-config-otlp-headers-loose-base"); - const exit = yield* resolveServerConfig( + const resolved = yield* resolveServerConfig( { mode: Option.some("web"), port: Option.some(3773), @@ -793,7 +791,7 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { ConfigProvider.layer( ConfigProvider.fromEnv({ env: { - T3CODE_OTLP_HEADERS: "authorization=Basic%20abc%3D%3D,x-tenant=t3", + T3CODE_OTLP_HEADERS: "authorization=Bearer abc==, x-tenant=t3", T3CODE_OTLP_TRACES_URL: "http://collector.internal:4318", }, }), @@ -801,15 +799,13 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { NetService.layer, ), ), - Effect.exit, ); - assert.isTrue(Exit.isFailure(exit)); - if (Exit.isFailure(exit)) { - const error = Cause.squash(exit.cause); - assert.instanceOf(error, Error); - expect((error as Error).message).toContain("http://collector.internal:4318"); - } + expect(resolved.otlpHeaders).toEqual({ + authorization: "Bearer abc==", + "x-tenant": "t3", + }); + expect(resolved.otlpTracesUrl).toBe("http://collector.internal:4318"); }), ); }); diff --git a/apps/server/src/cli/config.ts b/apps/server/src/cli/config.ts index 6e4bd6c9610b..4881b76ce94e 100644 --- a/apps/server/src/cli/config.ts +++ b/apps/server/src/cli/config.ts @@ -1,4 +1,5 @@ import * as NetService from "@t3tools/shared/Net"; +import { OtlpHeadersFromString } from "@t3tools/shared/observability"; import { parsePersistedServerObservabilitySettings } from "@t3tools/shared/serverSettings"; import { DesktopBackendBootstrap, PortSchema } from "@t3tools/contracts"; import * as Config from "effect/Config"; @@ -17,7 +18,6 @@ import { Argument, Flag } from "effect/unstable/cli"; import { readBootstrapEnvelope } from "../bootstrap.ts"; import * as ServerConfig from "../config.ts"; import { expandHomePath, resolveBaseDir } from "../os-jank.ts"; -import { otlpHeadersTransportIssue } from "@t3tools/shared/observability"; const modeFlag = Flag.choice("mode", ServerConfig.RuntimeMode.literals).pipe( Flag.withDescription("Runtime mode. `desktop` keeps loopback defaults unless overridden."), @@ -100,10 +100,10 @@ const EnvServerConfig = Config.all({ Config.withDefault(10_000), ), otlpServiceName: Config.string("T3CODE_OTLP_SERVICE_NAME").pipe(Config.withDefault("t3-server")), - otlpHeaders: Config.schema( - Config.Record(Schema.String, Schema.StringFromUriComponent), - "T3CODE_OTLP_HEADERS", - ).pipe(Config.option, Config.map(Option.getOrUndefined)), + otlpHeaders: Config.schema(OtlpHeadersFromString, "T3CODE_OTLP_HEADERS").pipe( + Config.option, + Config.map(Option.getOrUndefined), + ), mode: Config.schema(ServerConfig.RuntimeMode, "T3CODE_MODE").pipe( Config.option, Config.map(Option.getOrUndefined), @@ -416,14 +416,6 @@ export const resolveServerConfig = ( tailscaleServePort, }; - const transportIssue = otlpHeadersTransportIssue(config.otlpHeaders, [ - config.otlpTracesUrl, - config.otlpMetricsUrl, - ]); - if (transportIssue) { - return yield* Effect.die(new Error(transportIssue)); - } - return config; }); diff --git a/docs/operations/observability.md b/docs/operations/observability.md index d35177756391..287520459c61 100644 --- a/docs/operations/observability.md +++ b/docs/operations/observability.md @@ -530,7 +530,6 @@ OTLP export: - `T3CODE_OTLP_SERVICE_NAME`: service name, default `t3-server` - `T3CODE_OTLP_HEADERS`: extra headers for both exporters, same format as `OTEL_EXPORTER_OTLP_HEADERS`: comma-separated `key=value` pairs with percent-encoded values. - Refused on non-loopback http:// endpoints. If the OTLP URLs are unset, local tracing still works and metrics stay in-process only. diff --git a/packages/shared/src/observability.test.ts b/packages/shared/src/observability.test.ts index 42297e76c5e1..081291f34b86 100644 --- a/packages/shared/src/observability.test.ts +++ b/packages/shared/src/observability.test.ts @@ -21,8 +21,8 @@ import { makeTraceSink, type TraceRecord, type TraceSinkFlushStats, + OtlpHeadersFromString, truncateTraceAttributes, - otlpHeadersTransportIssue, } from "./observability.ts"; describe("errorTag", () => { @@ -461,45 +461,39 @@ describe("observability", () => { }); }); -describe("otlpHeadersTransportIssue", () => { - const headers = { authorization: "Bearer my-token" }; - - it("allows https endpoints", () => { - expect( - otlpHeadersTransportIssue(headers, ["https://api.example.com/v1/traces", undefined]), - ).toBeUndefined(); - }); - - it("allows loopback http endpoints", () => { - expect( - otlpHeadersTransportIssue(headers, [ - "http://localhost:4318/v1/traces", - "http://127.0.0.1:4318/v1/traces", - "http://127.0.0.2:4318/v1/traces", - "http://127.255.255.255:4318/v1/traces", - "http://[::1]:4318/v1/metrics", - ]), - ).toBeUndefined(); - }); - - it("refuses a plaintext non-loopback endpoint even when another is https", () => { - expect( - otlpHeadersTransportIssue(headers, [ - "https://api.example.com/v1/traces", - "http://collector.internal:4318/v1/metrics", - ]), - ).toContain("http://collector.internal:4318"); - }); - - it("ignores endpoints when no headers are configured", () => { - expect( - otlpHeadersTransportIssue(undefined, ["http://collector.internal:4318/v1/traces"]), - ).toBeUndefined(); +describe("OtlpHeadersFromString", () => { + const decode = Schema.decodeUnknownSync(OtlpHeadersFromString); + + it.each([ + { + name: "decodes percent-encoded values", + input: "authorization=Basic%20abc%3D%3D,x-tenant=t3", + expected: { authorization: "Basic abc==", "x-tenant": "t3" }, + }, + { + name: "ignores whitespace around separators", + input: "authorization=Basic%20abc%3D%3D, x-tenant = t3 ,", + expected: { authorization: "Basic abc==", "x-tenant": "t3" }, + }, + { + name: "keeps literal equals signs inside a value", + input: "authorization=Bearer abc==", + expected: { authorization: "Bearer abc==" }, + }, + { + name: "keeps an empty value", + input: "x-empty=", + expected: { "x-empty": "" }, + }, + ])("$name", ({ input, expected }) => { + expect(decode(input)).toEqual(expected); }); - it("refuses a DNS name that merely starts with 127.", () => { - expect( - otlpHeadersTransportIssue(headers, ["http://127.attacker.example:4318/v1/traces"]), - ).toContain("http://127.attacker.example:4318"); + it.each([ + { name: "rejects a pair without a separator", input: "authorization" }, + { name: "rejects a pair without a key", input: "=value" }, + { name: "rejects a malformed percent-encoding", input: "authorization=%E0" }, + ])("$name", ({ input }) => { + expect(() => decode(input)).toThrow(); }); }); diff --git a/packages/shared/src/observability.ts b/packages/shared/src/observability.ts index 3cd1e17e8f3d..b17ee253a78d 100644 --- a/packages/shared/src/observability.ts +++ b/packages/shared/src/observability.ts @@ -1,9 +1,11 @@ -import * as NodeNet from "node:net"; import * as Cause from "effect/Cause"; import * as Effect from "effect/Effect"; import type * as Exit from "effect/Exit"; import * as ExitRuntime from "effect/Exit"; import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; +import * as SchemaIssue from "effect/SchemaIssue"; +import * as SchemaTransformation from "effect/SchemaTransformation"; import * as Tracer from "effect/Tracer"; import { OtlpResource, OtlpTracer } from "effect/unstable/observability"; @@ -685,33 +687,50 @@ function parseBigInt(input: string): bigint { } } -const isLoopbackHost = (hostname: string) => { - if (hostname === "localhost" || hostname === "[::1]") { - return true; - } - - // match only 127.0.0.0/8, not any host that starts with 127. - return NodeNet.isIPv4(hostname) && hostname.startsWith("127."); -}; - -export const otlpHeadersTransportIssue = ( - headers: Readonly> | undefined, - urls: ReadonlyArray, -): string | undefined => { - if (!headers) { - return undefined; - } - - for (const rawUrl of urls) { - if (!rawUrl) { - continue; - } - - const url = new URL(rawUrl); - if (url.protocol === "http:" && !isLoopbackHost(url.hostname)) { - return `T3CODE_OTLP_HEADERS would be sent in plaintext to ${url.origin}. Use an https:// or a loopback http:// endpoint.`; - } - } - - return undefined; -}; +/** + * Parses the `OTEL_EXPORTER_OTLP_HEADERS` wire format used by + * `T3CODE_OTLP_HEADERS`: W3C Baggage `key=value` pairs joined by commas, with + * percent-encoded values. Each pair splits at its first `=` so an encoded or + * literal `=` inside a value survives, and whitespace around the separators is + * ignored. + */ +export const OtlpHeadersFromString = Schema.String.pipe( + Schema.decodeTo( + Schema.Record(Schema.String, Schema.String), + SchemaTransformation.transformOrFail({ + decode: (input) => { + const headers: Record = {}; + for (const pair of input.split(",")) { + if (pair.trim() === "") { + continue; + } + const separator = pair.indexOf("="); + const key = separator === -1 ? "" : pair.slice(0, separator).trim(); + if (key === "") { + return Effect.fail( + new SchemaIssue.InvalidValue({ + message: `Expected key=value but received ${JSON.stringify(pair.trim())}.`, + }), + ); + } + try { + headers[key] = decodeURIComponent(pair.slice(separator + 1).trim()); + } catch { + return Effect.fail( + new SchemaIssue.InvalidValue({ + message: `Header ${JSON.stringify(key)} has a malformed percent-encoded value.`, + }), + ); + } + } + return Effect.succeed(headers); + }, + encode: (headers) => + Effect.succeed( + Object.entries(headers) + .map(([key, value]) => `${key}=${encodeURIComponent(value)}`) + .join(","), + ), + }), + ), +);