From cfcc06cf14dbfc19c54aa8985539e941b82239c2 Mon Sep 17 00:00:00 2001 From: Knacky Date: Fri, 15 May 2026 09:48:23 +0200 Subject: [PATCH] fix(m7): surface evidence whitelist in UI + filter the OS file picker MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reported by the user: the blue-side dropzone said "≤ 25 MB each" but nowhere did it list the accepted extensions, and the OS file picker showed "All files" — so an operator could spend the time picking a `.exe` only to get a 400 back. - New constants `EVIDENCE_ALLOWED_EXTENSIONS` + `EVIDENCE_MAX_BYTES` in `lib/missions.ts`. Manual mirror of the backend whitelist (commented cross-reference). One source of truth on the client. - Dropzone now prints `Accepted: .png · .jpg · .jpeg · .pdf · .txt · .log · .json · .csv · .evtx · .zip · max 25 MB / file` (testid `evidence-allowed-formats`). - File input gains `accept=".png,.jpg,..."` so the OS picker pre-filters to those extensions instead of "All files". - `handleFiles` rejects drag-and-drops of unsupported extensions on the client too (still re-checked server-side — defence in depth, not a security boundary). Co-Authored-By: Claude Opus 4.7 (1M context) --- frontend/src/lib/missions.ts | 21 ++++++++++++++++ frontend/src/pages/MissionTestPage.tsx | 33 ++++++++++++++++++++++---- 2 files changed, 50 insertions(+), 4 deletions(-) diff --git a/frontend/src/lib/missions.ts b/frontend/src/lib/missions.ts index 1b43c32..8e5251b 100644 --- a/frontend/src/lib/missions.ts +++ b/frontend/src/lib/missions.ts @@ -267,6 +267,27 @@ export const missionTestKeys = { detectionLevels: () => ['detection-levels'] as const, }; +/** + * Evidence upload constraints. Mirror of the backend whitelist in + * `app/services/evidence.py` — kept in sync by hand because exposing the + * full whitelist via an endpoint would be a one-trip-too-many on every + * page mount. If the backend list changes, update this array too. + */ +export const EVIDENCE_ALLOWED_EXTENSIONS = [ + '.png', + '.jpg', + '.jpeg', + '.pdf', + '.txt', + '.log', + '.json', + '.csv', + '.evtx', + '.zip', +] as const; + +export const EVIDENCE_MAX_BYTES = 25 * 1024 * 1024; + export const MISSION_TEST_STATE_LABEL: Record = { pending: 'Pending', executed: 'Executed', diff --git a/frontend/src/pages/MissionTestPage.tsx b/frontend/src/pages/MissionTestPage.tsx index 3544ae7..595d087 100644 --- a/frontend/src/pages/MissionTestPage.tsx +++ b/frontend/src/pages/MissionTestPage.tsx @@ -38,6 +38,8 @@ import { } from '@/lib/api'; import { useAuth } from '@/lib/auth'; import { + EVIDENCE_ALLOWED_EXTENSIONS, + EVIDENCE_MAX_BYTES, MISSION_TEST_STATE_ACCENT, MISSION_TEST_STATE_LABEL, VALID_TEST_TRANSITIONS, @@ -403,10 +405,19 @@ function BlueZone({ test, missionId, canWriteBlue, detectionLevels }: BlueZonePr function handleFiles(files: FileList | null) { if (!files || files.length === 0) return; setDropError(null); - // Per spec §M7, max 25 MB per file is server-enforced; this is just a - // friendly UX guardrail so the user does not waste a roundtrip. + // Per spec §M7, max 25 MB + ext whitelist are server-enforced; the client + // checks too so a drag-and-drop of a `.exe` doesn't waste a roundtrip + // and the operator gets immediate feedback. for (const f of Array.from(files)) { - if (f.size > 25 * 1024 * 1024) { + const dotIdx = f.name.lastIndexOf('.'); + const ext = dotIdx >= 0 ? f.name.slice(dotIdx).toLowerCase() : ''; + if (!ext || !(EVIDENCE_ALLOWED_EXTENSIONS as readonly string[]).includes(ext)) { + setDropError( + `"${f.name}" has an unsupported extension. Accepted: ${EVIDENCE_ALLOWED_EXTENSIONS.join(', ')}.`, + ); + continue; + } + if (f.size > EVIDENCE_MAX_BYTES) { setDropError(`"${f.name}" exceeds the 25 MB limit.`); continue; } @@ -494,13 +505,27 @@ function BlueZone({ test, missionId, canWriteBlue, detectionLevels }: BlueZonePr >

{canWriteBlue - ? 'Drag files here or click to pick (≤ 25 MB each)' + ? 'Drag files here or click to pick' : 'Read-only — no upload permission'}

+ {canWriteBlue && ( +

+ Accepted: {EVIDENCE_ALLOWED_EXTENSIONS.join(' · ')} ·{' '} + max 25 MB / file +

+ )} handleFiles(e.target.files)} data-testid="evidence-file-input"