fix(redis): decouple TLS from auth so on-prem can require a password - #875
fix(redis): decouple TLS from auth so on-prem can require a password#875scalejeff wants to merge 4 commits into
Conversation
On-prem Redis is plaintext but still requires a credential. The Redis helpers
treated the presence of REDIS_AUTH_TOKEN as proof of in-transit encryption, so
any attempt to force auth on-prem produced a rediss:// handshake against a
plaintext server. Meanwhile the on-prem config paths never applied the token at
all, and cache_redis_host_port only stripped credentials from rediss:// URLs.
core/celery/app.py:
- Add redis_tls_enabled(), driven by REDIS_ENABLE_TLS. When unset it falls
back to bool(REDIS_AUTH_TOKEN), preserving today's ElastiCache behaviour.
- Extract build_redis_url() so scheme and credential are chosen
independently, and route get_redis_endpoint() through it.
- get_redis_instance() applies password and ssl=True separately.
common/config.py:
- Add _apply_redis_auth_token(), applied to all three on-prem branches of
cache_redis_url including the early cache_redis_onprem_url return. This
keeps the password out of Helm values: the chart names host and db index,
the app supplies the credential from its secret-backed env var. URLs that
already carry userinfo are left alone.
- Make cache_redis_host_port scheme-agnostic. It feeds the KEDA scaler's
address metadata, so a password surviving in it would both break the
address and leak into the ScaledObject.
Passwords are now percent-encoded. kombu and redis-py both unquote userinfo, so
the wire-level password is unchanged for AWS, while tokens containing @, / or #
stop corrupting URL parsing: unencoded, a '#' truncates the URL at the fragment
and both parsers then raise ValueError on the mangled port.
Beyond the minimum fix:
- get_async_redis_instance() and RedisBroker._init_client() built
passwordless URLs on every cloud, so both would fail NOAUTH against an
authenticated broker. Both now use build_redis_url().
Chart:
- Extract the Redis credential env into a shared modelEngine.redisAuthEnv
helper and include it from both the gateway/builder/cacher env and the
celery autoscaler StatefulSet. The autoscaler had no REDIS_AUTH_TOKEN at
all, so fixing RedisBroker._init_client() alone would have left it
connecting anonymously.
- Emit REDIS_ENABLE_TLS from the same redis.enableTLS value that feeds the
KEDA scaler, so chart and app cannot drift. The gate forwards any set
value, including a stringified bool from a desired-state value override;
gating on a real bool would have silently dropped "false" and left the app
inferring TLS while the scaler ran plaintext.
- redis.enableTLS now defaults to unset rather than false. Emitting it
unconditionally would have sent REDIS_ENABLE_TLS=false to every install,
silently downgrading an ElastiCache deployment with an auth token from TLS
to plaintext. The scaler gets `| default false` so its rendered output is
byte-identical to before when the value is unset.
Verified: ruff 0.6.8, black 24.8.0 and helm lint clean; app env and scaler
metadata agree for bool, stringified and unset enableTLS, and the unset render
is byte-identical to before. Note that tests/unit/conftest.py needs fastapi,
which is not installed locally, so the new tests were exercised against the
committed function bodies via an import shim (22/22 pass, and the 4 assertions
covering cache_redis_host_port and get_redis_instance fail against the pre-fix
bodies) rather than in-tree -- they still need one CI run to confirm the
import path.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
REDIS_ENABLE_TLS was only emitted when redis.enableTLS was explicitly set. An install that configures a credential and leaves the value alone therefore left the app inferring TLS from the credential while the KEDA scaler fell back to plaintext, so the scaler talked cleartext to a TLS endpoint and collected no queue metrics. Resolve the scheme once in modelEngine.redisEnableTLS and feed both the app env and the scaler from it. The unset case mirrors the app's own fallback -- a configured credential implies TLS -- so the two cannot disagree. The app's resolved scheme is unchanged in every configuration. Only the scaler moves, and only where a credential is set without an explicit enableTLS, which is exactly the case where it was misconfigured. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A token can reach the pod through the extraEnvVars hook, which the chart cannot see. Emitting REDIS_ENABLE_TLS unconditionally sent an explicit false in that case, overriding the app's token-based inference and forcing plaintext against a TLS-only Redis. Emit only when the chart can actually determine the scheme -- enableTLS set explicitly, or a credential configured through redis.auth/authSecretName -- and otherwise leave the app's inference intact. The KEDA scaler still resolves a concrete value from the same helper, since it has no inference of its own. All six auth/enableTLS combinations still agree. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
| listLength: "100" # something absurdly high so we don't scale past 1 pod | ||
| activationListLength: "0" | ||
| enableTLS: "{{ .Values.redis.enableTLS }}" | ||
| enableTLS: "{{ include "modelEngine.redisEnableTLS" . }}" |
There was a problem hiding this comment.
Can you double check whether this regresses AWS? Specifically enableTLS: "{{ include "modelEngine.redisEnableTLS" . }}".
The concern is that the scaler and the app connect to two different Redis instances. This trigger's address is ${REDIS_HOST_PORT} = hmi_config.cache_redis_host_port (k8s_resource_types.py:1368), which derives from cache_redis_url → on AWS that's cache_redis_aws_url verbatim, untouched by this PR (config.py:150). But redisEnableTLS infers TLS from redis.auth/authSecretName, which is the message broker's credential — a different endpoint.
The app connects to the cache using whatever scheme cache_redis_aws_url names, and that path never gets a credential injected on AWS. So if that URL is redis://, the cache accepts plaintext, and telling the scaler TLS=true is wrong for the same endpoint. Both shipped examples are plaintext, on a host distinct from the broker:
values_sample.yaml:263→redis://llm-engine-prod-cache.use1.cache.amazonaws.com:6379/15values_circleci.yaml:172→redis://redis-message-broker-master.default/15
Rendered with values_circleci.yaml --set redis.auth=… and enableTLS unset: main gives enableTLS: "false", this branch gives "true". If that's the shape of a real install, KEDA's handshake against a plaintext cache fails, the redis trigger reports no queue length, and async endpoints stop scaling off the activation threshold. AWS does take this branch — the servicebus alternative is gated on $azure_cloud_provider (:497).
| # On-prem Redis support (explicit URL, no cloud provider dependency) | ||
| if self.cache_redis_onprem_url: | ||
| return self.cache_redis_onprem_url | ||
| return _apply_redis_auth_token(self.cache_redis_onprem_url) |
There was a problem hiding this comment.
Can you check if Injecting the credential into cache_redis_url leaks it into logs?
entrypoints/k8s_cache.py:108 does logger.info(f"Using cache redis url {redis_url}") with no debug_mode guard, and the cacher Deployment runs exactly that entrypoint (cacher_deployment.yaml:61-64) with REDIS_AUTH_TOKEN in its env. On main the on-prem URL carried no credential, so this is new exposure — on every cacher startup, into Datadog. service_builder/tasks_v1.py:158 repeats it behind debug_mode.
neelaypandit-scale
left a comment
There was a problem hiding this comment.
otherwise looks good, just flagged 2 things, if you can just take a second look and dismiss if they are moot, I can approve
Pull Request Summary
On-prem Redis is plaintext but still requires a credential. The Redis helpers treated the presence of
REDIS_AUTH_TOKENas proof of in-transit encryption, so forcing auth on-prem produced arediss://handshake against a plaintext server. Separately, the on-prem config paths never applied the token at all, andcache_redis_host_portonly stripped credentials fromrediss://URLs — so the moment a plaintext URL carried:password@, it returned:password@host:6379and leaked the password into the KEDA ScaledObject'saddress.core/celery/app.pyredis_tls_enabled(), driven byREDIS_ENABLE_TLS. Unset falls back tobool(REDIS_AUTH_TOKEN), preserving today's ElastiCache behaviour.build_redis_url()so scheme and credential are chosen independently; routeget_redis_endpoint()through it.get_redis_instance()appliespasswordandssl=Trueseparately.common/config.py_apply_redis_auth_token(), applied to all three on-prem branches ofcache_redis_urlincluding the earlycache_redis_onprem_urlreturn. This keeps the password out of Helm values: the chart names host and db index, the app supplies the credential from its secret-backed env var. URLs already carrying userinfo are left alone.cache_redis_host_portscheme-agnostic.Passwords are percent-encoded. kombu and redis-py both unquote userinfo, so the wire-level password is unchanged for AWS, while tokens containing
@,/or#stop corrupting URL parsing — unencoded, a#truncates at the fragment and both parsers then raiseValueErroron the mangled port.Beyond the minimum fix
get_async_redis_instance()andRedisBroker._init_client()built passwordless URLs on every cloud, so both would failNOAUTHagainst an authenticated broker. Both now usebuild_redis_url().Chart
modelEngine.redisAuthEnvhelper, included from both the gateway/builder/cacher env and the celery autoscaler StatefulSet. The autoscaler had noREDIS_AUTH_TOKENat all, so fixingRedisBroker._init_client()alone would have left it connecting anonymously.modelEngine.redisEnableTLSand feed both the app env and the KEDA scaler from it, so they cannot disagree. Where no explicitenableTLSis given, a credential configured viaredis.auth/authSecretNameimplies TLS — mirroring the app's own fallback.REDIS_ENABLE_TLSentirely when the chart cannot determine it. A token supplied through theextraEnvVarshook is invisible to the chart, and emitting an explicitfalsethere would override the app's inference and force plaintext against a TLS-only Redis.redis.enableTLSdefaults to unset rather thanfalse. Emittingfalseunconditionally would have downgraded an ElastiCache install with an auth token from TLS to plaintext.Test Plan and Usage Guide
Chart. Rendered
mainand this branch across the fullredis.auth×redis.enableTLSmatrix, comparing the app's resolved scheme against the scaler'senableTLS:maintls=truetls=falsetls=truetls=falsemainhad three drifting cases; all six now agree. The app's resolved scheme is unchanged in every configuration — case B still yieldsrediss://, so ElastiCache installs with a token and no explicitenableTLSare untouched. Only the scaler moves, and only in B, where it was talking cleartext to a TLS endpoint and silently collecting no queue metrics.Also verified: stringified booleans pass through (desired-state value overrides can produce them, and gating on a real bool would have silently dropped
"false");authSecretNameimplies TLS exactly asauthdoes; the autoscaler StatefulSet is wired;helm lintpasses.Python. ruff 0.6.8 and black 24.8.0 clean. 22 new tests in
tests/unit/common/test_redis_auth.pycovering TLS/auth decoupling, token injection,cache_redis_host_portacross six URL shapes, andget_redis_instanceacross all four password/TLS combinations.Note
The new tests were exercised against the committed function bodies via an import shim rather than in-tree, because
tests/unit/conftest.pyneedsfastapiand the deps were not installed locally. 22/22 pass, and the 4 assertions coveringcache_redis_host_portandget_redis_instancefail against the pre-fix bodies, so they are genuine regression tests. They still need one CI run to confirm the in-tree import path.Known pre-existing issues, deliberately not fixed here
Both were flagged in review and verified against
mainas predating this PR. Called out rather than bundled in, to keep this change's "no AWS behaviour change" property auditable:ssl_cert_reqs=nonemeans Redis TLS certificates are never verified. Present onmain(lines 214, 229) and preserved verbatim. Fixing it properly is a deploy-time behaviour change for AWS and deserves its own PR. Worth tracking — it is a real MITM exposure.get_redis_endpoint()does not percent-encode its token. That branch is byte-identical betweenmainand this branch.Greptile Summary
This PR decouples Redis authentication from TLS so authenticated plaintext Redis works on-prem while preserving the existing authenticated ElastiCache TLS fallback.
Confidence Score: 5/5
The PR appears safe to merge; no new actionable failures were introduced since the previous review.
The latest change only increments the Helm chart patch version. The earlier transport-alignment and external-token findings are resolved, while the certificate-verification and AWS token-encoding threads were manually resolved as intentionally deferred, pre-existing work.
Important Files Changed
Flowchart
%%{init: {'theme': 'neutral'}}%% flowchart TD A[Redis configuration] --> B{REDIS_ENABLE_TLS explicitly set?} B -->|Yes| C[Use explicit TLS value] B -->|No| D{REDIS_AUTH_TOKEN present?} D -->|Yes| E[Infer TLS enabled] D -->|No| F[Use plaintext] C --> G[Build Redis URL] E --> G F --> G A --> H{Credential present?} H -->|Yes| I[Percent-encode and apply password] H -->|No| J[No userinfo] I --> G J --> G G --> K[Sync and async Redis clients] G --> L[Celery autoscaler broker] A --> M[Helm TLS resolution] M --> N[Application environment] M --> O[KEDA scaler metadata]Reviews (4): Last reviewed commit: "chore(chart): bump version to 0.2.10" | Re-trigger Greptile