DFE exposure model
Most of this stack has no internet-facing and no user-facing exposure. Three components do, and a fourth where a deployment turns the edge tunnel on. Everything else consumes from the broker or is reached only from inside the deployment.
Read this before you assess a security finding, advisory or scanner warning in a DFE repo. Grade a finding by whether anything in DFE can reach the flawed code, as well as by its severity score.
What is actually exposed
| Component | Exposure | Notes |
|---|---|---|
| dfe-receiver | Internet / user facing | The ingest edge. Takes arbitrary data from whoever can route to it. The primary attack surface. |
| dfe-ui | User facing | Browser-facing Next.js app, behind auth in every deployment that has an issuer. |
| dfe-hyperdx | User facing, authenticated | Search and dashboards embedded in dfe-ui. Verifies the engine's token on every request and queries ClickHouse as the one ClickHouse user the engine hands that session: one dfe-hyperdx team per ClickHouse identity, and a team holding any other user's connection refuses the session. Admin surfaces are engine-only. Its own store holds every team's connection, the unrestricted platform reader's included, so a flaw that crosses the team fence reaches every org's data. Signed-in members reach it through the gateway on port 8080, from exposure.groups.console.allow only, and every public route is rate-limited (ui.rate_limit, below). |
| The gateway's web routes | Internet facing from each exposure group's allow list only | On every cloud flavour Envoy refuses dfe-ui, dfe-hyperdx and the engine's browser families to an address off exposure.groups.console.allow, the engine's CLI families and /openapi.json off exposure.groups.api.allow, and the admin UIs off exposure.groups.admin.allow. An EMPTY list refuses every address. The ingest routes (otel, and the receiver on the gateway door) answer to exposure.groups.ingest.allow the same way. Envoy parses an outsider's request before the check, so an Envoy advisory is reachable. |
| culvert | Internet facing, opt-in | The edge-fleet VPN (below). |
| dfe-fetcher | Outbound only | Reaches external APIs, but it initiates every connection. Its ingest port is off by default. |
| dfe-engine | CLI and API | Reachable by operators, not by the public. The API is authenticated. |
| dfe-loader, dfe-archiver, dfe-transform-* | Not exposed | Consume from the broker and write to a datastore. On the direct transport, dfe-loader, dfe-archiver, dfe-transform-vrl, dfe-transform-vector and dfe-transform-elastic each bind a plaintext, unauthenticated gRPC Push listener on a ClusterIP Service, port 6000 (the push port of the app's thin-chart Service). The namespace baseline NetworkPolicy (dfe-ingress-policy, helm/charts/network-policies) admits the gateway namespace and every DFE and otel namespace, so the listener is reachable only from pods in those namespaces, never from outside the cluster. |
| ClickHouse, Kafka, the collector | Not exposed | Cluster-internal. On Kubernetes the apps reach ClickHouse over verified TLS (below). dfe-docker's docs/operating.md covers the Compose bindings. |
| The DocumentDB store | Not exposed | dfe-hyperdx's state, in cnpg: the DocumentDB gateway on 10260 in front of PostgreSQL, in each instance pod. A ClusterIP Service follows the primary. dfe-dfe-hyperdx's login holds readWriteAnyDatabase and clusterAdmin, so it reads and writes every database in the store, and dfe-hyperdx alone reads its copy. dfe-hyperdx alone may open 10260, the instances alone open each other's 5432, and CloudNativePG alone opens 8000 (documentdbAccess, helm/charts/network-policies). The instance pods sit outside data-ingress-policy and take their whole ingress from allow-documentdb-ingress, so the gateway, collector and ClickHouse operator namespaces reach none of their ports. |
| The collector's OTLP ingress | Off by default. When a deployment sets otel.ingress.enabled: reachable from outside the cluster, authenticated | otel.<domain> on the gateway reaches a second OTLP/HTTP receiver that refuses any request without the bearer token from the deployment's secret store. The check is the collector's own (bearertokenauth), so it holds however that port is reached. Before the token is checked, the collector's HTTP server and the extension parse an outsider's request: on such a deployment, grade an advisory in either as reachable. The in-cluster receiver on 4317/4318 stays unauthenticated and no route publishes it. |
The gateway's trusted proxy ranges
No gateway listener believes X-Forwarded-For by default. The CIDR filter judges the TCP peer, which every flavour's load balancer delivers unchanged (edge-auth-cloud.md). So a forged header from a client, the tunnel forwarder in an AWS public subnet, or any other host beside the load balancer passes nothing.
ui.trusted_proxy_cidrs is the one way to change that. The public listeners (https-public-*) then read the header when the TCP peer is inside the list. Every host in a listed range can claim any client address, so list a CDN's ranges and nothing shared. Treat a deployment that lists anything else as fenced to that whole range.
The receiver route through the gateway is refused on an internet-facing Service unless routes.receiver.authMode says the receiver checks callers (header or bearer). The gateway can't read the receiver's own values, so that key is only what the operator declared. If the two values disagree, grade the deployment by the receiver's.
The edge tunnel
culvert is off unless a deployment asks for it. On-prem it's a public LoadBalancer on UDP 51820 (WireGuard) and 1194 (OpenVPN). On AWS it's a NodePort (31820, 31194) behind the tunnel forwarder's address or one the deployer brings. Empty source ranges mean every address. openvpn-tcp and oauth2-udp exist only where listeners adds them.
A client authenticates with its own PKI certificate, and with OIDC when vpn.oidc.enabled. Before that, an outsider's packets reach the VPN daemons, so grade an advisory in OpenVPN, WireGuard or the culvert image as reachable. The pod runs as uid 0 with NET_ADMIN. By design an authenticated client reaches the receiver and nothing else, and no other client.
In-cluster transport
A deployment that carries a CA runs its in-cluster hops over TLS with verification on. One without stays on plain HTTP, because there's nothing to verify against. A Kubernetes deployment with the edge module on (bootstrap's default) carries one: bootstrap.sh detects or installs cert-manager, and the gateway chart's dfe-internal-ca ClusterIssuer signs. dfe-docker Compose and a cluster with the edge module off stay on HTTP unless an issuer is named in clickhouse.tls.issuerRef.
| Hop | Transport |
|---|---|
| dfe-engine, its hunt runner and keda shim -> ClickHouse | HTTPS 8443, verified against the certificate's CA |
| dfe-loader -> ClickHouse | HTTPS 8443, verified |
| dfe-hyperdx -> ClickHouse | HTTPS on the connection the engine hands it, verified |
| dfe-hyperdx -> its DocumentDB store | TLS on 10260, verified against the store's own cert-manager CA, SCRAM-SHA-256. The gateway serves no plaintext |
| OTel collector -> ClickHouse | Native TLS 9440, verified, under the same CA rule as the apps |
Smoke tests and scripts/ -> ClickHouse | HTTP 8123, plaintext |
| Apps -> in-cluster Kafka | SCRAM-SHA-512 over a plaintext listener (kafka.securityProtocol: SASL_PLAINTEXT) |
8123 and 9000 stay open beside 8443 and 9440 (clickhouse.tls.required: false), because the smoke tests still dial plaintext. Keeper has no TLS. A deployment on a TLS Kafka listener sets kafka.securityProtocol: SASL_SSL with its kafka.bootstrapServers in its deploy repo's infra/common.yaml, and every managed broker is SASL over TLS already. The DocumentDB store's PostgreSQL on 5432 serves its replicas over client-certificate TLS and its own gateway over localhost. CloudNativePG's last pg_hba rule takes a SCRAM password from any address, so the chart takes the password off the superuser the operator creates with a published one (helm/charts/documentdb/templates/documentdb.yaml). That leaves dfe-hyperdx's own login the only password role. The server keypair (dfe-clickhouse-tls, with tls.key) is readable only from the clickhouse namespace. The apps get a CA-only copy, through a ClusterSecretStore that serves only the namespaces it copies into. Issuer choice: deploy-tls-trust.md.
Secrets dfe-engine mints
Only the engine reaches the engine's JWT signing key, its ClickHouse service and tenant passwords, and the OIDC client secrets an operator enters. On AWS they sit in Secrets Manager under <path>/engine/, encrypted with the deployment key, and only the engine's Pod Identity role can read or write that prefix. external-secrets is denied it. Elsewhere they're files on the engine's own volume. On every cloud the hunt runner and keda shim run as service accounts of their own, with no AWS identity and no Kubernetes API token, so none of the engine's grants reaches them. Grade a flaw that exposes them as an engine compromise.
dfe-receiver's ingest bearer tokens follow the same store. Off AWS the engine also writes them into the dfe-ingest-bearer-tokens Secret, through a Role granting its service account get and update on that one Secret and nothing else, and the receiver mounts it read-only. A flaw that reads that Secret yields every ingest credential, so grade it as a receiver authentication bypass.
What that means for a finding
Grade by reachability first, then severity:
- Is the flawed code path reachable from dfe-receiver, dfe-ui, dfe-hyperdx or, where deployed, culvert? If yes, treat it seriously whatever the score, because the input is untrusted.
- Is it only reachable from outbound traffic we initiate, or from an operator-authenticated path? Then a mid-range score is usually a risk-accept, not a scramble.
- Is it in a dependency of a component with no listener at all? Reaching it requires already being inside the deployment, which is a different incident.
Recording the decision
Every finding ends in one of three states: fixed, closed as not required, or marked in the code as not-relevant or risk-accepted. Marked means AT THE LINE it concerns, with the reachability reasoning, so the next reader and the next scanner run both find it.
Where an advisory has no fixed release, marking it is the ONLY resolution available short of replacing the dependency. Make the call and write it down, rather than leaving the warning to fire forever.
Accepted risks
| Advisory | Package | Component | Decision |
|---|---|---|---|
| PYSEC-2026-2447 | diskcache 5.6.3 | dfe-engine | Accepted. No fixed release exists. dfe-engine is operator-facing (CLI + authenticated API), not internet-facing, and the cache is not fed from untrusted input. Revisit if a fix ships or if diskcache moves onto a request path. |
| GHSA-w9wp-h8wv-79jx | opentelemetry_sdk 0.31.x | every Rust service (through scalo-rs) | Not reachable. The unbounded allocation is in W3C Baggage propagation. scalo-rs registers no text-map propagator and never touches Baggage. It carries trace context itself in src/transport/propagation.rs, and no DFE service registers a propagator either. The fix (0.32.1) is out of reach while metrics-exporter-opentelemetry, which scalo-rs's otel-metrics feature builds on, requires opentelemetry ^0.31 -- its newest release is 0.2.1 (2025-11-15). tracing-opentelemetry is not the blocker: 0.33 pairs with opentelemetry 0.32. The exporter's main branch already builds against opentelemetry 0.33 but is unpublished, so DFE 2.2 GA ships on 0.31 with this accepted. Scanners are suppressed with this reason at the osv-scanner ignore in each Rust repo's .hyperi-ci.yaml (archiver: osv-scanner.toml). Dependabot alerts are dismissed. Revisit when that exporter publishes (tracked in hyperi-io/scalo-rs#281), or if anything registers a Baggage propagator. |
| GHSA-qxc2-j82w-r537 | @faker-js/faker 5.5.3 | docs-site build (through docusaurus-plugin-openapi-docs, postman-collection 5.3.1) | Not reachable. postman-collection fills its dynamic variables from faker while CI generates the API reference pages, from the engine's own OpenAPI spec. No user input reaches it and nothing ships in the deployed stack. The fix is faker 10.x, and postman-collection 5.3.1 calls faker 5's API, so it stays until postman-collection moves. |
| GHSA-gcqx-w48m-w2h4 | deepmerge 4.3.1 | docs-site build (through @svgr/webpack, @docusaurus/plugin-svgr) | Accepted. No fixed release exists: 4.3.1 is the latest. svgr merges its own SVGO config while CI builds the docs site, and no untrusted object reaches it. Nothing ships in the deployed stack. |
| RUSTSEC-2024-0449 | unsafe-libyaml 0.2.11 | every Rust service (through scalo-rs) | Accepted. An unmaintained notice, not a vulnerability, with no safe upgrade. 0.2.11 has no open memory-safety advisory: the one unsoundness advisory, RUSTSEC-2023-0075 (an unaligned write on 32-bit targets), was fixed in 0.2.10. Every service parses operator-supplied config YAML through it, by serde_yaml (figment's yaml feature) and serde_yaml_ng. dfe-transform-vrl also reaches event data: it registers vrl::stdlib::all() (src/engine/compiler.rs), which includes VRL's parse_yaml, a serde_yaml_ng::from_slice call, so a transform program that calls parse_yaml hands libyaml whatever the event carries. Only a transform author writes that call. Ingested data cannot add one. Scanners are suppressed in each Rust repo's deny.toml and at the osv-scanner ignore in its .hyperi-ci.yaml (archiver: osv-scanner.toml, and dfe-transform-elastic needs no deny.toml entry because it scopes unmaintained to the workspace). Revisit when vrl moves off serde_yaml_ng, which keeps dfe-transform-vrl on libyaml after the rest of the fleet moves to serde-saphyr in 2.2.1 (hyperi-io/scalo-rs#341), or if a config path starts to accept YAML from an untrusted caller. |
| RUSTSEC-2026-0387 | crypto-bigint 0.5.5 | dfe-archiver (tests only) | Not reachable. Timing side channels in operations meant to be constant-time. It arrives only through the aws-sdk-s3 dev-dependency the archiver's at-least-once tests use for bucket setup (aws-sigv4's sigv4a feature, then p256 and elliptic-curve), so no shipped binary carries it. The fix is crypto-bigint 0.7.6 and the 0.5 line has none: 0.5.5 (2023-11-18) is its newest release, and aws-sigv4 1.6.3 still requires ^0.5.4 for SigV4a. Scanners are suppressed in dfe-archiver's deny.toml and at the cargo-audit and osv-scanner ignores in its .hyperi-ci.yaml. Revisit when aws-sigv4 leaves crypto-bigint 0.5 or aws-sdk-s3 becomes a normal dependency. Dropping sigv4a from the dev-dependency's features removes it outright. |
| Design: VRL env and network functions | vrl 0.34 default features (enable_env_functions, enable_network_functions) | dfe-transform-vrl | Accepted for 2.2. A transform program can call get_env_var and http_request, so whoever can write a transform can read the pod's environment, the Kafka SASL credential included, and send it anywhere the pod can reach. Only a user the engine authenticates and authorises for transform writes can author one, through the API or dfe-ui. Ingested data cannot, so this is the operator trusting their own transform authors. Grant transform write accordingly. Revisit when the engine refuses these functions at transform save, or the crate drops the two features. |
| Design: no rate limit inside the dfe-hyperdx API | packages/api/src/api-app.ts, routers/api/*, dfe/routers/* (CodeQL js/missing-rate-limiting) | dfe-hyperdx | Accepted. Every route checks the engine's token before it does any work. The gateway rate-limits dfe-hyperdx's public route whenever ui.public.hyperdx publishes it (ui.rate_limit, on by default: a 6,000 a minute bucket the route's callers share and 1,200 a minute per client address, both per proxy replica). A limiter inside the fork would edit upstream Express files that every upstream merge has to carry. Not covered: the internal listener (private hostname, over the VPN) and dfe-docker, whose oauth2-proxy has no limiter. Both reach dfe-hyperdx only after login. Revisit if dfe-hyperdx takes a credential itself or stops checking the engine token per request. |
| zod v3 array walk | zod 3.25.76 | dfe-hyperdx | Mitigated, not fixed. Any signed-in member reaches the v3 array walk through /dashboards, /saved-search and /pinned-filters request bodies, and the MCP SDK runs zod's v4 core on the engine-only /mcp. dfe-hyperdx's security/patches/0004 answers 413 to a JSON body past 2,000 array elements or 20,000 object members before any route validates it. No 3.x release carries the fix. The 4.x copies in the repository are eslint, knip and Stryker tooling, not in the image. Scanners are suppressed with this reason in dfe-hyperdx's osv-scanner.toml. Revisit when a 3.x fix ships or the fork's routes move to zod 4. |
| Design: webhook URL guard | packages/api/src/utils/validators.ts | dfe-hyperdx | Not reachable in DFE. The guard fails open on a host that does not parse and checks no resolved address, so a name resolving to a cluster address passes. In DFE mode webhooks, alerts, MCP and the external API are engine-only (requireServicePrincipal, api-app.ts), and the engine creates no webhooks. Revisit if webhooks open to users, or with DFE auth off. |
| Design: operator Keeper policy matches its label in any namespace | clickhouse-operator 0.0.8, its managed Keeper NetworkPolicy (internal/controller/networkpolicy.go:17-24) | ClickHouse Keeper, cluster mode | Accepted. The policy the KeeperCluster turns on admits any pod labelled clickhouse.com/role: operator, from any namespace, to 2181, 2281 and 9123. So whoever can create a pod in any namespace can send Keeper four-letter words and create znodes at its root. The KeeperCluster holds the words to read-only ones (clickhouse.keeper.fourLetterWords), so rcvr, rqld, ydld and csnp are switched off, and znode creation at the root stays a denial-of-service route. They cannot read or write the znodes the servers create, which carry the servers' digest ACL. Before, data-ingress-policy opened every Keeper port to every pod in the namespaces it lists, and now those pods need an operator or server role label and raft 9234 takes only keepers. A labelled pod in a namespace it never listed now reaches the client ports as well. Policies are additive, so dfe-infra cannot narrow the operator's policy. Revisit when the operator scopes that peer to its own namespace, or if a deployment grants pod creation to anyone it does not trust with its data layer. |