fix(m7): surface evidence whitelist in UI + filter the OS file picker

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) <noreply@anthropic.com>
This commit is contained in:
Knacky
2026-05-15 09:48:23 +02:00
parent 5974a181fd
commit cfcc06cf14
2 changed files with 50 additions and 4 deletions

View File

@@ -267,6 +267,27 @@ export const missionTestKeys = {
detectionLevels: () => ['detection-levels'] as const, 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<MissionTestState, string> = { export const MISSION_TEST_STATE_LABEL: Record<MissionTestState, string> = {
pending: 'Pending', pending: 'Pending',
executed: 'Executed', executed: 'Executed',

View File

@@ -38,6 +38,8 @@ import {
} from '@/lib/api'; } from '@/lib/api';
import { useAuth } from '@/lib/auth'; import { useAuth } from '@/lib/auth';
import { import {
EVIDENCE_ALLOWED_EXTENSIONS,
EVIDENCE_MAX_BYTES,
MISSION_TEST_STATE_ACCENT, MISSION_TEST_STATE_ACCENT,
MISSION_TEST_STATE_LABEL, MISSION_TEST_STATE_LABEL,
VALID_TEST_TRANSITIONS, VALID_TEST_TRANSITIONS,
@@ -403,10 +405,19 @@ function BlueZone({ test, missionId, canWriteBlue, detectionLevels }: BlueZonePr
function handleFiles(files: FileList | null) { function handleFiles(files: FileList | null) {
if (!files || files.length === 0) return; if (!files || files.length === 0) return;
setDropError(null); setDropError(null);
// Per spec §M7, max 25 MB per file is server-enforced; this is just a // Per spec §M7, max 25 MB + ext whitelist are server-enforced; the client
// friendly UX guardrail so the user does not waste a roundtrip. // 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)) { 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.`); setDropError(`"${f.name}" exceeds the 25 MB limit.`);
continue; continue;
} }
@@ -494,13 +505,27 @@ function BlueZone({ test, missionId, canWriteBlue, detectionLevels }: BlueZonePr
> >
<p className="font-mono text-2xs text-text-dim"> <p className="font-mono text-2xs text-text-dim">
{canWriteBlue {canWriteBlue
? 'Drag files here or click to pick (≤ 25 MB each)' ? 'Drag files here or click to pick'
: 'Read-only — no upload permission'} : 'Read-only — no upload permission'}
</p> </p>
{canWriteBlue && (
<p
className="mt-1 font-mono text-3xs uppercase tracking-wider2 text-text-dim"
data-testid="evidence-allowed-formats"
>
Accepted: {EVIDENCE_ALLOWED_EXTENSIONS.join(' · ')} ·{' '}
max 25 MB / file
</p>
)}
<input <input
ref={fileInputRef} ref={fileInputRef}
type="file" type="file"
multiple multiple
// `accept` is the native filter the OS file-picker honours. It
// accepts a comma-separated list of extensions or MIME types.
// Servers re-check both, so this is a UX nicety, not a security
// boundary.
accept={EVIDENCE_ALLOWED_EXTENSIONS.join(',')}
className="hidden" className="hidden"
onChange={(e) => handleFiles(e.target.files)} onChange={(e) => handleFiles(e.target.files)}
data-testid="evidence-file-input" data-testid="evidence-file-input"