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:
@@ -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,
|
||||
);
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user