You said:
ChatGPT said:
I’ll audit the uploaded nats.js archive as the source of truth, focusing on material security, correctness, packaging, protocol, and failure-mode issues, then run feasible project checks.
I’ve moved past setup into candidate validation: likely issues cluster around protocol/transport parsing, missing validation or throws, WebSocket edge cases, and Deno TLS hostname handling.
Yes. I found several issues I’d treat as serious enough to file upstream, especially if nats.js is used behind config, tenant input, plugin input, or untrusted NATS endpoints.
Public/advisory context first: the uploaded repo appears to be around the current @nats-io/nats-core 3.4.0 line; npm lists 3.4.0 as latest, and Snyk’s package page currently reports “No vulnerabilities found” for that latest package. That is not proof of safety. The NATS advisory index still shows a historical JS-client information disclosure item, CVE-2020-26149, while the recent 2026 entries I saw are mainly nats-server advisories. npm+2
VulnInfo Guide+2
Highest-concern findings
| Severity | Issue | Why it matters |
|---|---|---|
| High | subscribe(..., { queue }) accepts raw protocol-control characters | Queue names are interpolated directly into SUB ... without validation. A queue value containing whitespace/CRLF can corrupt or inject NATS protocol commands. |
| High/Med | inboxPrefix / createInbox() accepts whitespace/control chars | Request mux setup can create a raw SUB using an invalid/injected inbox subject before later publish-side validation catches the reply subject. |
| High/Med | Parser/reader errors only console.log() and stop reading | A malicious or broken server frame can kill the read loop without closing/reconnecting, leaving the client hung and possibly accumulating unread socket data. |
| Med/High | ObjectStore max_chunk_size accepts negative/invalid numbers | Negative chunk size corrupts DataBuffer accounting and can loop/grow memory during put(). |
| Medium | ObjectStore digest mismatch resolves ObjectResult.error as success | Stream consumers may see an errored stream, but the documented error promise is resolved even on checksum failure. |
| Medium | MsgHdrsImpl.fromRecord() bypasses header validation | Public API allows CR/LF in header names/values even though append()/set() reject them. |
1. Queue group protocol injection/corruption
NatsConnectionImpl._check() validates only the subject argument: empty or whitespace subjects are rejected in core/src/nats.ts:85-99. subscribe() calls that for subject, then constructs SubscriptionImpl with opts unchanged in core/src/nats.ts:143-159. SubscriptionImpl copies opts onto itself via extend(this, opts) in core/src/protocol.ts:161-169.
The queue is later emitted verbatim:
core/src/protocol.ts:1019-1024
SUB ${s.subject} ${s.queue} ${s.sid}\r\n
core/src/protocol.ts:1076-1084
SUB ${sub.subject} ${sub.queue} ${sub.sid}\r\n
So this is accepted at the API boundary:
TypeScriptnc.subscribe("foo", { queue: "q\r\nPING\r\n" });
and it will be serialized into the line-oriented protocol. With a more careful string, a caller controlling queue can inject additional commands or at least force protocol errors/disconnects.
This is not “remote unauthenticated” unless an application passes untrusted queue names into subscribe(). But library code should still reject it because the protocol field is an unescaped token.
Fix: validate opts.queue before SubscriptionImpl is created and before reconnect resubscription. At minimum, reject empty/whitespace/control characters. I’d add regression tests for "\n", "\r\n", "a b", "\t", and reconnect replay.
2. inboxPrefix validation is incomplete and can hit the mux SUB path
createInbox() only rejects wildcard tokens * and >:
core/src/core.ts:789-803
It does not reject whitespace, CR/LF, empty tokens, leading/trailing dots, or other invalid subject-token forms. parseOptions() treats a call to createInbox(options.inboxPrefix) as sufficient validation in core/src/options.ts:140-142.
The mux request path is the concerning one. ProtocolHandler.request() calls initMux() in core/src/protocol.ts:1007-1010; initMux() calls this.muxSubscriptions.init(this.options.inboxPrefix), constructs a subscription for ${inbox}*, and calls this.subscribe(sub) directly in core/src/protocol.ts:1154-1164. That path bypasses NatsConnectionImpl._check().
So a bad inboxPrefix can be serialized into a raw SUB line before the following publish path rejects the reply subject. The no-mux request path is safer because it goes through this.subscribe(createInbox(...)) in core/src/nats.ts:236 and core/src/nats.ts:394-402.
Fix: make createInbox() validate a real NATS subject prefix, not just “no wildcards.” For reply inboxes, I’d reject wildcards, empty tokens, whitespace/control chars, leading/trailing ., and probably enforce a prefix grammar shared with the normal subject validator.
3. Parser exceptions can silently kill the read loop
The connection reader loop is:
core/src/protocol.ts:572-586
It does:
TypeScripttry {
for await (const b of this.transport) {
this.parser.parse(b);
}
} catch (err) {
console.log("reader closed", err);
}
If parser.parse() throws, or processInfo() throws on invalid JSON at core/src/protocol.ts:853-855, the async reader stops. It does not close the transport, set lastError, reject pending requests, or enter the reconnect path.
That has two bad effects:
-
The app can hang until heartbeat/stale-connection logic notices, rather than failing fast.
-
The transport data listeners can continue pushing frames into
yieldswhile nothing drains the async iterator. Node transport does this intransport-node/src/node_transport.ts:342-347and iteratesyieldsintransport-node/src/node_transport.ts:366-394; WebSocket transport is analogous incore/src/ws_transport.ts:229-255.
There is also no apparent max length for the initial server INFO line accumulation: Node peekInfo() concatenates all this.yields until a complete protocol message in transport-node/src/node_transport.ts:147-166; WebSocket does the same in core/src/ws_transport.ts:118-146. Parser argument accumulation similarly creates/appends argBuf for INFO_ARG, MSG_ARG, and MINUS_ERR_ARG without a control-line cap in core/src/parser.ts:519-524.
Fix: on parser/read-loop exception, close the transport with the error and trigger normal close/reconnect/error propagation. Add a max control-line/INFO/ERR argument length. The client already has payload-size concepts; control frames need their own bound.
4. ObjectStore max_chunk_size can be negative or otherwise invalid
ObjectStoreImpl._put() accepts meta.options.max_chunk_size almost raw:
obj/src/objectstore.ts:478-483
The only clamp is upper-bounding to max_payload. Then the loop uses it here:
obj/src/objectstore.ts:587-595
TypeScriptwhile (db.size() > maxChunk) {
info.chunks!++;
info.size! += maxChunk;
const payload = db.drain(meta.options.max_chunk_size);
sha.update(payload);
await publishChunk(payload);
}
DataBuffer.drain(n) does not defend against negative n:
core/src/databuffer.ts:81-95
With n = -1, v.subarray(0, -1) returns all but the last byte, v.subarray(n) returns the last byte, and this.byteLength = max - n increases the recorded size. That can turn a put into an infinite/growing loop and produce wrong object metadata.
This is a local API/input validation bug, not a server-originated one, but it is severe for systems where object metadata comes from user input.
Fix: require Number.isSafeInteger(max_chunk_size) && max_chunk_size > 0 before assigning it. Reject 0, negative, NaN, Infinity, and fractional values. Then clamp to server max_payload.
5. ObjectStore read integrity error is not reflected in ObjectResult.error
The type says ObjectResult.error should report stream processing failure:
obj/src/types.ts:206-220
But in get(), digest mismatch calls controller!.error(...) and then breaks:
obj/src/objectstore.ts:738-751
The surrounding async IIFE then resolves the deferred regardless:
obj/src/objectstore.ts:754-760
So checksum failure can make r.data error while r.error resolves successfully. That is exactly the kind of integrity signal callers may rely on for object-store data verification.
Fix: create one Error, call controller.error(err), d.reject(err) or resolve d to that error consistently with the documented contract, and return immediately rather than falling through to .then(() => d.resolve()).
6. ObjectStore link dereference can create a bucket on read
When get() sees a link to another bucket, it does:
obj/src/objectstore.ts:686-697
TypeScriptconst os = info.options.link.bucket !== this.name
? await ObjectStoreImpl.create(this.js, info.options.link.bucket)
: this;
return os.get(ln);
ObjectStoreImpl.create() calls init() in obj/src/objectstore.ts:1047-1055, and init() creates the backing stream if it is missing in obj/src/objectstore.ts:1028-1034.
That means a read of a link can perform a create/update operation. A typo or malicious object metadata link can cause unexpected resource creation if the client’s credentials allow it.
Fix: link dereference should bind/open/check an existing object store, not call create. Creation should be explicit.
7. MsgHdrsImpl.fromRecord() bypasses header validation
Normal header writes validate names and values:
core/src/headers.ts:35-64
core/src/headers.ts:244-262
But fromRecord() directly writes into the internal map:
core/src/headers.ts:307-313
TypeScripth.headers.set(k, Array.isArray(v) ? v : [`${v}`]);
That skips canonicalMIMEHeaderKey() and validHeaderValue(). Since MsgHdrsImpl is exported, callers can construct headers with CR/LF or invalid names. The HPUB header block length limits this to header smuggling/corruption rather than arbitrary raw command injection, but it can still forge/split header fields across recipients.
Fix: implement fromRecord() by calling append() for each value.
Things I did not find
I did not find a parser syntax/build-stopper in the TypeScript sources; I parsed the TypeScript files cleanly. I also did not see the old JS credential-disclosure CVE pattern reintroduced in the current Connect serialization path from this static pass.
I did not run the full Deno/NATS integration suite in this container, so the exploitability classification above is static-review confidence, not a reproduced upstream test failure.