confluentinc / confluentinc/confluent-kafka-javascript

Segfault (strlen on NULL) in admin.describeGroups() when brokers are unreachable — unguarded NewFromUtf8 in FromConsumerGroupDescription

Open
#531 0 comments 0 reactions 0 assignees View on GitHub
Dominant language
TypeScript
Stars
304
Forks
45
Avg merge
11h 47m
Merged PRs (30d)
5

Description

## Environment

- OS: macOS arm64 (Apple Silicon)
- Node.js: v20.19.1
- @confluentinc/kafka-javascript: reproduced on **1.4.1** and **1.10.0** (prebuilt binaries); the unguarded code is still present on current `master` (`src/common.cc`, `FromConsumerGroupDescription`)
- Kafka: apache/kafka:3.9.1 single-node KRaft via docker compose (any broker works — the bug only needs the brokers to become unreachable)

## Summary

Calling `admin.describeGroups()` while all brokers are unreachable crashes the whole Node.js process with a segmentation fault (exit 139) instead of rejecting the promise. The native binding converts per-group error results without NULL-checking the string accessors of `rd_kafka_ConsumerGroupDescription_t`, so `Nan::New` → `v8::String::NewFromUtf8(NULL)` → `strlen(NULL)` crashes on the main thread. This makes `describeGroups` unusable in health-check / liveness-probe style code, because the exact moment you most need a clean error (broker outage) is the moment the process dies.

## Steps to reproduce

**docker-compose.yaml** (single-node KRaft broker):

```yaml
name: repro-kafka
services:
kafka:
image: apache/kafka:3.9.1
ports:
- "9092:9092"
environment:
KAFKA_NODE_ID: 1
KAFKA_PROCESS_ROLES: broker,controller
KAFKA_LISTENERS: PLAINTEXT://0.0.0.0:9092,CONTROLLER://0.0.0.0:9093
KAFKA_ADVERTISED_LISTENERS: PLAINTEXT://localhost:9092
KAFKA_CONTROLLER_LISTENER_NAMES: CONTROLLER
KAFKA_LISTENER_SECURITY_PROTOCOL_MAP: PLAINTEXT:PLAINTEXT,CONTROLLER:PLAINTEXT
KAFKA_CONTROLLER_QUORUM_VOTERS: 1@localhost:9093
KAFKA_OFFSETS_TOPIC_REPLICATION_FACTOR: 1
KAFKA_TRANSACTION_STATE_LOG_REPLICATION_FACTOR: 1
KAFKA_TRANSACTION_STATE_LOG_MIN_ISR: 1
healthcheck:
test: ["CMD-SHELL", "/opt/kafka/bin/kafka-broker-api-versions.sh --bootstrap-server localhost:9092 >/dev/null 2>&1"]
interval: 5s
timeout: 10s
retries: 24
```

**repro.js**:

```js
const { KafkaJS } = require('@confluentinc/kafka-javascript');
const { execSync } = require('child_process');

(async () => {
const kafka = new KafkaJS.Kafka({ kafkaJS: { brokers: ['localhost:9092'], logLevel: KafkaJS.logLevel.NOTHING } });
const admin = kafka.admin();
await admin.connect();
console.log('connected');

setInterval(async () => {
try {
await admin.describeGroups(['any-group-id'], { timeout: 3000 });
console.log(new Date().toISOString(), 'ok');
} catch (e) {
console.log(new Date().toISOString(), 'unavailable:', e.message);
}
}, 1000);

await new Promise((r) => setTimeout(r, 5000));
console.log('stopping broker');
execSync('docker compose stop kafka');
})();
```

**Run**:

```sh
docker compose up -d --wait
npm i @confluentinc/kafka-javascript@1.10.0
node repro.js; echo "exit=$?"
```

The group does not need to exist. Against a *healthy* cluster, describing a nonexistent group returns a normal DEAD-state description and nothing crashes — the crash requires the brokers to be unreachable (stopped or paused) so that librdkafka produces a per-group **error** description.

## Observed

The process dies with SIGSEGV a few seconds after the broker stops (as soon as the in-flight describe operation times out):

```
connected
2026-09-01T14:06:56.735Z ok
stopping broker
exit=139
```

lldb backtrace (1.10.0 prebuilt, macOS arm64, Node v20.19.1):

