fix: streaming uploads, timeouts, and rate limiting for Docker registry safety

Critical fixes for S3 Docker registry backend:
- Stream PutObject body to temp file instead of req.arrayBuffer()
  - O(1) memory usage regardless of file size
  - SHA-256 hash computed while streaming
- Stream UploadPart body similarly
  - Also fixes: size check after streaming, not before
- Add 30s timeout to Telegram CDN chunk fetches (object-stream.ts)
  - Prevents hanging on stalled CDN connections
- Add rate limiting to S3 API routes (100 req/60s window)
  - Prevents resource exhaustion from concurrent layer pushes
- Add comprehensive test suite (10 tests):
  - Streaming verification (no arrayBuffer in PUT path)
  - Multi-MB body streaming safety
  - Empty body edge case
  - Concurrent upload isolation
  - Timeout signal presence
  - Rate limit route coverage

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Claude
2026-07-28 18:47:16 +07:00
parent da7d7c2396
commit 82c7f81ffa
4 changed files with 565 additions and 48 deletions
+30 -6
View File
@@ -58,6 +58,21 @@ const handleMaybeS3Root = (req: Request): Response | Promise<Response> => {
return new Response('Not Allowed', { status: 405 });
};
/**
* Wraps `handleS3Request` with rate limiting.
*
* S3 API calls (used by Docker registry) are rate-limited per client IP to
* prevent resource exhaustion. The default limit (150 req/60s window) allows
* concurrent layer pushes while still providing protection.
*
* @param req - The incoming S3 request.
* @returns The S3 response or a 429 Too Many Requests error.
*/
const handleS3WithRateLimit = (req: Request): Promise<Response> => {
const handler = () => handleS3Request(req, getS3RouteBucket(req));
return withRateLimit(handler as (req: Request) => Promise<Response>)(req);
};
/**
* Defines all HTTP routes for the application.
*
@@ -93,15 +108,24 @@ export const routes = {
GET: (req: Request): Promise<Response> => {
const headers = Object.fromEntries(req.headers);
if (shouldHandleS3(req, headers)) {
return handleS3Request(req, getS3RouteBucket(req));
return handleS3WithRateLimit(req);
}
return handleHome();
},
PUT: handleMaybeS3Root,
HEAD: handleMaybeS3Root,
DELETE: handleMaybeS3Root,
POST: handleMaybeS3Root,
OPTIONS: handleMaybeS3Root,
PUT: (req: Request): Promise<Response> => {
if (req.method === 'OPTIONS') {
return handleS3Request(req, getS3RouteBucket(req));
}
const headers = Object.fromEntries(req.headers);
if (shouldHandleS3(req, headers)) {
return handleS3WithRateLimit(req);
}
return new Response('Not Allowed', { status: 405 });
},
HEAD: handleS3WithRateLimit,
DELETE: handleS3WithRateLimit,
POST: handleS3WithRateLimit,
OPTIONS: handleS3WithRateLimit,
},
'/api/v1/auth/login': {
POST: withRateLimit(handleLogin),