Skip to content

[API] News media S3 stream piped to HTTP response with no error/abort handling (pod crash, fd leak) #546

Description

@Flegma

Location: src/news/news.controller.ts:129 (api)

What: The public media-serving endpoints do result.stream.pipe(res) on a raw MinIO getObject Readable (news.service.getImageStream, branding/avatars/trophies getStream/getFile/getPwaIcon all return client.getObject(...)). No .on('error') listener is attached to the source stream and no res.on('close') handler destroys it. main.ts (line 117) registers only a process.on('unhandledRejection') handler and NO process.on('uncaughtException') handler. When a Readable emits 'error' with zero 'error' listeners, Node re-throws it as an uncaughtException, which with no handler terminates the whole Node process. Separately, when the HTTP client disconnects mid-download there is nothing to destroy the source stream, so the underlying MinIO socket/fd is held until it times out.

Impact: A client requests GET /avatars/players/ (a very hot path, rendered for every player/team in the UI) or GET /news/image/. The MinIO read stream errors partway through the transfer (S3/MinIO restart, TCP reset, object deleted mid-read) -> the source Readable emits 'error' with no listener -> uncaughtException with no handler -> the api Node process exits, dropping every concurrent HTTP request and WebSocket connection on that pod. Independently, users navigating away mid-image-load repeatedly abort the response with no source-stream teardown, leaking MinIO sockets/file descriptors until the pod exhausts fds and crashes.

Suggested fix: Before piping, attach result.stream.on('error', (e) => { res.destroy(e); }) and res.on('close', () => result.stream.destroy()) (or use stream.pipeline / res.download) in each of news.controller.ts:129, branding.controller.ts:81 & :102, avatars.controller.ts:212, and trophies.controller.ts:78; also add a global process.on('uncaughtException') handler in main.ts.

Verifier evidence

news.controller.ts:129 result.stream.pipe(res); (source from getImageStream). news.service.ts:245-251 returns stream from this.s3.get(key). s3.service.ts:92-96 public async get(...): Promise<Readable> { return await this.client.getObject(bucket, filename); } (raw MinIO stream). Same pattern: branding.controller.ts:81 and :102, avatars.controller.ts:212 (getStream at avatars.service.ts:379-388), trophies.controller.ts:78. main.ts:117-119 only process.on("unhandledRejection", ...); grep of src/ for uncaughtException|useGlobalFilters|ExceptionFilter returns only that one unhandledRejection line. No .on('error') on any of these streams and no res.on('close')/res.on('aborted') handlers exist in the cited controllers.


Found by the 2026-07 multi-agent code audit; adversarially verified (CONFIRMED, P1). One of a batch of findings from that pass.

Metadata

Metadata

Assignees

No one assigned

    Labels

    P1-highStability & reliabilityaudit-2026-07Findings from the 2026-07 code audit / review passreliabilityReliability or availability concernresource-leakResource or connection leakservice:api5stackgg/api service

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    Status
    Done

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions