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

@@ -20,7 +20,7 @@ import { Readable } from 'node:stream';
import { NextRequest, NextResponse } from 'next/server';
import { MAX_FILE_SIZE } from '@/lib/constants/file-validation';
import { ALLOWED_MIME_TYPES, MAX_FILE_SIZE } from '@/lib/constants/file-validation';
import {
AppError,
errorResponse,
@@ -63,21 +63,6 @@ export async function GET(
}
const { payload } = result;
// Single-use enforcement. SET NX with a TTL pinned to the token's own
// expiry so the dedup window never closes before the token does. Using
// the body half of the token as the dedup key (signature included
// would also work but body is enough - a reused token has the same body).
const replayKey = `storage:proxy:seen:${token.split('.')[0]}`;
const remainingSeconds = Math.max(
REPLAY_TTL_FLOOR_SECONDS,
Math.min(REPLAY_TTL_CEILING_SECONDS, payload.e - Math.floor(Date.now() / 1000) + 60),
);
const setOk = await redis.set(replayKey, '1', 'EX', remainingSeconds, 'NX');
if (setOk !== 'OK') {
logger.warn({ key: payload.k }, 'Storage proxy token replay rejected');
return errorResponse(new ForbiddenError('Token already used'));
}
let absolutePath: string;
try {
absolutePath = backend.resolveKeyForProxy(payload.k);
@@ -86,6 +71,11 @@ export async function GET(
return errorResponse(new ValidationError('Invalid key'));
}
// Confirm the file is servable BEFORE burning the single-use replay key
// (audit M18). The old order consumed the SET-NX key first, so a transient
// `fs.stat` failure / NFS hiccup / ENOENT permanently bricked the emailed
// URL ("Token already used" for its full life). Now a stat failure leaves
// the token unused and a genuine retry succeeds.
let size: number;
try {
const stat = await fs.stat(absolutePath);
@@ -101,12 +91,50 @@ export async function GET(
return errorResponse(err);
}
// Single-use enforcement. SET NX with a TTL pinned to the token's own
// expiry so the dedup window never closes before the token does. Using
// the body half of the token as the dedup key (signature included
// would also work but body is enough - a reused token has the same body).
// Claimed only now - after the file is confirmed servable - so an earlier
// transient error doesn't permanently consume the token (audit M18).
const replayKey = `storage:proxy:seen:${token.split('.')[0]}`;
const remainingSeconds = Math.max(
REPLAY_TTL_FLOOR_SECONDS,
Math.min(REPLAY_TTL_CEILING_SECONDS, payload.e - Math.floor(Date.now() / 1000) + 60),
);
const setOk = await redis.set(replayKey, '1', 'EX', remainingSeconds, 'NX');
if (setOk !== 'OK') {
logger.warn({ key: payload.k }, 'Storage proxy token replay rejected');
return errorResponse(new ForbiddenError('Token already used'));
}
// Convert the Node Readable into a Web ReadableStream for NextResponse.
// If the stream fails after this point we DEL the replay key so the
// customer's retry isn't bricked (audit M18) - the dedup intent is "one
// successful download", not "one attempt".
const nodeStream = createReadStream(absolutePath);
nodeStream.on('error', (err) => {
logger.warn({ err, key: payload.k }, 'Storage proxy stream failed; releasing replay key');
void redis.del(replayKey).catch(() => undefined);
});
const webStream = Readable.toWeb(nodeStream) as unknown as ReadableStream<Uint8Array>;
const headers = new Headers();
headers.set('Content-Type', payload.c ?? 'application/octet-stream');
// L17(a): constrain the served Content-Type to a known-safe allow-list.
// `payload.c` is issuer-signed (not attacker-forgeable) but a future buggy
// issuer could mint an active type (e.g. text/html) that a browser would
// render inline. Anything off the allow-list is served as a download with
// a generic octet-stream type; `nosniff` is set unconditionally below.
const tokenContentType = payload.c && ALLOWED_MIME_TYPES.has(payload.c) ? payload.c : null;
headers.set('Content-Type', tokenContentType ?? 'application/octet-stream');
if (!tokenContentType) {
// Force download for any non-allow-listed type so an unexpected
// content-type can never be rendered inline by the browser.
headers.set(
'Content-Disposition',
`attachment; filename="${(payload.f ?? 'download').replace(/"/g, '')}"`,
);
}
headers.set('Content-Length', String(size));
if (payload.f) {
// RFC 5987 - quote the filename and provide a UTF-8 fallback.
@@ -167,15 +195,25 @@ export async function PUT(
return errorResponse(new ForbiddenError('Token already used'));
}
// Effective byte cap. The token may carry a per-port `b` cap (from
// `system_settings.berth_pdf_max_upload_mb`); enforce the tighter of that
// and the global `MAX_FILE_SIZE` ceiling. Without this (audit M17) the
// proxy enforced only the global 50 MB and a rep could write 50 MB to a
// berth advertised as 15 MB-capped.
const effectiveCap =
typeof payload.b === 'number' && Number.isFinite(payload.b) && payload.b > 0
? Math.min(MAX_FILE_SIZE, payload.b)
: MAX_FILE_SIZE;
// Pre-flight size check via Content-Length so a malicious caller can't
// exhaust disk by streaming hundreds of MB before we look at the body.
const contentLengthHeader = req.headers.get('content-length');
const contentLength = contentLengthHeader ? Number(contentLengthHeader) : NaN;
if (Number.isFinite(contentLength) && contentLength > MAX_FILE_SIZE) {
if (Number.isFinite(contentLength) && contentLength > effectiveCap) {
return errorResponse(
new AppError(
413,
`File exceeds ${MAX_FILE_SIZE} byte cap (Content-Length: ${contentLength})`,
`File exceeds ${effectiveCap} byte cap (Content-Length: ${contentLength})`,
'PAYLOAD_TOO_LARGE',
),
);
@@ -187,7 +225,7 @@ export async function PUT(
// Read the body into a buffer with a hard cap. Filesystem deployments are
// small-tenant (single-node only - see FilesystemBackend boot guard) so
// 50 MB ceiling fits comfortably in heap; no streaming needed.
// the ceiling fits comfortably in heap; no streaming needed.
let buffer: Buffer;
try {
const chunks: Buffer[] = [];
@@ -197,14 +235,14 @@ export async function PUT(
const { done, value } = await reader.read();
if (done) break;
total += value.byteLength;
if (total > MAX_FILE_SIZE) {
if (total > effectiveCap) {
try {
await reader.cancel();
} catch {
/* ignore */
}
return errorResponse(
new AppError(413, `File exceeds ${MAX_FILE_SIZE} byte cap`, 'PAYLOAD_TOO_LARGE'),
new AppError(413, `File exceeds ${effectiveCap} byte cap`, 'PAYLOAD_TOO_LARGE'),
);
}
chunks.push(Buffer.from(value));

View File

@@ -27,17 +27,24 @@ export const GET = withAuth(
const id = params.id!;
const content = await getSalesContentConfig(ctx.portId);
const storageKey = await generateBrochureStorageKey(ctx.portId, id);
const maxBytes = content.brochureMaxUploadMb * 1024 * 1024;
const storage = await getStorageBackend();
const { url } = await storage.presignUpload(storageKey, {
expirySeconds: 900,
contentType: 'application/pdf',
// Bind the token to the port (engages the filesystem proxy `p`
// port-namespace assertion) - audit L22.
portSlug: ctx.portSlug,
// Embed the per-port cap so the filesystem proxy PUT enforces the
// advertised brochure cap rather than the global 50 MB - audit M17.
maxBytes,
});
return NextResponse.json({
data: {
storageKey,
uploadUrl: url,
method: 'PUT',
maxBytes: content.brochureMaxUploadMb * 1024 * 1024,
maxBytes,
},
});
} catch (error) {

View File

@@ -1,7 +1,14 @@
/**
* Returns a presigned URL the browser can use to PUT a PDF directly to the
* active storage backend. The URL is constrained by content-length-range up
* to `system_settings.berth_pdf_max_upload_mb` (default 15 MB) per §11.1.
* active storage backend. `maxBytes` (from `system_settings.berth_pdf_max_upload_mb`,
* default 15 MB per §11.1) is returned to the client as a hint and used to
* early-reject an oversized `sizeBytes` before a URL is minted.
*
* NOTE (audit M16/M17): the S3 presigned-PUT path does NOT sign a
* content-length-range or Content-Type condition, so the cap is enforced
* server-side at register time (`uploadBerthPdf` re-HEADs + magic-byte
* probes and rejects over-cap bytes). The filesystem proxy path embeds the
* cap in the HMAC token (`b` field) and enforces it in the proxy PUT.
*
* For S3 backends this is a true signed URL; for filesystem backends it's a
* CRM-internal proxy URL with an HMAC token (see `FilesystemBackend`).
@@ -67,6 +74,9 @@ export const postHandler: RouteHandler = async (req, ctx, params) => {
contentType: 'application/pdf',
expirySeconds: 900,
portSlug: ctx.portSlug,
// Embed the per-port cap in the filesystem proxy token so the proxy
// PUT enforces the advertised 15 MB (not the global 50 MB) - audit M17.
maxBytes,
});
return NextResponse.json({