Skip to content

HttpApiClient: Redacted query and header values appear in plain text in client spans #9882

Open

Description

@fubhy

Follow-up to #9838. #9855 fixed HttpClientError, so an error no longer prints the request. Client spans have the same cause and still record the secrets: a Redacted query value appears in plain text in url.full and url.query, and a Redacted header appears in http.request.header.* once header capture is on.

import { Effect, Layer, Redacted, Schema, Tracer } from "effect"
import { HttpClient, HttpClientResponse } from "effect/http"
import { HttpApi, HttpApiClient, HttpApiEndpoint, HttpApiGroup } from "effect/http-api"

const Api = HttpApi.make("api").add(
  HttpApiGroup.make("g").add(
    HttpApiEndpoint.post("join", "/join", {
      query: { token: Schema.RedactedFromValue(Schema.String) },
      headers: { "x-token": Schema.RedactedFromValue(Schema.String) }
    })
  )
)

// a client whose every request succeeds
const ok = Layer.succeed(
  HttpClient.HttpClient,
  HttpClient.make((request) => Effect.succeed(HttpClientResponse.fromWeb(request, new Response(null, { status: 204 }))))
)

// a tracer that records span attributes
const attributes: Array<[string, unknown]> = []
const tracer = Tracer.make({
  span: (options) => {
    const span = new Tracer.NativeSpan(options)
    const attribute = span.attribute.bind(span)
    span.attribute = (key, value) => {
      attributes.push([key, value])
      attribute(key, value)
    }
    return span
  }
})

const program = Effect.gen(function*() {
  const client = yield* HttpApiClient.make(Api, { baseUrl: "http://x" })
  const secret = Redacted.make("s3cret")
  yield* client.g.join({ query: { token: secret }, headers: { "x-token": secret } })
  for (const [key, value] of attributes) if (String(value).includes("s3cret")) console.log(key, value)
})

Effect.runPromise(
  program.pipe(
    Effect.provide(ok),
    Effect.withTracer(tracer),
    // header capture is opt-in; without this, only the query leaks
    Effect.provideService(HttpClient.TracerHeaderFilter, () => true)
  )
)

Output:

url.full http://x/join?token=s3cret
url.query token=s3cret
http.request.header.x-token s3cret

The same token passed straight to HttpClientRequest.setUrlParam as a Redacted is recorded as <redacted>.

Cause

As in #9838, HttpApiClient encodes the query and the headers with their schemas, which turns a Redacted into its plain value, and puts the encoded values into the request (HttpApiClient.ts L552–L562). The client span then redacts only by name (HttpClient.ts L731–L739):

  • Query parameters: the names in UrlParams.CurrentRedactedNames, plus the names of parameters whose value is still Redacted (tracing.ts L40–L52). After encoding, none are.
  • Headers: the names in Headers.CurrentRedactedNames.

The endpoint's schema still knows which fields are Redacted. Bodies are not recorded in spans, so only the query and the headers are affected.

Possible fix

HttpApiClient could collect, per endpoint, the query and header fields whose schema contains a Redacted, and add them to UrlParams.CurrentRedactedNames and Headers.CurrentRedactedNames while it executes that endpoint's request. Alternatively, for the query, it could keep those values Redacted in the request's UrlParams, which the span already redacts.

Workaround

Add the names yourself around the client:

const withRedactedNames = <A, E, R>(effect: Effect.Effect<A, E, R>) =>
  Effect.flatMap(
    Effect.all([Effect.service(UrlParams.CurrentRedactedNames), Effect.service(Headers.CurrentRedactedNames)]),
    ([urlParams, headers]) =>
      effect.pipe(
        Effect.provideService(UrlParams.CurrentRedactedNames, [...urlParams, "token"]),
        Effect.provideService(Headers.CurrentRedactedNames, [...headers, "x-token"])
      )
  )

// HttpApiClient.make(Api, { transformClient: HttpClient.transform(withRedactedNames) })

The Docker Engine client I'm building does this for its registry headers and secret query parameters.

Activity

  1. fubhy commented on Oct 25, 2026

    @fubhy
    MemberAuthor

    A related case for the fix: secrets that aren't modelled as Redacted, because the value is plain data that only sometimes holds a secret. Docker's Engine API has several in its query:

    • POST /build?buildargs={"NPM_TOKEN":"…"}: a JSON map of build arguments.
    • POST /build?remote=https://user:pass@host/repo.git and POST /images/create?fromSrc=https://…?X-Amz-Signature=…: URLs with credentials or a presigned signature.
    • changes on commit and import: Dockerfile instructions such as ENV TOKEN=….

    Making these Redacted would change the types callers build and read, only to keep the values out of traces. My client lists the wire names around every request instead (the workaround above), separately from the declaration.

    Proposal: let the fix's rule ("fields whose schema contains a Redacted") also count fields annotated as sensitive, so they are declared next to the field:

    query: {
      buildargs: Schema.optional(BuildArgs.pipe(HttpApiSchema.sensitive)),
      remote: Schema.optional(Schema.String.pipe(HttpApiSchema.sensitive))
    }

    The client would add the annotated wire names to UrlParams.CurrentRedactedNames and Headers.CurrentRedactedNames together with the Redacted ones. HttpApiBuilder could do the same on the server, for the tracer middleware and request logs.

  2. fubhy commented on Oct 25, 2026

    @fubhy
    MemberAuthor

    @effect-bot see above

  3. effect-bot commented on Oct 25, 2026

    @effect-bot
    Contributor

    Thanks. I've added your proposal to the work tracking this issue. It covers fields annotated as sensitive (e.g. HttpApiSchema.sensitive) being redacted alongside the Redacted ones on the client, and possibly the same for HttpApiBuilder on the server. The follow-up will build on the open fix in #9889.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions