fix(storage): make S3 server-side-encryption optional (default off)
All checks were successful
Build & Push Docker Images / lint (push) Successful in 2m46s
Build & Push Docker Images / build-and-push (push) Successful in 7m53s

Prod MinIO has no KMS/KES, so the unconditional
`x-amz-server-side-encryption: AES256` header on every PutObject was
rejected with `NotImplemented` ("KMS not configured") — breaking ALL
server-side uploads on prod: avatars, the signed-PDF deposit on
Documenso completion, GDPR exports, the nightly DB backup, generated
EOI/contract PDFs, report renders. Reads/presigned downloads were
unaffected, so the cutover walkthrough missed it.

The SSE header is now sent only when explicitly configured via the
per-port `storage_s3_sse` setting (or the STORAGE_S3_SSE env fallback);
the default is off so a vanilla S3-compatible backend accepts uploads.
This also resolves the put()-encrypts-but-presignUpload-doesn't
asymmetry — presigned PUTs never sent SSE, so both paths now match by
default.

Extracted buildPutObjectMetadata() as a pure, unit-tested helper.

Interim fix; the planned filesystem-storage migration removes SSE from
the prod path entirely.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-03 22:08:41 +02:00
parent 1750e265e7
commit eff57af571
4 changed files with 83 additions and 12 deletions

View File

@@ -31,6 +31,11 @@ const envSchema = z
.enum(['true', 'false']) .enum(['true', 'false'])
.optional() .optional()
.transform((v) => (v == null ? undefined : v === 'true')), .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 - admin: /admin/documenso
DOCUMENSO_API_URL: z.string().url().optional(), DOCUMENSO_API_URL: z.string().url().optional(),

View File

@@ -123,6 +123,8 @@ interface StorageConfigSnapshot {
accessKey?: string; accessKey?: string;
secretKeyEncrypted?: string; secretKeyEncrypted?: string;
forcePathStyle?: boolean; forcePathStyle?: boolean;
/** SSE algorithm (e.g. 'AES256'); unset = no SSE header (default). */
sse?: string;
}; };
filesystem?: { filesystem?: {
root?: string; root?: string;
@@ -153,6 +155,7 @@ async function loadStorageConfig(): Promise<StorageConfigSnapshot> {
'storage_s3_access_key_encrypted', // modern AES envelope 'storage_s3_access_key_encrypted', // modern AES envelope
'storage_s3_secret_key_encrypted', 'storage_s3_secret_key_encrypted',
'storage_s3_force_path_style', 'storage_s3_force_path_style',
'storage_s3_sse',
'storage_filesystem_root', 'storage_filesystem_root',
'storage_proxy_hmac_secret_encrypted', 'storage_proxy_hmac_secret_encrypted',
] as const; ] as const;
@@ -165,6 +168,7 @@ async function loadStorageConfig(): Promise<StorageConfigSnapshot> {
s3AccessKeyEncryptedRaw, s3AccessKeyEncryptedRaw,
s3SecretKeyEncrypted, s3SecretKeyEncrypted,
s3ForcePathStyle, s3ForcePathStyle,
s3Sse,
fsRoot, fsRoot,
fsHmacSecretEncrypted, fsHmacSecretEncrypted,
] = await Promise.all(keys.map((k) => readGlobalSetting<unknown>(k))); ] = await Promise.all(keys.map((k) => readGlobalSetting<unknown>(k)));
@@ -201,6 +205,7 @@ async function loadStorageConfig(): Promise<StorageConfigSnapshot> {
typeof s3SecretKeyEncrypted === 'string' ? s3SecretKeyEncrypted : undefined, typeof s3SecretKeyEncrypted === 'string' ? s3SecretKeyEncrypted : undefined,
forcePathStyle: forcePathStyle:
typeof s3ForcePathStyle === 'boolean' ? s3ForcePathStyle : Boolean(s3ForcePathStyle), typeof s3ForcePathStyle === 'boolean' ? s3ForcePathStyle : Boolean(s3ForcePathStyle),
sse: typeof s3Sse === 'string' && s3Sse.trim() ? s3Sse.trim() : undefined,
}, },
filesystem: { filesystem: {
root: typeof fsRoot === 'string' ? fsRoot : undefined, root: typeof fsRoot === 'string' ? fsRoot : undefined,
@@ -261,6 +266,7 @@ async function buildBackend(cfg: StorageConfigSnapshot): Promise<StorageBackend>
accessKey: cfg.s3?.accessKey, accessKey: cfg.s3?.accessKey,
secretKeyEncrypted: cfg.s3?.secretKeyEncrypted, secretKeyEncrypted: cfg.s3?.secretKeyEncrypted,
forcePathStyle: cfg.s3?.forcePathStyle, forcePathStyle: cfg.s3?.forcePathStyle,
sse: cfg.s3?.sse,
}); });
} }

