fix(audit): storage cluster — M16 (presign doc/contract), M17 (per-port byte cap), M18 (replay-after-stat), L17 (mime allow-list, fingerprint hash), L22 (brochure portSlug)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-02 12:40:56 +02:00
parent 65ed90b603
commit 9305c030de
6 changed files with 132 additions and 26 deletions

View File

@@ -108,6 +108,15 @@ interface ProxyTokenPayload {
* tokens always include it.
*/
p?: string;
/**
* Optional per-port upload byte cap (audit M17). Carried only on `put`
* tokens. The proxy PUT handler enforces this in addition to the global
* `MAX_FILE_SIZE` ceiling, so a token minted against a 15 MB-capped port
* can't be replayed to write a 50 MB object. Absent on `get` tokens and
* on tokens minted before this field shipped (those fall back to the
* global ceiling).
*/
b?: number;
}
function b64urlEncode(buf: Buffer): string {
@@ -329,6 +338,9 @@ export class FilesystemBackend implements StorageBackend {
op: 'put',
c: opts.contentType,
...(opts.portSlug ? { p: opts.portSlug } : {}),
...(typeof opts.maxBytes === 'number' && Number.isFinite(opts.maxBytes)
? { b: opts.maxBytes }
: {}),
},
this.hmacSecret,
);

View File

@@ -11,6 +11,8 @@
* truth.
*/
import { createHash } from 'node:crypto';
import { and, eq, isNull } from 'drizzle-orm';
import { db } from '@/lib/db';
@@ -47,6 +49,16 @@ export interface PresignOpts {
* slug, so this is the matching enforcement.
*/
portSlug?: string;
/**
* Optional per-port upload byte cap for presigned uploads. Embedded in
* the filesystem proxy token (`b` field) and enforced by the proxy PUT
* handler, so a token minted against a 15 MB-capped port can't be used
* to write a 50 MB object (audit M17). S3 presigned PUTs can't sign a
* content-length-range on this path, so the cap there is re-checked
* server-side at register time. When unset, the proxy falls back to the
* global `MAX_FILE_SIZE` ceiling.
*/
maxBytes?: number;
}
export interface StorageBackend {
@@ -209,7 +221,12 @@ async function loadStorageConfig(): Promise<StorageConfigSnapshot> {
* client is held in memory until the next mismatch.
*/
function fingerprint(cfg: StorageConfigSnapshot): string {
return JSON.stringify(cfg);
// L17(c): hash the serialized config rather than holding the decrypted S3
// access key verbatim in a process-lifetime string. A SHA-256 digest still
// changes whenever any field (including the secret material) rotates, so
// the cache-invalidation semantics are unchanged, but the cleartext secret
// no longer lingers in the cache key.
return createHash('sha256').update(JSON.stringify(cfg)).digest('hex');
}
/**

View File

@@ -282,6 +282,28 @@ export class S3Backend implements StorageBackend {
}
}
/**
* Mint a presigned PUT URL for a direct browser upload.
*
* IMPORTANT (audit M16): `presignedPutObject` signs ONLY the bucket+key+
* expiry. It does NOT constrain the request's `Content-Type` or
* `Content-Length`, so a holder of this URL can PUT any bytes, of any
* type, up to the storage provider's own object-size ceiling for the
* 15-minute window. The `opts.contentType` / `opts.maxBytes` hints are
* advisory only on this path.
*
* Every consumer of an S3 presigned upload MUST re-validate the object
* server-side after the browser PUT (HEAD for size + magic-byte probe)
* and delete it on mismatch - the berth-PDF (`uploadBerthPdf`) and
* brochure (`registerBrochureVersion`) register endpoints already do
* this. Do NOT add a new presigned-upload consumer that trusts the
* uploaded bytes without that re-check.
*
* A stricter alternative is `presignedPostPolicy`, which DOES sign a
* `content-length-range` + `Content-Type` condition; it's deferred here
* because it changes the upload from a PUT to a multipart POST and every
* current caller is wired for PUT.
*/
async presignUpload(
key: string,
opts: PresignOpts,