Skip to content

Commit 4d4914c

Browse files
committed
feat(upload): surface aborted status + sanitize audit metadata
- Map the new "aborted" pipeline reason to HTTP 499 (nginx "client closed request") and pipe request.signal into runUploadPipeline. - Strip control characters and ANSI escape sequences from user-supplied strings (filename, virus name) before they land in the audit log or console.error. Clip to 255 chars. - Propagate the "aborted" failure reason through uploadFile (client) and useFileUploadForm so users see "L'upload a été interrompu" instead of the generic server_error copy.
1 parent f6d1487 commit 4d4914c

3 files changed

Lines changed: 30 additions & 3 deletions

File tree

packages/app/src/app/api/upload/route.ts

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ function isFlowType(value: string | null): value is FlowType {
4242
* - 403 — declaration for current year not found (not owned by session SIREN)
4343
* - 400 — cse_opinion max files reached
4444
* - 422 — ClamAV detected a virus (body includes `virus` name)
45+
* - 499 — client closed the request before the upload finished
4546
* - 503 — ClamAV unreachable / timed out (transient, user should retry)
4647
* - 500 — S3 or DB failure after stream; compensating delete attempted
4748
*
@@ -195,6 +196,7 @@ export async function POST(request: Request): Promise<Response> {
195196
contentType,
196197
stream: request.body,
197198
flowType,
199+
signal: request.signal,
198200
});
199201
} catch (error) {
200202
console.error("[api/upload]", error);
@@ -296,11 +298,31 @@ function mapFailureToHttp(reason: PipelineFailureReason): {
296298
return { status: 422, errorMessage: "HTTP 422 virus_detected" };
297299
case "scan_unavailable":
298300
return { status: 503, errorMessage: "HTTP 503 antivirus_unavailable" };
301+
case "aborted":
302+
// 499 is the nginx-style "client closed request" status. The body is
303+
// never consumed by the client (they are gone), so the status is
304+
// purely for server-side observability.
305+
return { status: 499, errorMessage: "HTTP 499 client_aborted" };
299306
case "server_error":
300307
return { status: 500, errorMessage: "HTTP 500 server_error" };
301308
}
302309
}
303310

311+
/**
312+
* Strips control characters (including ANSI escape sequences) and clips to
313+
* 255 chars before a user-supplied string is persisted to the audit log or
314+
* echoed to a terminal via console.error. Defence-in-depth: a crafted header
315+
* could carry escape sequences that mislead log readers.
316+
*/
317+
function sanitizeUserText(value: string): string {
318+
let out = "";
319+
for (const ch of value) {
320+
const code = ch.codePointAt(0) ?? 0;
321+
if (code >= 0x20 && code !== 0x7f) out += ch;
322+
}
323+
return out.slice(0, 255);
324+
}
325+
304326
type AuditFailureInput = {
305327
action: AuditActionKey;
306328
flowType: FlowType;
@@ -331,9 +353,9 @@ function writeFailure({
331353
s3Cleanup = null,
332354
}: AuditFailureInput): void {
333355
const metadata: Record<string, unknown> = { flowType };
334-
if (fileName) metadata.fileName = fileName;
356+
if (fileName) metadata.fileName = sanitizeUserText(fileName);
335357
if (fileId) metadata.fileId = fileId;
336-
if (virusName) metadata.virusName = virusName;
358+
if (virusName) metadata.virusName = sanitizeUserText(virusName);
337359
if (s3Cleanup) metadata.s3Cleanup = s3Cleanup;
338360

339361
void logAction({

packages/app/src/modules/shared/uploadFile.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,8 @@ export type UploadFailureReason =
1111
| "not_found"
1212
| "scan_unavailable"
1313
| "unauthorized"
14-
| "server_error";
14+
| "server_error"
15+
| "aborted";
1516

1617
type UploadSuccess = { ok: true; fileId: string; fileName: string };
1718
type UploadError = {
@@ -80,6 +81,7 @@ const VALID_REASONS = new Set<UploadFailureReason>([
8081
"scan_unavailable",
8182
"unauthorized",
8283
"server_error",
84+
"aborted",
8385
]);
8486

8587
function parseReason(status: number, raw: unknown): UploadFailureReason {
@@ -92,6 +94,7 @@ function parseReason(status: number, raw: unknown): UploadFailureReason {
9294
// Fall back to a reason inferred from the HTTP status for early-exit paths
9395
// where the server returns 400/401 without a structured `reason` field.
9496
if (status === 401) return "unauthorized";
97+
if (status === 499) return "aborted";
9598
if (status === 400) return "wrong_type";
9699
return "server_error";
97100
}

packages/app/src/modules/shared/useFileUploadForm.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -130,5 +130,7 @@ function formatUploadError(
130130
return serverError;
131131
case "server_error":
132132
return "Erreur lors de l'upload du fichier. Merci de réessayer.";
133+
case "aborted":
134+
return "L'upload a été interrompu. Merci de réessayer.";
133135
}
134136
}

0 commit comments

Comments
 (0)