Repository navigation
Description
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 stillRedacted(tracing.tsL40–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
fubhy commentedon Oct 25, 2026
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.gitandPOST /images/create?fromSrc=https://…?X-Amz-Signature=…: URLs with credentials or a presigned signature.changeson commit and import: Dockerfile instructions such asENV TOKEN=….Making these
Redactedwould 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:The client would add the annotated wire names to
UrlParams.CurrentRedactedNamesandHeaders.CurrentRedactedNamestogether with theRedactedones.HttpApiBuildercould do the same on the server, for the tracer middleware and request logs.fubhy commentedon Oct 25, 2026
@effect-bot see above
effect-bot commentedon Oct 25, 2026
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 theRedactedones on the client, and possibly the same forHttpApiBuilderon the server. The follow-up will build on the open fix in #9889.