Skip to content

fix(core): preserve HttpClientTimeout in Server via ClientTimeout field - #52

Open
spbsoluble wants to merge 10 commits into
mainfrom
fix/server-client-timeout
Open

fix(core): preserve HttpClientTimeout in Server via ClientTimeout field#52
spbsoluble wants to merge 10 commits into
mainfrom
fix/server-client-timeout

Conversation

@spbsoluble

Copy link
Copy Markdown
Collaborator

Closes #51.

Problem

Server has no client-timeout field, so every GetServerConfig() implementation silently drops CommandAuthConfig.HttpClientTimeout. Downstream consumers that authenticate once and rebuild a client from the returned *Server (keyfactor-go-client v3 NewKeyfactorClient, keyfactor-go-client-sdk NewAPIClient) lose the configured timeout and fall back to the 60s DefaultClientTimeout — all transport timeouts (ResponseHeaderTimeout et al.) derive from it in BuildTransport()/SetClient(). Real-world symptom: terraform-provider-keyfactor's request_timeout = 300 still produced net/http: timeout awaiting response headers at ~60s on slow PFX enrollments.

Fix

  • Add Server.ClientTimeout (client_timeout json/yaml).
  • Populate it from HttpClientTimeout in all four GetServerConfig() implementations (core, basic, oauth, kerberos).
  • Honor it in the reverse direction (GetBasicAuthClientConfig/GetOAuthClientConfig/GetKerberosClientConfig) so round-trips are lossless.

Tests

5 new tests: WithClientTimeout(300)GetServerConfig().ClientTimeout == 300 round-trips for each auth type, and BuildTransport() uses the configured value for ResponseHeaderTimeout. Red before the fix (field absent), green after.

Validation

RC tag v1.6.0-rc.2 was cut from this branch and validated through the full downstream chain: keyfactor-go-client v3.6.0-rc.1 and keyfactor-go-client-sdk v24.1.2-rc.1 build and test green against it, and terraform-provider-keyfactor's full unit suite (323 pass / 0 fail) passes against the published RCs with no local replaces.

Release note: v1.6.0-rc.0/-rc.1 were cut from the (unmerged) config-loader feature branch; this branch does not contain that work. Final v1.6.0 should be cut from main after both lines merge.

