|
| 1 | +import { z } from "zod"; |
1 | 2 | import { AUDIT_ACTIONS, type AuditActionKey } from "~/modules/audit"; |
2 | 3 | import { getCurrentYear } from "~/modules/domain"; |
3 | 4 | import { parseSiren } from "~/modules/shared/parseSiren"; |
@@ -42,6 +43,7 @@ function isFlowType(value: string | null): value is FlowType { |
42 | 43 | * - 403 — declaration for current year not found (not owned by session SIREN) |
43 | 44 | * - 400 — cse_opinion max files reached |
44 | 45 | * - 422 — ClamAV detected a virus (body includes `virus` name) |
| 46 | + * - 499 — client closed the request before the upload finished |
45 | 47 | * - 503 — ClamAV unreachable / timed out (transient, user should retry) |
46 | 48 | * - 500 — S3 or DB failure after stream; compensating delete attempted |
47 | 49 | * |
@@ -195,6 +197,7 @@ export async function POST(request: Request): Promise<Response> { |
195 | 197 | contentType, |
196 | 198 | stream: request.body, |
197 | 199 | flowType, |
| 200 | + signal: request.signal, |
198 | 201 | }); |
199 | 202 | } catch (error) { |
200 | 203 | console.error("[api/upload]", error); |
@@ -223,11 +226,11 @@ export async function POST(request: Request): Promise<Response> { |
223 | 226 | userId, |
224 | 227 | userEmail, |
225 | 228 | siren, |
226 | | - metadata: { |
| 229 | + metadata: uploadAuditMetadataSchema.parse({ |
227 | 230 | flowType, |
228 | 231 | fileId: result.fileId, |
229 | 232 | fileName: result.fileName, |
230 | | - }, |
| 233 | + }), |
231 | 234 | ipAddress: requestContext.ipAddress, |
232 | 235 | userAgent: requestContext.userAgent, |
233 | 236 | durationMs: Date.now() - startedAt, |
@@ -296,11 +299,48 @@ function mapFailureToHttp(reason: PipelineFailureReason): { |
296 | 299 | return { status: 422, errorMessage: "HTTP 422 virus_detected" }; |
297 | 300 | case "scan_unavailable": |
298 | 301 | return { status: 503, errorMessage: "HTTP 503 antivirus_unavailable" }; |
| 302 | + case "aborted": |
| 303 | + // 499 is the nginx-style "client closed request" status. The body is |
| 304 | + // never consumed by the client (they are gone), so the status is |
| 305 | + // purely for server-side observability. |
| 306 | + return { status: 499, errorMessage: "HTTP 499 client_aborted" }; |
299 | 307 | case "server_error": |
300 | 308 | return { status: 500, errorMessage: "HTTP 500 server_error" }; |
301 | 309 | } |
302 | 310 | } |
303 | 311 |
|
| 312 | +/** |
| 313 | + * Strips control characters (including ANSI escape sequences) and clips to |
| 314 | + * 255 chars before a user-supplied string is persisted to the audit log or |
| 315 | + * echoed to a terminal via console.error. Defence-in-depth: a crafted header |
| 316 | + * could carry escape sequences that mislead log readers. |
| 317 | + */ |
| 318 | +function sanitizeUserText(value: string): string { |
| 319 | + let out = ""; |
| 320 | + for (const ch of value) { |
| 321 | + const code = ch.codePointAt(0) ?? 0; |
| 322 | + if (code >= 0x20 && code !== 0x7f) out += ch; |
| 323 | + } |
| 324 | + return out.slice(0, 255); |
| 325 | +} |
| 326 | + |
| 327 | +/** |
| 328 | + * Audit metadata schema for /api/upload. Fields sourced from user input |
| 329 | + * (fileName, virusName) use `sanitizedUserString` so future additions to the |
| 330 | + * schema are sanitised by construction — a new untrusted string just has to |
| 331 | + * reuse the same transform. Internal literals (flowType, fileId, s3Cleanup) |
| 332 | + * are already constrained and do not need sanitisation. |
| 333 | + */ |
| 334 | +const sanitizedUserString = z.string().transform(sanitizeUserText); |
| 335 | + |
| 336 | +const uploadAuditMetadataSchema = z.object({ |
| 337 | + flowType: z.enum(["cse_opinion", "joint_evaluation"]), |
| 338 | + fileId: z.string().optional(), |
| 339 | + fileName: sanitizedUserString.optional(), |
| 340 | + virusName: sanitizedUserString.optional(), |
| 341 | + s3Cleanup: z.enum(["ok", "failed"]).optional(), |
| 342 | +}); |
| 343 | + |
304 | 344 | type AuditFailureInput = { |
305 | 345 | action: AuditActionKey; |
306 | 346 | flowType: FlowType; |
@@ -330,11 +370,13 @@ function writeFailure({ |
330 | 370 | virusName = null, |
331 | 371 | s3Cleanup = null, |
332 | 372 | }: AuditFailureInput): void { |
333 | | - const metadata: Record<string, unknown> = { flowType }; |
334 | | - if (fileName) metadata.fileName = fileName; |
335 | | - if (fileId) metadata.fileId = fileId; |
336 | | - if (virusName) metadata.virusName = virusName; |
337 | | - if (s3Cleanup) metadata.s3Cleanup = s3Cleanup; |
| 373 | + const metadata = uploadAuditMetadataSchema.parse({ |
| 374 | + flowType, |
| 375 | + fileName: fileName ?? undefined, |
| 376 | + fileId: fileId ?? undefined, |
| 377 | + virusName: virusName ?? undefined, |
| 378 | + s3Cleanup: s3Cleanup ?? undefined, |
| 379 | + }); |
338 | 380 |
|
339 | 381 | void logAction({ |
340 | 382 | action, |
|
0 commit comments