From 8e870de9d7a7400a8f921d4dc29f98b0c7b8ba81 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=B5=85=E5=BF=B5?= Date: Thu, 17 Sep 2026 20:35:08 +0800 Subject: [PATCH] fix(storage): correct presign content-type claim; head returns contentType Spec review (live negative test) found s3-request-presigner unconditionally unsigns content-type: header-less PUT returns 200, so the previous docblock claiming signature enforcement was a false guarantee. Content-Type is now documented as convention-only; head() returns contentType so the complete step (Task 19) can verify it server-side against the session declaration. Co-Authored-By: Claude Code --- packages/storage/src/minio.adapter.ts | 14 ++++++++------ packages/storage/src/port.ts | 8 +++++++- packages/storage/test/minio.adapter.spec.ts | 1 + 3 files changed, 16 insertions(+), 7 deletions(-) diff --git a/packages/storage/src/minio.adapter.ts b/packages/storage/src/minio.adapter.ts index ba344b3..1d1be85 100644 --- a/packages/storage/src/minio.adapter.ts +++ b/packages/storage/src/minio.adapter.ts @@ -46,10 +46,12 @@ export class MinioStorageAdapter implements StoragePort { } /** - * 预签名上传。S3 预签名无法对 Content-Length 签名(SigV4 规范排除该头), - * bytes 参数仅作为端口契约保留:字节数核验发生在上传完成后的 head/verify 阶段。 - * Content-Type 经 PutObjectCommand 的 ContentType 参数参与签名,客户端必须 - * 原样携带返回的 headers,否则 403 SignatureDoesNotMatch。 + * 预签名上传。SigV4 预签名无法对 Content-Length 签名(规范排除);所装版本的 + * s3-request-presigner 亦将 Content-Type 列为不可签名头(prepareRequest 强制 + * unsignableHeaders.add("content-type"),实测不带头的 PUT 返回 200)。 + * 因此返回的 headers 是「约定必须发送」而非签名强制:字节完整性由 verify 阶段 + * 的 head 核验,Content-Type 一致性由 complete 时服务端比对 head().contentType + * 与会话申明值兜底(见 Task 19)。bytes 参数仅作为端口契约保留。 */ async presignPut(objectKey: string, bytes: number, contentType: string, ttlSeconds: number): Promise { void bytes; // 见上:签名不含 Content-Length,大小核验由 verify 阶段负责 @@ -73,10 +75,10 @@ export class MinioStorageAdapter implements StoragePort { }); } - async head(objectKey: string): Promise<{ bytes: number; etag: string } | null> { + async head(objectKey: string): Promise<{ bytes: number; etag: string; contentType: string } | null> { try { const out = await this.client.send(new HeadObjectCommand({ Bucket: this.bucket, Key: objectKey })); - return { bytes: out.ContentLength ?? 0, etag: out.ETag ?? '' }; + return { bytes: out.ContentLength ?? 0, etag: out.ETag ?? '', contentType: out.ContentType ?? '' }; } catch (error) { if (isNotFound(error)) return null; throw error; diff --git a/packages/storage/src/port.ts b/packages/storage/src/port.ts index 600f0fc..b2c93ea 100644 --- a/packages/storage/src/port.ts +++ b/packages/storage/src/port.ts @@ -9,9 +9,15 @@ export interface UploadCredential { expiresAt: string; // ISO } export interface StoragePort { + /** + * 预签名上传凭证。注意:headers 为「约定必须发送」而非签名强制——SigV4 预签名 + * 不含 Content-Length,且 SDK 将 Content-Type 列为不可签名头。字节核验在 + * complete 阶段经 head().bytes 比对,Content-Type 一致性经 head().contentType + * 与会话申明值比对(服务端兜底,见 Task 19)。 + */ presignPut(objectKey: string, bytes: number, contentType: string, ttlSeconds: number): Promise; presignGet(objectKey: string, ttlSeconds: number): Promise; - head(objectKey: string): Promise<{ bytes: number; etag: string } | null>; + head(objectKey: string): Promise<{ bytes: number; etag: string; contentType: string } | null>; getStream(objectKey: string): Promise; put(objectKey: string, body: Buffer, contentType: string): Promise<{ etag: string }>; /** 复制到正式键;目标已存在时校验一致性并幂等返回(禁止覆盖语义)。 */ diff --git a/packages/storage/test/minio.adapter.spec.ts b/packages/storage/test/minio.adapter.spec.ts index d554821..f67376b 100644 --- a/packages/storage/test/minio.adapter.spec.ts +++ b/packages/storage/test/minio.adapter.spec.ts @@ -31,6 +31,7 @@ describe('MinioStorageAdapter(真实 MinIO 集成)', () => { expect(head).not.toBeNull(); expect(head?.bytes).toBe(body.length); expect(head?.etag).toBeTruthy(); + expect(head?.contentType).toBe('application/octet-stream'); }); it('put + getStream: 写入后流式读回的字节与写入一致', async () => {