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'])
|
.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(),
|
||||||
|
|||||||
@@ -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,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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})`,
|
||||||
);
|
);
|
||||||
|
|||||||
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