diff --git a/src/lib/env.ts b/src/lib/env.ts index 7ed96c92..66a6988b 100644 --- a/src/lib/env.ts +++ b/src/lib/env.ts @@ -31,6 +31,11 @@ const envSchema = z .enum(['true', 'false']) .optional() .transform((v) => (v == null ? undefined : v === 'true')), + // Server-side-encryption algorithm for S3 PutObject (e.g. 'AES256' or + // 'aws:kms'). UNSET = no SSE header — required for MinIO/S3 backends + // without KMS/KES, which reject the header with NotImplemented. The + // per-port `storage_s3_sse` setting overrides this fallback. + STORAGE_S3_SSE: z.string().min(1).optional(), // Documenso - admin: /admin/documenso DOCUMENSO_API_URL: z.string().url().optional(), diff --git a/src/lib/storage/index.ts b/src/lib/storage/index.ts index 5f10bb2b..792d2b71 100644 --- a/src/lib/storage/index.ts +++ b/src/lib/storage/index.ts @@ -123,6 +123,8 @@ interface StorageConfigSnapshot { accessKey?: string; secretKeyEncrypted?: string; forcePathStyle?: boolean; + /** SSE algorithm (e.g. 'AES256'); unset = no SSE header (default). */ + sse?: string; }; filesystem?: { root?: string; @@ -153,6 +155,7 @@ async function loadStorageConfig(): Promise { 'storage_s3_access_key_encrypted', // modern AES envelope 'storage_s3_secret_key_encrypted', 'storage_s3_force_path_style', + 'storage_s3_sse', 'storage_filesystem_root', 'storage_proxy_hmac_secret_encrypted', ] as const; @@ -165,6 +168,7 @@ async function loadStorageConfig(): Promise { s3AccessKeyEncryptedRaw, s3SecretKeyEncrypted, s3ForcePathStyle, + s3Sse, fsRoot, fsHmacSecretEncrypted, ] = await Promise.all(keys.map((k) => readGlobalSetting(k))); @@ -201,6 +205,7 @@ async function loadStorageConfig(): Promise { typeof s3SecretKeyEncrypted === 'string' ? s3SecretKeyEncrypted : undefined, forcePathStyle: typeof s3ForcePathStyle === 'boolean' ? s3ForcePathStyle : Boolean(s3ForcePathStyle), + sse: typeof s3Sse === 'string' && s3Sse.trim() ? s3Sse.trim() : undefined, }, filesystem: { root: typeof fsRoot === 'string' ? fsRoot : undefined, @@ -261,6 +266,7 @@ async function buildBackend(cfg: StorageConfigSnapshot): Promise accessKey: cfg.s3?.accessKey, secretKeyEncrypted: cfg.s3?.secretKeyEncrypted, forcePathStyle: cfg.s3?.forcePathStyle, + sse: cfg.s3?.sse, }); } diff --git a/src/lib/storage/s3.ts b/src/lib/storage/s3.ts index 25b81320..d0918407 100644 --- a/src/lib/storage/s3.ts +++ b/src/lib/storage/s3.ts @@ -28,6 +28,28 @@ interface S3BackendConfig { accessKey?: string; secretKeyEncrypted?: string; forcePathStyle?: boolean; + /** + * Server-side-encryption algorithm to request on every PutObject, e.g. + * `'AES256'` (SSE-S3) or `'aws:kms'` (SSE-KMS). LEAVE UNSET for backends + * that don't have KMS/KES configured — a plain MinIO rejects ANY + * `x-amz-server-side-encryption` header with `NotImplemented` ("KMS not + * configured"), which previously broke every server-side upload. Default + * (undefined) = no header sent. + */ + sse?: string; +} + +/** + * Build the metadata map passed to MinIO's `putObject`. The + * server-side-encryption header is included ONLY when an algorithm is + * configured — sending it against a backend without KMS/KES fails the + * whole upload (`NotImplemented`). Pure + exported so the SSE policy is + * unit-testable without a live S3. + */ +export function buildPutObjectMetadata(contentType: string, sse?: string): Record { + const meta: Record = { 'Content-Type': contentType }; + if (sse) meta['x-amz-server-side-encryption'] = sse; + return meta; } /** @@ -81,6 +103,7 @@ interface ResolvedConfig { bucket: string; accessKey: string; secretKey: string; + sse?: string; } function decryptIfPresent(stored: string | undefined): string | undefined { @@ -138,6 +161,10 @@ function resolveConfig(cfg: S3BackendConfig): ResolvedConfig { bucket: cfg.bucket ?? env.MINIO_BUCKET ?? '', accessKey: cfg.accessKey ?? env.MINIO_ACCESS_KEY ?? '', secretKey: decryptIfPresent(cfg.secretKeyEncrypted) ?? env.MINIO_SECRET_KEY ?? '', + // Default OFF: only request server-side encryption when explicitly set + // (per-port `storage_s3_sse` setting → cfg.sse, or the STORAGE_S3_SSE env + // fallback). A backend without KMS rejects the header outright. + sse: cfg.sse ?? env.STORAGE_S3_SSE ?? undefined, }; } @@ -146,10 +173,12 @@ export class S3Backend implements StorageBackend { private client: Client; private bucket: string; + private sse?: string; - private constructor(client: Client, bucket: string) { + private constructor(client: Client, bucket: string, sse?: string) { this.client = client; this.bucket = bucket; + this.sse = sse; } static async create(cfg: S3BackendConfig): Promise { @@ -201,7 +230,7 @@ export class S3Backend implements StorageBackend { ); throw err; } - return new S3Backend(client, resolved.bucket); + return new S3Backend(client, resolved.bucket, resolved.sse); } async put( @@ -217,16 +246,19 @@ export class S3Backend implements StorageBackend { const sha256 = opts.sha256 ?? createHash('sha256').update(buffer).digest('hex'); await withTimeout( - this.client.putObject(this.bucket, key, buffer, buffer.length, { - 'Content-Type': opts.contentType, - // Force server-side encryption for every blob - signed contracts, - // GDPR exports, pg_dumps, EOI PDFs all otherwise land at rest in - // cleartext unless the bucket has default-encryption configured. - // The audit's S3-pathing CRITICAL was that this was missing. - // SSE-S3 (AES-256) is the right baseline; SSE-KMS can be a future - // upgrade for tenants that need their own keys. - 'x-amz-server-side-encryption': 'AES256', - }), + // SSE header is sent ONLY when `this.sse` is configured (default off). + // A MinIO/S3 backend without KMS/KES rejects any + // `x-amz-server-side-encryption` header with `NotImplemented`, which + // previously broke every server-side upload. At-rest encryption, when + // wanted, is enabled deliberately via the `storage_s3_sse` setting once + // the backend's KMS is configured. See active-uat 2026-06-03 CRITICAL. + this.client.putObject( + this.bucket, + key, + buffer, + buffer.length, + buildPutObjectMetadata(opts.contentType, this.sse), + ), STORAGE_DEFAULT_TIMEOUT_MS, `putObject(${key})`, ); diff --git a/tests/unit/storage/s3-sse.test.ts b/tests/unit/storage/s3-sse.test.ts new file mode 100644 index 00000000..4690c4fb --- /dev/null +++ b/tests/unit/storage/s3-sse.test.ts @@ -0,0 +1,28 @@ +/** + * SSE (server-side-encryption) header policy for the S3 backend. + * + * Regression (2026-06-03 prod): MinIO with no KMS/KES rejected EVERY + * PutObject because `put()` unconditionally sent + * `x-amz-server-side-encryption: AES256`, which a backend without KMS + * answers with `NotImplemented` ("KMS not configured"). The header must + * only be sent when SSE is explicitly configured; the default is OFF so + * a vanilla S3-compatible backend accepts uploads. + */ + +import { describe, expect, it } from 'vitest'; + +import { buildPutObjectMetadata } from '@/lib/storage/s3'; + +describe('buildPutObjectMetadata', () => { + it('omits the server-side-encryption header when no SSE is configured', () => { + const meta = buildPutObjectMetadata('application/pdf', undefined); + expect(meta['Content-Type']).toBe('application/pdf'); + expect(meta['x-amz-server-side-encryption']).toBeUndefined(); + }); + + it('sends the configured SSE algorithm when one is set', () => { + const meta = buildPutObjectMetadata('image/png', 'AES256'); + expect(meta['Content-Type']).toBe('image/png'); + expect(meta['x-amz-server-side-encryption']).toBe('AES256'); + }); +});