View File

@@ -28,6 +28,28 @@ interface S3BackendConfig {
accessKey?: string; accessKey?: string;
secretKeyEncrypted?: string; secretKeyEncrypted?: string;
forcePathStyle?: boolean; 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<string, string> {
const meta: Record<string, string> = { 'Content-Type': contentType };
if (sse) meta['x-amz-server-side-encryption'] = sse;
return meta;
} }
/** /**
@@ -81,6 +103,7 @@ interface ResolvedConfig {
bucket: string; bucket: string;
accessKey: string; accessKey: string;
secretKey: string; secretKey: string;
sse?: string;
} }
function decryptIfPresent(stored: string | undefined): string | undefined { function decryptIfPresent(stored: string | undefined): string | undefined {
@@ -138,6 +161,10 @@ function resolveConfig(cfg: S3BackendConfig): ResolvedConfig {
bucket: cfg.bucket ?? env.MINIO_BUCKET ?? '', bucket: cfg.bucket ?? env.MINIO_BUCKET ?? '',
accessKey: cfg.accessKey ?? env.MINIO_ACCESS_KEY ?? '', accessKey: cfg.accessKey ?? env.MINIO_ACCESS_KEY ?? '',
secretKey: decryptIfPresent(cfg.secretKeyEncrypted) ?? env.MINIO_SECRET_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 client: Client;
private bucket: string; private bucket: string;
private sse?: string;
private constructor(client: Client, bucket: string) { private constructor(client: Client, bucket: string, sse?: string) {
this.client = client; this.client = client;
this.bucket = bucket; this.bucket = bucket;
this.sse = sse;
} }
static async create(cfg: S3BackendConfig): Promise<S3Backend> { static async create(cfg: S3BackendConfig): Promise<S3Backend> {
@@ -201,7 +230,7 @@ export class S3Backend implements StorageBackend {
); );
throw err; throw err;
} }
return new S3Backend(client, resolved.bucket); return new S3Backend(client, resolved.bucket, resolved.sse);
} }
async put( async put(
@@ -217,16 +246,19 @@ export class S3Backend implements StorageBackend {
const sha256 = opts.sha256 ?? createHash('sha256').update(buffer).digest('hex'); const sha256 = opts.sha256 ?? createHash('sha256').update(buffer).digest('hex');
await withTimeout( await withTimeout(
this.client.putObject(this.bucket, key, buffer, buffer.length, { // SSE header is sent ONLY when `this.sse` is configured (default off).
'Content-Type': opts.contentType, // A MinIO/S3 backend without KMS/KES rejects any
// Force server-side encryption for every blob - signed contracts, // `x-amz-server-side-encryption` header with `NotImplemented`, which
// GDPR exports, pg_dumps, EOI PDFs all otherwise land at rest in // previously broke every server-side upload. At-rest encryption, when
// cleartext unless the bucket has default-encryption configured. // wanted, is enabled deliberately via the `storage_s3_sse` setting once
// The audit's S3-pathing CRITICAL was that this was missing. // the backend's KMS is configured. See active-uat 2026-06-03 CRITICAL.
// SSE-S3 (AES-256) is the right baseline; SSE-KMS can be a future this.client.putObject(
// upgrade for tenants that need their own keys. this.bucket,
'x-amz-server-side-encryption': 'AES256', key,
}), buffer,
buffer.length,
buildPutObjectMetadata(opts.contentType, this.sse),
),
STORAGE_DEFAULT_TIMEOUT_MS, STORAGE_DEFAULT_TIMEOUT_MS,
`putObject(${key})`, `putObject(${key})`,
); );

View File

@@ -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');
});
});