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

@@ -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
>
<p className="font-mono text-2xs text-text-dim">
{canWriteBlue
? 'Drag files here or click to pick (≤ 25 MB each)'
? 'Drag files here or click to pick'
: 'Read-only — no upload permission'}
</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
ref={fileInputRef}
type="file"
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"
onChange={(e) => handleFiles(e.target.files)}
data-testid="evidence-file-input"