…ld (fixes #51)

GetServerConfig() on CommandAuthConfig (and its basic/oauth/kerberos
embedders) dropped the caller's HttpClientTimeout entirely: Server had no
timeout field, so any WithClientTimeout() value set via
CommandAuthConfig.WithClientTimeout was lost once the config was flattened
to a Server for downstream consumers (e.g. keyfactor-go-client's
NewKeyfactorClient, which rebuilds its own CommandAuthConfig from a
Server). Consumers silently fell back to DefaultClientTimeout (60s),
producing "net/http: timeout awaiting response headers" on long-running
calls such as PFX enrollment even when a much larger timeout was
explicitly configured upstream.

Add Server.ClientTimeout (client_timeout json/yaml tag) and populate it
from HttpClientTimeout in all four GetServerConfig() implementations
(core, basic, oauth, kerberos). Also honor it in the reverse direction --
GetBasicAuthClientConfig/GetOAuthClientConfig/GetKerberosClientConfig now
call WithClientTimeout(s.ClientTimeout) so a Server round-trips losslessly
back into a CommandAuthConfig-derived config.
…o default

LoadConfig merged Host/Port/APIPath/CACertPath/SkipVerify from a loaded
Server into CommandAuthConfig but never ClientTimeout, and
ValidateAuthConfig never consulted FileConfig as a fallback for
HttpClientTimeout the way it already does for CommandHostName. A
config-file-only client_timeout was silently dropped, landing on the
60s default instead.

Separately, ValidateAuthConfig's env var fallback treated any
LookupEnv ok=true (including an empty string, common when .env files
pre-declare all KEYFACTOR_* vars) as authoritative, swallowing Atoi
errors and skipping the default. An empty/unparseable/non-positive
KEYFACTOR_CLIENT_TIMEOUT left HttpClientTimeout at its zero value,
which disables ResponseHeaderTimeout/TLSHandshakeTimeout/
IdleConnTimeout/http.Client.Timeout entirely -- an unbounded-wait
hazard since Authenticate() has no request context to otherwise bound
the call.

Now: explicit struct value/WithClientTimeout() wins outright; absent
that, a config file value (merged eagerly in LoadConfig, consistent
with the other Server fields, and consulted again in ValidateAuthConfig
as a defensive fallback like CommandHostName) takes effect before the
env var is ever checked; an unparseable or <=0 env var is logged and
ignored rather than silently zeroing the timeout; and the package
default applies only when nothing else resolved a positive value.

Basic, Kerberos, and OAuth auth types all delegate to
CommandAuthConfig.LoadConfig/ValidateAuthConfig, so no separate
per-type fix was needed.
… idle/handshake timeouts with HttpClientTimeout

RequestToCurl appended the full, unredacted request body to the curl
command it generates for TRACE logging (auth_oauth.go's oauth2Transport
RoundTrip logs this on every OAuth-authenticated request, and the auth
probe path does the same). Any secret-bearing request -- e.g. a PFX
enrollment carrying a private-key password -- was therefore written to
the log in plaintext whenever TRACE logging is enabled, which is exactly
what support asks a customer to turn on when reporting the slow-request
issue this timeout work exists to fix. RequestToCurl now parses JSON and
form-encoded bodies and replaces known-sensitive field values (password,
secret, token, and private-key variants, matched case-insensitively,
nested objects/arrays included) with a placeholder while preserving the
rest of the body for diagnostics. A body that can't be confidently
classified as JSON or form-encoded is omitted entirely behind a
"<redacted: N bytes, content-type X>" marker rather than ever risking a
raw secret leak.

Separately, BuildTransport() and SetClient() derived IdleConnTimeout and
ExpectContinueTimeout from the same HttpClientTimeout value used for the
request deadline (ResponseHeaderTimeout). IdleConnTimeout governs how
long an idle pooled connection is retained, not a request deadline, so a
large configured timeout (e.g. 1800s, needed for slow PFX enrollments)
kept every idle socket -- and its goroutine -- alive for that same
duration; a large `terraform apply` issuing many sequential requests
could hold open hundreds of sockets/goroutines for half an hour.
IdleConnTimeout, ExpectContinueTimeout, and TLSHandshakeTimeout are now
pinned to fixed defaults matching net/http.DefaultTransport (90s/1s/10s)
via a shared newHTTPTransport() constructor used by both BuildTransport
and SetClient, while ResponseHeaderTimeout continues to track
HttpClientTimeout as intended.
newHTTPTransport() hardcoded MaxConnsPerHost: 10, which was harmless
while every request built its own throwaway transport. Now that
consumers cache and reuse a single *http.Client/*http.Transport (to
fix a socket-leak bug), that cap becomes a hard, unqueued-timeout
ceiling of 10 concurrent in-flight requests per host for the life of
the process -- e.g. terraform apply -parallelism=25 silently
serializes into batches of 10 with no bound on queue wait, since the
client has Timeout: 0 and requests carry no context deadline.

Set MaxConnsPerHost to 0 (unbounded, matching
net/http.DefaultTransport) while leaving the idle-connection pool
limits (MaxIdleConns/MaxIdleConnsPerHost) unchanged.
…hadows the env var

GetServerConfig() serialized the resolved HttpClientTimeout verbatim,
including the 60s value ValidateAuthConfig synthesizes when nothing
was configured. Callers that persist GetServerConfig()'s output to a
config file (e.g. kfutil's login flow, which writes to
~/.keyfactor/command_config.json) therefore always wrote
client_timeout: 60 to disk even when the user chose nothing.

On the next run, LoadConfig merges that file value into
HttpClientTimeout before ValidateAuthConfig runs (mirroring how
Host/Port/etc. are merged), so ValidateAuthConfig's
`if c.HttpClientTimeout <= 0` guard was already false and the
KEYFACTOR_CLIENT_TIMEOUT env var branch was skipped -- permanently and
silently shadowing the env var. This is a regression: the env var
always worked before Server gained a ClientTimeout field to persist.

Track whether HttpClientTimeout's value was synthesized by the
package-default fallback (new unexported clientTimeoutDefaulted field,
cleared by WithClientTimeout) versus explicitly configured, and have
GetServerConfig() omit ClientTimeout (via its existing omitempty tag)
whenever it was only defaulted. Explicit values (struct/
WithClientTimeout(), env var, or an existing file value) are still
persisted, and the round-1 precedence order is unchanged.
The request-body redactor only inspected each JSON value's own key
against sensitiveBodyKeys and never re-parsed string values that were
themselves JSON documents, leaving two confirmed leak paths:

- keyfactor-go-client v3 marshals a certificate store's Properties map
  into a JSON-encoded STRING field. terraform-provider-keyfactor puts
  ServerPassword in that map (and for K8S store types this field can
  carry an entire kubeconfig/service-account token), so it was emitted
  verbatim in generated curl commands.
- PAM provider creation carries its secret under the generic key
  "Value", nested under ProviderTypeParamValues. "value" wasn't in
  sensitiveBodyKeys, so a Vault token/Delinea password was logged
  verbatim.

redactJSONValue now recognizes string values that look like a JSON
document (balanced outer brackets), re-parses and redacts them
recursively, and re-serializes the result -- bounded by a depth limit
(maxNestedJSONStringDepth) and size limit (maxNestedJSONStringLen) to
bound the cost of adversarial nesting. A string that looks like JSON
but fails to parse, or that hits either guard, is redacted in its
entirety rather than ever emitted raw.

sensitiveBodyKeys gains serverpassword, storepassword, newpassword,
relaypassword, pkcs12blob, and value. "value" is blanket-redacted
(rather than only within a credential-bearing parent) since this
redactor walks structure without tracking its ancestry, and a
parent-key allowlist would still miss future generic-"Value" secret
fields; "properties" is deliberately NOT added, since blanket-hiding
it would erase non-secret store configuration -- the nested-JSON-string
handling above already redacts secrets within it while preserving the
rest of its structure.
CommandAuthConfigBasic.GetServerConfig() shadows the embedded
CommandAuthConfig method that round 2 fixed to skip persisting a
ValidateAuthConfig-synthesized default ClientTimeout. Since
CommandAuthConfigBasic is what real basic-auth callers actually
construct, the round-2 fix never took effect for them. Delegate to
the embedded GetServerConfig() for the correctly-gated ClientTimeout
and layer basic-auth-specific fields on top.
…ation

CommandAuthConfigKerberos.GetServerConfig() shadows the embedded
CommandAuthConfig method that round 2 fixed to skip persisting a
ValidateAuthConfig-synthesized default ClientTimeout, so the fix never
took effect for real Kerberos callers. Delegate to the embedded
GetServerConfig() for the correctly-gated ClientTimeout and layer
Kerberos-specific fields on top.
CommandConfigOauth.GetServerConfig() shadows the embedded
CommandAuthConfig method that round 2 fixed to skip persisting a
ValidateAuthConfig-synthesized default ClientTimeout, so the fix never
took effect for real OAuth callers. Delegate to the embedded
GetServerConfig() for the correctly-gated ClientTimeout and layer
OAuth-specific fields on top.
…heck

looksLikeJSONDocument only inspected the first/last byte after
strings.TrimSpace to decide whether a nested string value looked like
JSON worth re-parsing and redacting. TrimSpace does not strip a
U+FEFF byte-order-mark, so a nested JSON-encoded string value
prefixed with a BOM (e.g. a PAM/orchestrator service-account JSON key
embedded in a Properties map value, plausible from a
Windows-authored file) was judged "not JSON" and returned completely
unredacted. encoding/json also rejects a leading BOM outright rather
than tolerating it, so the fix strips the BOM explicitly before both
the heuristic check and the actual json.Unmarshal call.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Server struct drops HttpClientTimeout: GetServerConfig() loses configured client timeout, downstream clients fall back to 60s default

1 participant