Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
28 changes: 18 additions & 10 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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 <support@bymax.one>",
"license": "MIT",
Expand Down
33 changes: 33 additions & 0 deletions src/server/config/resolved-options.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@
* @layer server/config
*/

import { inspect } from 'node:util'

import type { Telemetry } from 'bullmq'
import { applyDefaults } from './resolved-options'
import {
Expand Down Expand Up @@ -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 })
Expand Down
28 changes: 23 additions & 5 deletions src/server/config/resolved-options.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,8 +44,10 @@ export interface ResolvedQueueOptions {
* @returns A frozen, fully-resolved options object.
*/
export function applyDefaults(opts: BymaxQueueModuleOptions): Readonly<ResolvedQueueOptions> {
const base: ResolvedQueueOptions = {
connection: opts.connection,
const connection = opts.connection

const base: Omit<ResolvedQueueOptions, 'connection'> &
Partial<Pick<ResolvedQueueOptions, 'connection'>> = {
defaultJobOptions: { ...DEFAULT_JOB_OPTIONS, ...(opts.defaultJobOptions ?? {}) },
prefix: opts.prefix ?? 'bull',
queueOptions: opts.queueOptions ?? {},
Expand All @@ -58,9 +60,25 @@ export function applyDefaults(opts: BymaxQueueModuleOptions): Readonly<ResolvedQ
drainTimeoutMs: opts.shutdown?.drainTimeoutMs ?? DEFAULT_SHUTDOWN_DRAIN_TIMEOUT_MS,
drainOnShutdown: opts.shutdown?.drainOnShutdown ?? false,
},
connectionReadyTimeoutMs:
opts.connectionReadyTimeoutMs ?? DEFAULT_CONNECTION_READY_TIMEOUT_MS,
connectionReadyTimeoutMs: opts.connectionReadyTimeoutMs ?? DEFAULT_CONNECTION_READY_TIMEOUT_MS,
}
if (opts.telemetry !== undefined) base.telemetry = opts.telemetry
return Object.freeze(base)

// The connection carries the Redis credentials — a `url` holds the password
// inline, an `options` object holds it as a field, and a bring-your-own
// `client` holds both on the ioredis instance. This resolved object is
// injected into QueueService, WorkerRegistry, QueueEventsRegistry and
// QueueLifecycle, so an enumerable `connection` is emitted by anything that
// serializes one of them incidentally: a structured logger rendering its
// arguments, an error reporter capturing the scope of a throw. Attaching it
// as a non-enumerable accessor withholds it from `JSON.stringify`, object
// spread and `util.inspect` — including `showHidden`, which still prints a
// hidden data property. Reads are unchanged.
Object.defineProperty(base, 'connection', {
get: (): QueueConnectionConfig => connection,
enumerable: false,
configurable: false,
})

return Object.freeze(base) as Readonly<ResolvedQueueOptions>
}
20 changes: 20 additions & 0 deletions src/server/services/connection-resolver.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
*/

import { EventEmitter } from 'node:events'
import { inspect } from 'node:util'
import type { Redis } from 'ioredis'
import { ConnectionResolver } from './connection-resolver.service'
import { QueueException } from '../errors/queue-exception'
Expand Down Expand Up @@ -288,4 +289,23 @@ describe('ConnectionResolver — exception details', () => {
await assertion
jest.useRealTimers()
})
it('keeps the client and the module options out of every serialization path', () => {
// The resolver is injected into QueueService, WorkerRegistry,
// QueueEventsRegistry and QueueLifecycle. It holds the consumer's module
// options — whose `url` carries the Redis password inline — and an ioredis
// instance, which carries `options.password` as a plain field. Both were
// reachable by walking a service that holds the resolver, which is what a
// structured logger or an error reporter does when it renders one.
const secret = 'r3d1sPassw0rd-canary'
const client = new FakeRedis('ready')
redisConstructor.mockReturnValue(asRedis(client))
const resolver = new ConnectionResolver({
connection: { url: `redis://default:${secret}@127.0.0.1:6379` },
})

expect(Object.keys(resolver)).not.toContain('client')
expect(Object.keys(resolver)).not.toContain('options')
expect(JSON.stringify(resolver)).not.toContain(secret)
expect(inspect(resolver, { depth: null, showHidden: true })).not.toContain(secret)
})
})
34 changes: 24 additions & 10 deletions src/server/services/connection-resolver.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,36 +28,50 @@ import { QUEUE_ERROR_CODES } from '../constants/error-codes'
*/
@Injectable()
export class ConnectionResolver {
private client: Redis | undefined
/**
* The resolved Redis client.
*
* An ECMAScript private field rather than a TypeScript `private` one, which
* is erased at runtime: an ioredis instance carries `options.password` as a
* plain field, so leaving this enumerable would let anything that serializes
* a service holding this resolver walk into the credentials.
*/
#client: Redis | undefined

/** The consumer's module options, which carry the Redis credentials. */
readonly #options: BymaxQueueModuleOptions

private mode: QueueConnectionMode | undefined

constructor(@Inject(BYMAX_QUEUE_OPTIONS) private readonly options: BymaxQueueModuleOptions) {}
constructor(@Inject(BYMAX_QUEUE_OPTIONS) options: BymaxQueueModuleOptions) {
this.#options = options
}

/** Resolve and validate the connection. Call once during module bootstrap. */
async init(): Promise<void> {
const cfg = this.options.connection
const cfg = this.#options.connection
if ('client' in cfg) {
this.initModeA(cfg.client)
return
}
this.mode = 'mode-b-owned'
this.client =
this.#client =
'url' in cfg
? new Redis(cfg.url, { ...(cfg.options ?? {}), lazyConnect: false })
: new Redis({ ...cfg.options, lazyConnect: false })
await this.waitReady(
this.options.connectionReadyTimeoutMs ?? DEFAULT_CONNECTION_READY_TIMEOUT_MS,
this.#options.connectionReadyTimeoutMs ?? DEFAULT_CONNECTION_READY_TIMEOUT_MS,
)
}

/** The resolved Queue-role client. */
getClient(): Redis {
if (!this.client) {
if (!this.#client) {
throw new QueueException(QUEUE_ERROR_CODES.CONNECTION_INVALID, 500, {
reason: 'not initialized',
})
}
return this.client
return this.#client
}

/** The resolved connection mode. */
Expand All @@ -77,8 +91,8 @@ export class ConnectionResolver {

/** Close the library-owned connection on shutdown; never touch a BYO client. */
async teardown(): Promise<void> {
if (this.isOwned() && this.client) {
const client = this.client
if (this.isOwned() && this.#client) {
const client = this.#client
await client.quit().catch(() => {
client.disconnect()
})
Expand All @@ -88,7 +102,7 @@ export class ConnectionResolver {
/** Validate and adopt a bring-your-own client for the Queue role. */
private initModeA(client: Redis): void {
this.mode = 'mode-a-byo'
this.client = client
this.#client = client
if (!isClientUsable(client)) {
throw new QueueException(QUEUE_ERROR_CODES.CONNECTION_INVALID, 500, { status: client.status })
}
Expand Down
16 changes: 8 additions & 8 deletions src/server/services/queue-events-registry.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,8 +35,8 @@ import { attachDefaultErrorListener } from '../utils/attach-error-listener'
@Injectable()
export class QueueEventsRegistry {
private readonly logger = new Logger(QueueEventsRegistry.name)
private readonly events = new Map<string, QueueEvents>()
private readonly connections = new Map<string, Redis>()
readonly #events = new Map<string, QueueEvents>()
readonly #connections = new Map<string, Redis>()

constructor(
@Inject(ConnectionResolver) private readonly connection: ConnectionResolver,
Expand All @@ -52,7 +52,7 @@ export class QueueEventsRegistry {
* @returns The `QueueEvents` instance for the queue.
*/
getOrCreate(queueName: string): QueueEvents {
const existing = this.events.get(queueName)
const existing = this.#events.get(queueName)
if (existing) return existing
const conn = duplicateConnection(this.connection.getClient())
let qe: QueueEvents
Expand All @@ -64,8 +64,8 @@ export class QueueEventsRegistry {
throw err
}
attachDefaultErrorListener(qe, this.logger, 'QueueEvents', queueName)
this.events.set(queueName, qe)
this.connections.set(queueName, conn)
this.#events.set(queueName, qe)
this.#connections.set(queueName, conn)
return qe
}

Expand All @@ -75,7 +75,7 @@ export class QueueEventsRegistry {
* @returns An immutable array of queue names.
*/
list(): readonly string[] {
return Array.from(this.events.keys())
return Array.from(this.#events.keys())
}

/**
Expand All @@ -84,7 +84,7 @@ export class QueueEventsRegistry {
* @returns A read-only view of the internal map.
*/
getAll(): ReadonlyMap<string, QueueEvents> {
return this.events
return this.#events
}

/**
Expand All @@ -101,6 +101,6 @@ export class QueueEventsRegistry {
* @returns A read-only snapshot of queue name to duplicated connection.
*/
getConnections(): ReadonlyMap<string, Redis> {
return new Map(this.connections)
return new Map(this.#connections)
}
}
12 changes: 6 additions & 6 deletions src/server/services/queue.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ type CleanableStatus = 'completed' | 'failed' | 'delayed' | 'wait' | 'active' |
@Injectable()
export class QueueService {
private readonly logger = new Logger(QueueService.name)
private readonly queues = new Map<string, Queue>()
readonly #queues = new Map<string, Queue>()

constructor(
@Inject(ConnectionResolver) private readonly connection: ConnectionResolver,
Expand All @@ -70,7 +70,7 @@ export class QueueService {
queueName: string,
overrides?: Partial<Omit<QueueOptions, 'connection' | 'prefix'>>,
): Queue<TData, TResult> {
const existing = this.queues.get(queueName)
const existing = this.#queues.get(queueName)
// The cache is keyed by name and holds default-generic Queues; the caller
// declares the payload generics, which BullMQ's invariant generics cannot
// narrow structurally. The cast re-projects the runtime Queue onto the
Expand All @@ -86,7 +86,7 @@ export class QueueService {
...overrides,
})
attachDefaultErrorListener(queue, this.logger, 'Queue', queueName)
this.queues.set(queueName, queue)
this.#queues.set(queueName, queue)
return queue as unknown as Queue<TData, TResult>
}

Expand Down Expand Up @@ -346,7 +346,7 @@ export class QueueService {

/** Return a read-only view of the cached queues. */
getCachedQueues(): ReadonlyMap<string, Queue> {
return this.queues
return this.#queues
}

/**
Expand All @@ -359,9 +359,9 @@ export class QueueService {
* exists to prevent. `QueueLifecycle` is the only caller.
*/
async closeAll(): Promise<void> {
for (const queue of this.queues.values()) {
for (const queue of this.#queues.values()) {
await queue.close().catch(() => undefined)
}
this.queues.clear()
this.#queues.clear()
}
}
Loading
Loading