Skip to content

fix(security)!: sanitize unmapped S3 errors and stop mid-stream socket leaks - #124

Merged
gmoon merged 1 commit into
masterfrom
fix/sanitize-errors-and-stream-leaks
Oct 31, 2026
Merged

gmoon merged 1 commit into
masterfrom
fix/sanitize-errors-and-stream-leaks

Conversation

@gmoon

@gmoon gmoon commented Jul 4, 2026

Copy link
Copy Markdown
Owner

Two hardening fixes from a security review of the S3-fronting proxy. Scoped to the two highest-value findings; validated locally (lint + type-check + build + 49/49 unit tests green).

1. Error-detail leakage (MEDIUM) — src/s3-gateway.ts

mapError wrapped only NoSuchKey/NoSuchBucket→404, AccessDenied→403, InvalidRange→416 and rethrew everything else unchanged. Unmapped S3ServiceExceptions carry sensitive detail:

  • PermanentRedirect → discloses the bucket's real region/endpoint
  • SlowDown/InternalError → carry $metadata.requestId and often the bucket name

…and the examples render err.message straight into the response body, so a client can force these conditions and harvest AWS internals.

Fix: unmapped errors are wrapped in a generic S3ProxyError(500); the original is preserved on cause for server-side logging only. Applies to both fetch() and healthCheck() — the latter is client-reachable via the examples' /health route, so narrowing to fetch() alone would leave /health leaking.

Important

Behavior change: send()/init()/fetch() now reject with a sanitized S3ProxyError(500) for previously-unclassified errors instead of the raw error. Inspect err.cause for the original. The known mappings (404/403/416) are unchanged. Unit tests updated to assert the new contract (sanitized message + preserved cause).

2. Stream/socket leak in examples (MEDIUM) — examples/{http,express-basic,fastify-basic}.ts

.pipe(res) does not destroy the S3 source stream when the client disconnects mid-transfer, leaking the underlying S3 socket under abusive clients (open a range request, disconnect, repeat). Switched to stream.pipeline(), which destroys the source on client abort or write error. (hono-basic.ts already handles this via Readable.toWeb cancellation.)

Deliberately not included (tracked separately)

Test plan

  • npm run lint / type-check / build / test:unit (49/49) green locally
  • CI green
  • Confirm the error-contract change is acceptable for downstream consumers

@gmoon
gmoon force-pushed the fix/sanitize-errors-and-stream-leaks branch from 8e53ef7 to 2f0f7f6 Compare October 31, 2026 19:55
@gmoon

gmoon commented Oct 31, 2026

Copy link
Copy Markdown
Owner Author

Rebased onto current master (was 40 commits behind) and finished:

  • Error sanitization (src/s3-gateway.ts) merged cleanly — the mapError → S3ProxyError(500) with original on cause is intact.
  • Stream-leak fix relocated to the core. Master had refactored examples/express-basic.ts to the v4.1 proxy.middleware() one-liner, whose streaming lives in src/index.ts writeBody() — which still used the leaky stream.pipe(res). Moved the pipeline() fix there, so pipe() / middleware() / static-site are all covered at the source (not just the examples). http.ts and fastify-basic.ts keep their direct-fetch() pipeline() fixes.
  • Minor contract refinement: a mid-stream source error now terminates (destroys) the response instead of end()-ing it — a truncated body surfaces as a broken connection rather than a silently-short success. test/pipe.test.ts updated to assert this.

Local: lint + type-check + build + 76/76 unit tests green. Verifying CI next.

@gmoon
gmoon force-pushed the fix/sanitize-errors-and-stream-leaks branch from 2f0f7f6 to 6c29d61 Compare October 31, 2026 20:01
…t leaks

Two hardening fixes from a security review of the S3-fronting proxy.

1. Error-detail leakage (src/s3-gateway.ts): mapError() mapped only
   NoSuchKey/NoSuchBucket->404, AccessDenied->403, InvalidRange->416 and
   rethrew everything else unchanged. Unmapped S3ServiceExceptions leak
   sensitive detail (PermanentRedirect -> bucket region/endpoint;
   SlowDown/InternalError -> requestId and often bucket name), which the
   examples render straight into the response body. Unmapped errors are
   now wrapped in a generic S3ProxyError(500); the original is preserved
   on `cause` for server-side logging only.

2. Mid-stream socket leak (src/index.ts writeBody, examples/http.ts,
   examples/fastify-basic.ts): plain stream.pipe(res) does not destroy the
   S3 source stream when the client disconnects mid-transfer, leaking the
   underlying S3 socket. Switched to stream.pipeline(), which destroys the
   source on client abort or write error.

Rebased onto master: the leak fix moved from examples/express-basic.ts
(now the v4.1 middleware() one-liner) into the shared writeBody() path, so
pipe()/middleware()/static-site are all covered at the core.

BREAKING CHANGE: fetch(), send(), and init() now reject with a sanitized
S3ProxyError(500) for previously-unclassified errors instead of the raw
S3ServiceException/AWS error. Inspect `err.cause` for the original. The
known mappings (S3NotFound/404, S3Forbidden/403, S3InvalidRange/416) are
unchanged. Additionally, a mid-stream S3 failure (after headers are sent)
now terminates (destroys) the response rather than end()-ing it, so a
truncated body surfaces as a broken connection instead of a silent short
success. Consumers that branch on raw error types/messages or rely on the
old end()-on-mid-stream-error behavior must update.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@gmoon
gmoon force-pushed the fix/sanitize-errors-and-stream-leaks branch from 6c29d61 to 5dad0c8 Compare October 31, 2026 20:10
@gmoon gmoon changed the title fix(security): sanitize unmapped errors + stop example stream leaks fix(security)!: sanitize unmapped S3 errors and stop mid-stream socket leaks Oct 31, 2026
@gmoon
gmoon merged commit ad5d88f into master Oct 31, 2026
12 checks passed
@gmoon gmoon mentioned this pull request Oct 31, 2026
gmoon added a commit that referenced this pull request Oct 31, 2026
Major release. Breaking security hardening (#124):
- fetch()/send()/init() reject with a sanitized S3ProxyError(500) for previously-unclassified errors; original on err.cause. Known 404/403/416 mappings unchanged.
- Mid-stream S3 failures now terminate (destroy) the response instead of end()-ing it.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant