fix(storage): make S3 server-side-encryption optional (default off)
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:
@@ -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(),
|
||||
|
||||
@@ -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<StorageConfigSnapshot> {
|
||||
'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<StorageConfigSnapshot> {
|
||||
s3AccessKeyEncryptedRaw,
|
||||
s3SecretKeyEncrypted,
|
||||
s3ForcePathStyle,
|
||||
s3Sse,
|
||||
fsRoot,
|
||||
fsHmacSecretEncrypted,
|
||||
] = await Promise.all(keys.map((k) => readGlobalSetting<unknown>(k)));
|
||||
@@ -201,6 +205,7 @@ async function loadStorageConfig(): Promise<StorageConfigSnapshot> {
|
||||
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<StorageBackend>
|
||||
accessKey: cfg.s3?.accessKey,
|
||||
secretKeyEncrypted: cfg.s3?.secretKeyEncrypted,
|
||||
forcePathStyle: cfg.s3?.forcePathStyle,
|
||||
sse: cfg.s3?.sse,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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<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;
|
||||
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<S3Backend> {
|
||||
@@ -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})`,
|
||||
);
|
||||
|
||||
28
tests/unit/storage/s3-sse.test.ts
Normal file
28
tests/unit/storage/s3-sse.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user