From acdbbfd45716d8707a2eb142ca77a1e84e2116b7 Mon Sep 17 00:00:00 2001 From: Maximiliano Salvatti <40447063+msalvatti@users.noreply.github.com> Date: Tue, 4 Aug 2026 09:38:23 -0300 Subject: [PATCH] fix(security): keep the Redis credentials out of service serialization MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The resolved options carried `connection` as a plain field, ConnectionResolver held the consumer's module options and the ioredis client in TypeScript `private` properties, and the registries held their `Queue`, `Worker` and `QueueEvents` maps the same way. `private` is erased at runtime, so every one of those was an enumerable own property and the credentials were reachable by walking a service: a connection `url` carries the password inline, and an ioredis instance carries `options.password` as a plain field. Verified against the published 1.0.7 in a real consumer, `JSON.stringify`, a circular-safe stringifier and `util.inspect` on `QueueService` each emitted the Redis password in plaintext through three distinct paths — the resolved options, the resolver's copy of the module options, and the client itself. That is what a structured logger does when it renders a provider it was handed, and what an error reporter does when it captures the scope of a throw. `connection` becomes a non-enumerable accessor on the resolved options; a non-enumerable value would have sufficed for stringify and spread, but `util.inspect({ showHidden: true })` still prints a hidden data property. The resolver's client and options, and the three registry maps, become ECMAScript private fields. Walking `QueueService` now reaches no credential by any path. The consumer's own options object is never mutated — only the objects this library constructs are changed. Reads are unchanged and no public type or export moved. --- CHANGELOG.md | 21 ++++++++++++ README.md | 28 +++++++++------ package.json | 2 +- src/server/config/resolved-options.spec.ts | 33 ++++++++++++++++++ src/server/config/resolved-options.ts | 28 ++++++++++++--- .../connection-resolver.service.spec.ts | 20 +++++++++++ .../services/connection-resolver.service.ts | 34 +++++++++++++------ .../services/queue-events-registry.service.ts | 16 ++++----- src/server/services/queue.service.ts | 12 +++---- .../services/worker-registry.service.ts | 18 +++++----- 10 files changed, 163 insertions(+), 49 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1f340f1..6a5f05f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,26 @@ versioning follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html). --- +## [1.0.8] — 2026-08-04 + +**Runtime change.** `dist/` differs from `1.0.7`. + +### Security + +- The Redis credentials are no longer disclosed when a service that holds them is + serialized. `connection` moves from a plain field on the resolved options to a + non-enumerable accessor, and `ConnectionResolver` keeps the ioredis client and the + consumer's module options in ECMAScript private fields, as do the maps holding the + `Queue`, `Worker` and `QueueEvents` instances. Those objects were all reachable by + walking a service: a `url` carries the password inline, and an ioredis instance carries + `options.password` as a plain field, so `JSON.stringify`, object spread and + `util.inspect` on `QueueService` emitted the password in plaintext — which is what a + structured logger does when it renders a provider it was handed, and what an error + reporter does when it captures the scope of a throw. + +Reading on purpose is unchanged: `options.connection` resolves as before, and no public +type or export changed. + ## [1.0.7] — 2026-08-04 **Runtime change.** `dist/` differs from `1.0.6`: the four decorators no longer carry @@ -369,6 +389,7 @@ v6 peer range. --- +[1.0.8]: https://github.com/bymaxone/nest-queue/compare/v1.0.7...v1.0.8 [1.0.7]: https://github.com/bymaxone/nest-queue/compare/v1.0.6...v1.0.7 [1.0.6]: https://github.com/bymaxone/nest-queue/releases/tag/v1.0.6 [1.0.5]: https://github.com/bymaxone/nest-queue/releases/tag/v1.0.5 diff --git a/README.md b/README.md index bf09d60..195b3e8 100644 --- a/README.md +++ b/README.md @@ -711,6 +711,14 @@ message, or an exception payload. `QueueException.details` carries only scalar configuration values (`{ actualValue, expectedValue, limit, received }`), never a connection object and never `job.data`. +They are also not reachable by serializing the objects that hold them. The connection is +attached to the resolved options as a non-enumerable accessor, and the resolver keeps the +ioredis client — which carries `options.password` as a plain field — in a private field, +as do the registries that hold the `Queue`, `Worker` and `QueueEvents` instances. So +`JSON.stringify`, object spread, `util.inspect` and `util.inspect` with `showHidden` all +come back without credentials, which is what matters when a structured logger renders a +provider it was handed or an error reporter captures the scope of a throw. + ### Job data is opaque The library treats `job.data` as a payload to transport, never to inspect. It is never @@ -755,16 +763,16 @@ key will do it twice at some point. ## 🛡️ Security Table -| Layer | Implementation | -| ----------------- | ---------------------------------------------------------------------------------------------------------- | -| Credentials | Injected options only; never read from `process.env`, never logged, never placed in an exception | -| Error payloads | `QueueException.details` restricted to scalar configuration values — no `job.data`, no connection object | -| Job payloads | Treated as opaque; never deep-merged (prototype-pollution guard), never logged | -| Namespacing | `prefix` applied uniformly to `Queue`, `Worker`, `QueueEvents` and `FlowProducer` | -| Connection policy | `maxRetriesPerRequest: null` confined to duplicated consumer connections; producers keep fail-fast retries | -| Delivery | At-least-once with a bounded drain; unacknowledged work is re-queued by BullMQ's stalled check | -| Supply chain | `dependencies: {}`; SHA-pinned Actions, OSV-Scanner, TruffleHog, OpenSSF Scorecard; npm publish over OIDC | -| Input validation | Options validated at bootstrap; cron patterns validated by BullMQ's own parser, never a hand-rolled regex | +| Layer | Implementation | +| ----------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Credentials | Injected options only; never read from `process.env`, never logged, never placed in an exception; held in a non-enumerable accessor and private fields, so serializing a service omits them | +| Error payloads | `QueueException.details` restricted to scalar configuration values — no `job.data`, no connection object | +| Job payloads | Treated as opaque; never deep-merged (prototype-pollution guard), never logged | +| Namespacing | `prefix` applied uniformly to `Queue`, `Worker`, `QueueEvents` and `FlowProducer` | +| Connection policy | `maxRetriesPerRequest: null` confined to duplicated consumer connections; producers keep fail-fast retries | +| Delivery | At-least-once with a bounded drain; unacknowledged work is re-queued by BullMQ's stalled check | +| Supply chain | `dependencies: {}`; SHA-pinned Actions, OSV-Scanner, TruffleHog, OpenSSF Scorecard; npm publish over OIDC | +| Input validation | Options validated at bootstrap; cron patterns validated by BullMQ's own parser, never a hand-rolled regex | > [!IMPORTANT] > **Delivery is at-least-once, and `prefix` is not an access boundary.** Handlers must be diff --git a/package.json b/package.json index d4512d7..dc1c261 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@bymax-one/nest-queue", - "version": "1.0.7", + "version": "1.0.8", "description": "NestJS dynamic module wrapping BullMQ — typed jobs, flows, job schedulers, deduplication, OpenTelemetry, graceful shutdown", "author": "Bymax One ", "license": "MIT", diff --git a/src/server/config/resolved-options.spec.ts b/src/server/config/resolved-options.spec.ts index d3bf641..c7287d1 100644 --- a/src/server/config/resolved-options.spec.ts +++ b/src/server/config/resolved-options.spec.ts @@ -3,6 +3,8 @@ * @layer server/config */ +import { inspect } from 'node:util' + import type { Telemetry } from 'bullmq' import { applyDefaults } from './resolved-options' import { @@ -75,6 +77,37 @@ describe('applyDefaults', () => { expect(resolved.telemetry).toBe(telemetry) }) + it('keeps the connection out of every incidental serialization path', () => { + // The resolved options are injected into QueueService, WorkerRegistry, + // QueueEventsRegistry and QueueLifecycle, so whatever serializes one of + // them incidentally reaches this object: a structured logger rendering its + // arguments, an error reporter capturing the scope of a throw, an object + // spread. A `url` carries the Redis password inline, which is why the + // connection is the field that has to be withheld. + const secret = 'r3d1sPassw0rd-canary' + const resolved = applyDefaults({ + connection: { url: `redis://default:${secret}@127.0.0.1:6379` }, + }) + + expect(JSON.stringify(resolved)).not.toContain(secret) + expect(JSON.stringify({ ...resolved })).not.toContain(secret) + expect(inspect(resolved, { depth: null })).not.toContain(secret) + // `showHidden` is why the property is an accessor rather than merely a + // non-enumerable value: a hidden data property is still printed here. + expect(inspect(resolved, { depth: null, showHidden: true })).not.toContain(secret) + expect(Object.keys(resolved)).not.toContain('connection') + }) + + it('still exposes the connection to the resolver that has to dial Redis', () => { + // Containment must cost nothing at the supported surface: ConnectionResolver + // reads this to build the client, so withholding it from serialization must + // not withhold it from property access. + const connection: BymaxQueueModuleOptions['connection'] = { url: 'redis://localhost:6379' } + const resolved = applyDefaults({ connection }) + + expect(resolved.connection).toBe(connection) + }) + it('returns a frozen object that rejects mutation', () => { // Freezing guards the resolved options against accidental mutation. const resolved = applyDefaults({ connection: baseConnection }) diff --git a/src/server/config/resolved-options.ts b/src/server/config/resolved-options.ts index 613a31b..fc30abe 100644 --- a/src/server/config/resolved-options.ts +++ b/src/server/config/resolved-options.ts @@ -44,8 +44,10 @@ export interface ResolvedQueueOptions { * @returns A frozen, fully-resolved options object. */ export function applyDefaults(opts: BymaxQueueModuleOptions): Readonly { - const base: ResolvedQueueOptions = { - connection: opts.connection, + const connection = opts.connection + + const base: Omit & + Partial> = { defaultJobOptions: { ...DEFAULT_JOB_OPTIONS, ...(opts.defaultJobOptions ?? {}) }, prefix: opts.prefix ?? 'bull', queueOptions: opts.queueOptions ?? {}, @@ -58,9 +60,25 @@ export function applyDefaults(opts: BymaxQueueModuleOptions): Readonly