diff --git a/src/interfaces/http/controllers/web-api-controller.ts b/src/interfaces/http/controllers/web-api-controller.ts index 76a4998..7825cc1 100644 --- a/src/interfaces/http/controllers/web-api-controller.ts +++ b/src/interfaces/http/controllers/web-api-controller.ts @@ -11,6 +11,7 @@ import { sanitizeFilenameHeader } from '../../../shared/http/filename'; import logger from '../../../shared/logger/index'; import { getErrorMessage } from '../../../shared/utils/file'; import { streamToTemp } from '../../../shared/utils/temp-stream'; +import { BucketNameSchema } from '../../../shared/validation/schemas'; /** Lazily built upload use case wired to the DI singletons. */ const getUploadUseCase = () => @@ -80,7 +81,7 @@ export const handleListBucketsV1 = async (): Promise => { */ export const handleCreateBucketV1 = async (req: Request): Promise => { const body = (await req.json()) as { name?: string }; - if (!body.name || !/^[a-z0-9][a-z0-9.-]{1,61}[a-z0-9]$/.test(body.name)) { + if (!body.name || !BucketNameSchema.safeParse(body.name).success) { return jsonError('Invalid bucket name. Use lowercase, 3-63 chars, no underscore', 400); } const existing = await bucketRepository.findByName(body.name); diff --git a/src/interfaces/http/routes/index.ts b/src/interfaces/http/routes/index.ts index 8acf6c3..f02669c 100644 --- a/src/interfaces/http/routes/index.ts +++ b/src/interfaces/http/routes/index.ts @@ -100,9 +100,18 @@ export const routes = { if (shouldHandleS3(req, headers)) return handleS3Direct(req); return Promise.resolve(new Response('Not Allowed', { status: 405 })); }, - HEAD: handleS3Direct, - DELETE: handleS3Direct, - POST: handleS3Direct, + HEAD: (req: Request): Promise => { + if (shouldHandleS3(req, Object.fromEntries(req.headers))) return handleS3Direct(req); + return Promise.resolve(new Response('Not Found', { status: 404 })); + }, + DELETE: (req: Request): Promise => { + if (shouldHandleS3(req, Object.fromEntries(req.headers))) return handleS3Direct(req); + return Promise.resolve(new Response('Not Found', { status: 404 })); + }, + POST: (req: Request): Promise => { + if (shouldHandleS3(req, Object.fromEntries(req.headers))) return handleS3Direct(req); + return Promise.resolve(new Response('Not Found', { status: 404 })); + }, OPTIONS: handleCatchAllOptions, }, // Catch-all for S3 path-style requests (/{bucket}/{key} ...) diff --git a/src/interfaces/s3/virtual-host.ts b/src/interfaces/s3/virtual-host.ts index a840e0d..4208fb9 100644 --- a/src/interfaces/s3/virtual-host.ts +++ b/src/interfaces/s3/virtual-host.ts @@ -7,11 +7,13 @@ const stripPort = (host: string): string => { return host.split(':')[0].toLowerCase().replace(/\.$/, ''); }; -const isValidBucketLabel = (bucket: string): boolean => - /^[a-z0-9][a-z0-9.-]{1,61}[a-z0-9]$/.test(bucket) && - !bucket.includes('..') && - !bucket.includes('.-') && - !bucket.includes('-.'); +import { BucketNameSchema } from '../../shared/validation/schemas'; + +/** + * Validates a virtual-hosted bucket label against the single canonical + * bucket-name schema (same rules as bucket creation). + */ +const isValidBucketLabel = (bucket: string): boolean => BucketNameSchema.safeParse(bucket).success; export const extractS3BucketFromHost = (host: string, domains: string[]): string | null => { const normalizedHost = stripPort(host); diff --git a/test/s3-routing.test.ts b/test/s3-routing.test.ts index 6e61376..1758111 100644 --- a/test/s3-routing.test.ts +++ b/test/s3-routing.test.ts @@ -129,4 +129,21 @@ describe('S3 routing (routes table)', () => { ); expect(res.status).toBe(204); }); + + it('answers HEAD / without S3 headers as 404 (never S3-direct)', async () => { + const res = await routes['/'].HEAD(new Request('http://localhost:4000/', { method: 'HEAD' })); + expect(res.status).toBe(404); + }); + + it('answers DELETE / without S3 headers as 404 (never S3-direct)', async () => { + const res = await routes['/'].DELETE( + new Request('http://localhost:4000/', { method: 'DELETE' }), + ); + expect(res.status).toBe(404); + }); + + it('answers POST / without S3 headers as 404 (never S3-direct)', async () => { + const res = await routes['/'].POST(new Request('http://localhost:4000/', { method: 'POST' })); + expect(res.status).toBe(404); + }); });