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"