```
* thread #1, queue = 'com.apple.main-thread', stop reason = EXC_BAD_ACCESS (code=1, address=0x0)
* frame #0: libsystem_platform.dylib`_platform_strlen + 4
frame #1: node`v8::String::NewFromUtf8(v8::Isolate*, char const*, v8::NewStringType, int) + 156
frame #2: confluent-kafka-javascript.node`NodeKafka::Conversion::Admin::FromConsumerGroupDescription(rd_kafka_ConsumerGroupDescription_s const*) + 1228
frame #3: confluent-kafka-javascript.node`NodeKafka::Conversion::Admin::FromDescribeConsumerGroupsResult(rd_kafka_op_s const*) + 92
frame #4: confluent-kafka-javascript.node`NodeKafka::Workers::AdminClientDescribeGroups::HandleOKCallback() + 76
frame #5: node`Nan::AsyncWorker::WorkComplete() + 72
```

## Root cause

When brokers are unreachable, librdkafka completes the DescribeConsumerGroups op with per-group error descriptions built by `rd_kafka_ConsumerGroupDescription_new_error()` (`deps/librdkafka/src/rdkafka_admin.c`), which passes `partition_assignor = NULL`:

```c
static rd_kafka_ConsumerGroupDescription_t *
rd_kafka_ConsumerGroupDescription_new_error(const char *group_id,
rd_kafka_error_t *error) {
return rd_kafka_ConsumerGroupDescription_new(
group_id, rd_false, NULL, NULL, NULL, 0, ...);
}
```

and `rd_kafka_ConsumerGroupDescription_new()` deliberately keeps it NULL:

```c
grpdesc->partition_assignor = !partition_assignor
? (char *)partition_assignor
: rd_strdup(partition_assignor);
```

So `rd_kafka_ConsumerGroupDescription_partition_assignor(desc)` returns NULL for error-state descriptions. But `FromConsumerGroupDescription` in `src/common.cc` feeds the string accessors straight into `Nan::New` without a NULL check — three conversions: `groupId` (via `_group_id`), and `protocol` + `partitionAssignor` (both via `_partition_assignor`):

```cc
// protocol
Nan::Set(returnObject, Nan::New("protocol").ToLocalChecked(),
Nan::New(
rd_kafka_ConsumerGroupDescription_partition_assignor(desc))
.ToLocalChecked());
```

`Nan::New(NULL)` → `v8::String::NewFromUtf8(isolate, NULL)` → `strlen(NULL)` → SIGSEGV, on the main thread inside `HandleOKCallback`, so no JS-level try/catch or process-level handler can intercept it.

This is why a healthy-cluster DEAD response does *not* reproduce it: in that path the description is built from the broker protocol response where the assignor is an empty string, not NULL. Only error descriptions (unreachable brokers, coordinator lookup failure, request timeout) carry the NULL.

Note that the same file already guards this exact pattern elsewhere — `ToV8Object(rd_kafka_Node_t*)` NULL-checks `host` and `rack` "to prevent segfault" — so `FromConsumerGroupDescription` just misses the equivalent guards.

## Expected

A describeGroups response containing per-group errors should be delivered safely to JS (each group object carrying its `error` field), or the promise should reject — the process must not segfault.

## Related

- #374 (`fetchOffsets` segfault for unseen group) and #164 (groups e2e segfault) look like the same class of issue: NULL string accessors from error/absent group state reaching unguarded `NewFromUtf8`.

## Fix

Adding NULL guards (empty-string fallback, following the existing `rd_kafka_Node_host` guard style in the same file) to the three conversions fixes the segfault — verified locally on macOS arm64 with a from-source build of `master` + the guards:

- While brokers are down, `describeGroups` no longer crashes: it resolves with the per-group error delivered to JS (`groups[0].error` = `Local: Timed out`).
- `docker compose pause` / `unpause` outage cycle: the process survives the full cycle, reports unavailable during the outage, and recovers to healthy responses after unpause (exit 0). The same cycle segfaults without the guards.
- `docker compose stop` / `start` cycle: the segfault is gone as well; the process survives the outage window and resumes successful describes. (During validation of this variant I did hit a separate, pre-existing librdkafka assertion — `rd_assert(eonce->refcnt > 0)` in `rd_kafka_enq_once_del_source_return`, reached from `rd_kafka_admin_coord_response_parse` on the `rdk:main` thread — around the broker restart. That is an independent librdkafka admin-op lifecycle bug, unrelated to this conversion-layer fix; the assertion string is present in the published prebuilt binaries too. I can file it separately with the backtrace if useful.)

Fix branch: https://github.com/kty1965/confluent-kafka-javascript/tree/fix/describe-groups-null-guard

Happy to open a PR if this looks good.

Contributor guide

Open the contributing guide

Research direction

Start in src/common.cc at FromConsumerGroupDescription and compare its string conversions with the existing NULL guards in ToV8Object. Run repro.js with the docker-compose broker outage to observe the failure. Done means describeGroups survives unreachable brokers and delivers the per-group error to JavaScript without a process crash.

Written by the indexing model from the issue text.

Assessment

Tech stack
cpp, javascript, node.js
Domain
api, backend
Issue type
Bug
Difficulty
2/5
Estimated time
1-3 hours
Activity status
Active
Clarity
Clearly specified
Newbie friendliness
35/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.